
Kicad Schematic
- 271 installs
- 1 repo stars
- Updated February 26, 2026
- kenchangh/kicad-schematic
Helps with ai & agent building tasks.
About
kicad-schematic is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- kicad-schematic
- AI & Agent Building
- AI-coding skill
Kicad Schematic by the numbers
- 271 all-time installs (skills.sh)
- +15 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #2,441 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/kenchangh/kicad-schematic --skill kicad-schematicAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 271 |
|---|---|
| repo stars | ★ 1 |
| Last updated | February 26, 2026 |
| Repository | kenchangh/kicad-schematic ↗ |
What it does
Helps with ai & agent building tasks.
Files
KiCad Schematic Agent
Generate ERC-clean KiCad 8/9 schematics by writing Python scripts that use computed pin positions — never guess coordinates. Also fix ERC errors on existing schematics and handle KiCad 8→9 migration.
Critical Principle
The #1 cause of broken schematics is guessed pin positions. When connecting labels to IC pins, you MUST compute exact coordinates using the symbol definition's pin positions and the coordinate transform formula. The helper library in scripts/kicad_sch_helpers.py does this automatically.
The Y-axis Trap (Most Common Bug)
Symbol libraries (.kicad_sym) use Y-up (math convention). Schematics (.kicad_sch) use Y-down (screen convention). This means you MUST negate the Y coordinate when transforming from library to schematic space. Forgetting this will place labels 10-50mm away from their pins, causing massive pin_not_connected and label_dangling errors.
Transform formula — pin at library (px, py), symbol placed at schematic (sx, sy) with rotation R:
- Rotation 0: schematic position = (sx + px, sy - py)
- Rotation 90: schematic position = (sx + py, sy + px)
- Rotation 180: schematic position = (sx - px, sy + py)
- Rotation 270: schematic position = (sx - py, sy - px)
Always use pin_abs() from the helper library — never compute these by hand.
Architecture
User describes circuit
|
Read symbol libraries (.kicad_sym) to get pin positions
|
Build pin position dictionaries for every multi-pin IC
|
Write Python script using SchematicBuilder (from helper library)
- Use connect_pin() for IC pins (computes positions automatically)
- Use place_2pin_vertical() for passives (knows pin 1/2 positions)
|
Generate .kicad_sch file
|
Post-process with fix_subsymbol_names()
|
Run ERC validation: kicad-cli sch erc --format json
|
Parse errors -> fix script -> regenerate -> repeat (max 5 iterations)Step-by-step Workflow
0. Ensure kicad-cli is Available
Before running any ERC validation, verify that kicad-cli is on the system PATH. Run:
which kicad-cli 2>/dev/null || where kicad-cli 2>/dev/nullIf not found, check for a local KiCad installation and offer to create a symlink:
macOS:
# Check if KiCad is installed as an app
KICAD_CLI="/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli"
if [ -f "$KICAD_CLI" ]; then
echo "Found kicad-cli inside KiCad.app. Creating symlink..."
sudo ln -sf "$KICAD_CLI" /usr/local/bin/kicad-cli
echo "Done! kicad-cli is now available on PATH."
else
echo "KiCad not found. Install from https://www.kicad.org/download/macos/"
fiLinux:
# kicad-cli is typically installed alongside KiCad via package manager
# Check common locations
for p in /usr/bin/kicad-cli /usr/local/bin/kicad-cli /snap/kicad/current/bin/kicad-cli; do
if [ -f "$p" ]; then
echo "Found kicad-cli at $p"
# If not on PATH, symlink it
if ! command -v kicad-cli &>/dev/null; then
sudo ln -sf "$p" /usr/local/bin/kicad-cli
fi
break
fi
done
# If still not found:
# Ubuntu/Debian: sudo apt install kicad
# Fedora: sudo dnf install kicad
# Arch: sudo pacman -S kicad
# Or install from https://www.kicad.org/download/linux/Windows:
# Check standard install path
$kicadCli = "C:\Program Files\KiCad\8.0\bin\kicad-cli.exe"
if (Test-Path $kicadCli) {
Write-Host "Found kicad-cli. Add to PATH:"
Write-Host ' [Environment]::SetEnvironmentVariable("PATH", $env:PATH + ";C:\Program Files\KiCad\8.0\bin", "User")'
} else {
Write-Host "KiCad not found. Install from https://www.kicad.org/download/windows/"
}Tell the user what you found and ask for confirmation before creating any symlinks. If kicad-cli is truly not installed, provide the download link for their OS and stop — ERC validation requires it.
1. Understand the Circuit
Before writing any code, gather:
- Component list with specific part numbers
- Power architecture (voltage rails, regulators)
- Signal connections (which pins connect to which)
- Symbol libraries needed (standard KiCad libs + any custom .kicad_sym files)
2. Read Symbol Libraries (NON-NEGOTIABLE)
For every IC and multi-pin component, read its .kicad_sym definition to get exact pin names, numbers, positions, and types. You cannot connect pins correctly without this data.
from kicad_sch_helpers import SymbolLibrary
lib = SymbolLibrary()
lib.load_from_kicad_sym("path/to/library.kicad_sym")
# Now you know exact pin positions
ad9363 = lib.get("AD9363ABCZ")
for pin in ad9363.pins:
print(f"{pin.name} ({pin.number}): at ({pin.x}, {pin.y}), type={pin.pin_type}")For manual/inline approaches, build a pin dictionary from the library:
# Extract from .kicad_sym file — these are LIBRARY coordinates (Y-up)
AD_PINS = {
'TX1A_P': (-17.78, 25.40),
'TX1A_N': (-17.78, 22.86),
'SPI_CLK': (-17.78, -10.16),
# ... all pins
}
# SOT-23-5 packages (common for LDOs like AP2112K, ME6211):
SOT5_PINS = {
'VIN': (-7.62, 2.54), # Pin 1 - top left
'GND': ( 0.00, -7.62), # Pin 2 - bottom center
'EN': (-7.62, -2.54), # Pin 3 - bottom left
'NC': ( 7.62, -2.54), # Pin 4 - bottom right <- NOT VOUT!
'VOUT': ( 7.62, 2.54), # Pin 5 - top right <- THIS is VOUT!
}WARNING — SOT-23-5 pin trap: VOUT is at (7.62, +2.54) and NC is at (7.62, -2.54). These are only 5.08mm apart. Confusing them means your LDO output goes nowhere. Always verify from the actual library file.
3. Write the Generator Script
Use SchematicBuilder for all schematic construction. The key method is connect_pin() which computes exact pin positions automatically:
from kicad_sch_helpers import SchematicBuilder, SymbolLibrary, snap
lib = SymbolLibrary()
lib.load_from_kicad_sym("custom_symbols.kicad_sym")
sch = SchematicBuilder(symbol_lib=lib, project_name="my_project")
sch.set_lib_symbols(lib_symbols_content) # Raw S-expression for embedded symbols
# Place an IC
sch.place("CubeSat_SDR:AD9363ABCZ", "U1", "AD9363ABCZ",
x=320, y=200, footprint="CubeSat_SDR:AD9363_BGA144")
# Connect pins by NAME — coordinates computed automatically
sch.connect_pin("U1", "TX1A_P", "TX1A_P", wire_dx=-5.08)
sch.connect_pin("U1", "SPI_CLK", "SPI_CLK", wire_dy=-5.08)
sch.connect_pin("U1", "GND", "GND", wire_dy=5.08)
# For unused pins, add no-connect flags
sch.connect_pin_noconnect("U1", "AUXDAC1")
# For 2-pin passives, use convenience helpers
from kicad_sch_helpers import place_2pin_vertical
place_2pin_vertical(sch, "Device:C", "C1", "100nF",
x=snap(230), y=snap(155),
top_net="VCC_3V3", bottom_net="GND",
footprint="Capacitor_SMD:C_0402_1005Metric")If using inline pin dictionaries (without SymbolLibrary), use pin_abs():
from kicad_sch_helpers import pin_abs, snap
GRID = 1.27
def wl(sch, sx, sy, pin_name, pins_dict, net, dx=0, dy=0, rot=0, label_angle=0):
"""Wire + Label: connect an IC pin to a net label."""
px, py = pin_abs(sx, sy, pins_dict[pin_name][0], pins_dict[pin_name][1], rot)
ex, ey = snap(px + dx), snap(py + dy)
if dx != 0 or dy != 0:
sch.w(px, py, ex, ey)
sch.label(net, ex, ey, label_angle)
# Usage:
wl(sch, 320, 200, 'TX1A_P', AD_PINS, "TX1A_P", dx=-7.62)4. Handle the lib_symbols Section
Every symbol referenced must be embedded in the schematic's lib_symbols. Three critical rules:
1. Parent symbols use the full lib_id: (symbol "Device:R" ...) 2. Sub-symbols must NOT have the library prefix: (symbol "R_0_1" ...) not (symbol "Device:R_0_1" ...) 3. Always post-process with fix_subsymbol_names() to catch any mistakes
Use fix_subsymbol_names() as a post-processing step:
from kicad_sch_helpers import fix_subsymbol_names
content = sch.build(title="My Schematic")
content = fix_subsymbol_names(content)The regex-based fixer handles nested sub-symbols at any depth and any library prefix format.
5. Grid Snapping (Prevents 90% of Warnings)
Every coordinate in the schematic must be a multiple of 1.27mm. Use snap():
from kicad_sch_helpers import snap
# snap() rounds to nearest 1.27mm grid point
x = snap(123.45) # -> 124.46 (nearest multiple of 1.27)Apply snap() to: component positions, wire endpoints, label positions, no-connect positions, and PWR_FLAG positions. The connect_pin() and pin_abs() functions do this automatically.
6. Add PWR_FLAG Symbols
For every power net that originates from a voltage regulator (not a power symbol), add a PWR_FLAG to prevent "power_pin_not_driven" errors:
# Define PWR_FLAG in lib_symbols (see references/kicad_sexpression_format.md)
# Then place on each power net:
sch.place_pwr_flag(x=70, y=78, net_name="VCC_3V3A")Rule of thumb: If a net is driven by a component whose output pin type is passive (not power_out), that net needs a PWR_FLAG. This includes most LDO regulators.
Also place PWR_FLAG on GND nets that don't have a dedicated GND power symbol driving them.
7. No-Connect Flags
Every unused pin on every IC MUST have a no-connect flag. Missing no-connect flags cause pin_not_connected errors.
# Using SchematicBuilder:
sch.connect_pin_noconnect("U1", "AUXDAC1")
# Or manually with pin_abs:
px, py = pin_abs(sx, sy, pin_x, pin_y, rotation)
sch.nc(px, py)8. Validate with kicad-cli
from kicad_sch_helpers import run_erc
result = run_erc("output/schematic.kicad_sch")
print(f"Errors: {result['errors']}, Warnings: {result['warnings']}")
if result['errors'] > 0:
for detail in result['details']:
if detail.get('severity') == 'error':
print(f" {detail['type']}: {detail.get('description', '')}")9. Automated Fix Loop
For complex schematics, use the validation loop:
from kicad_sch_helpers import validate_and_fix_loop
def my_fixer(erc_result, iteration):
"""Analyze ERC errors and apply fixes. Return True if fixes applied."""
error_types = erc_result.get('error_types', {})
if 'pin_not_connected' in error_types:
# Read the schematic, find unconnected pins, add connections
# ... fix logic ...
return True
if 'label_dangling' in error_types:
# Move labels to correct pin positions
# ... fix logic ...
return True
return False # No fixable errors found
final = validate_and_fix_loop("output/schematic.kicad_sch", my_fixer)Fixing ERC on Existing Schematics
When the user has an existing .kicad_sch with ERC errors (not generating a new schematic), use this workflow.
Step 1: Run ERC with JSON Output
Always use --format json -o file.json --severity-all. Never pipe kicad-cli output to stdout — it writes JSON to the file specified by -o.
On macOS, kicad-cli needs environment variables to find the standard libraries:
KICAD9_SYMBOL_DIR="/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols" \
KICAD9_FOOTPRINT_DIR="/Applications/KiCad/KiCad.app/Contents/SharedSupport/footprints" \
/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli sch erc \
--format json --severity-all -o /tmp/erc_result.json schematic.kicad_schOr use the helper:
from kicad_sch_helpers import run_erc
result = run_erc("schematic.kicad_sch", env_vars={
"KICAD9_SYMBOL_DIR": "/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols",
"KICAD9_FOOTPRINT_DIR": "/Applications/KiCad/KiCad.app/Contents/SharedSupport/footprints",
})Step 2: Parse and Categorize Violations
import json
with open("/tmp/erc_result.json") as f:
report = json.load(f)
violations = []
for sheet in report.get("sheets", []):
violations.extend(sheet.get("violations", []))
# Categorize by severity and type
errors = [v for v in violations if v.get("severity") == "error"]
warnings = [v for v in violations if v.get("severity") == "warning"]
by_type = {}
for v in violations:
t = v.get("type", "unknown")
by_type.setdefault(t, []).append(v)
print(f"Errors: {len(errors)}, Warnings: {len(warnings)}")
for t, items in sorted(by_type.items()):
print(f" {t}: {len(items)}")Note: The JSON format nests violations under sheets[].violations[], not at the top level.
Step 3: Write Targeted Python Fix Scripts
Never manually edit `.kicad_sch` files — always write Python scripts. The s-expression format is sensitive to whitespace and parenthesis balance.
Key utility functions (all in scripts/kicad_sch_helpers.py):
from kicad_sch_helpers import (
find_block, # Find balanced parenthesized block
remove_block_with_whitespace, # Clean removal preserving formatting
extract_embedded_symbol, # Extract from lib_symbols section
convert_embedded_to_library, # Embedded → standalone library format
find_by_uuid, # Locate element by UUID
remove_by_uuid, # Remove element by UUID
replace_lib_id, # Bulk lib_id replacement
replace_footprint, # Bulk footprint replacement
fix_annotation_suffixes, # Add numeric suffixes to bare refs
create_pwr_flag_block, # Generate PWR_FLAG s-expression
)Step 4: Common Fix Patterns
Remove a symbol by UUID:
content = remove_by_uuid(content, "uuid-string", "symbol")Replace a lib_id across all instances:
content = replace_lib_id(content, "Connector:Conn_01x04", "CubeSat_SDR:Conn_01x04")Add PWR_FLAG to fix power_pin_not_driven:
pwr_block = create_pwr_flag_block(
x=34.29, y=77.47, ref_num=7,
project_name="my_project",
root_uuid="5fb33c66-7637-43ae-9eef-34b4f23f6cfb"
)
# Insert before the final closing paren
last_close = content.rstrip().rfind(')')
content = content[:last_close] + '\n' + pwr_block + '\n' + content[last_close:]Suppress warnings in .kicad_pro:
import json
with open("project.kicad_pro") as f:
pro = json.load(f)
pro["erc"]["rule_severities"]["lib_symbol_mismatch"] = "ignore"
with open("project.kicad_pro", "w") as f:
json.dump(pro, f, indent=2)
f.write('\n')Step 5: Iterative Fix-Verify Loop
Always run ERC after each batch of fixes. Some fixes expose new issues: 1. Run ERC → parse results → categorize 2. Fix the highest-priority errors first (see error priority in references) 3. Run ERC again → verify error count dropped 4. Repeat until 0 errors (warnings may be suppressed if justified)
Back up the schematic before running fix scripts. Use shutil.copy2().
---
KiCad 8→9 Migration
When validating a KiCad 8 schematic with KiCad 9, expect these categories of issues:
Symbol Renames (lib_symbol_issues)
| KiCad 8 Name | KiCad 9 Name | Notes |
|---|---|---|
Connector:Conn_01x04 | Connector:Conn_01x04_Pin | All single-row connectors renamed |
Connector:Conn_01x06 | Connector:Conn_01x06_Pin | Same pattern |
Connector:Conn_02x20 | Connector:Conn_02x20_Pin | All dual-row connectors too |
Connector:SMA | Removed | Use custom library or Connector:Coaxial |
Connector:TestPoint | Connector:TestPoint (moved) | May have different pin layout |
Regulator_Linear:AMS1117 | Has 4 pins (added ADJ) | 3-pin schematic won't match |
Fix strategy: Create a project-level custom library with KiCad 8 symbol versions. Change lib_ids to point to the custom library. Do NOT try to update to KiCad 9 versions — pin positions differ and will break wire connections.
Pin Position Changes (lib_symbol_mismatch)
CRITICAL: Do NOT replace embedded Device:C/R/L symbols with KiCad 9 versions.
KiCad 9 changed passive pin positions:
- KiCad 8:
Device:Cpins at (0, ±2.54) - KiCad 9:
Device:Cpins at (0, ±3.81)
Replacing embedded symbols breaks every wire connected to every capacitor, resistor, and inductor. Instead, suppress lib_symbol_mismatch in .kicad_pro — the embedded KiCad 8 symbols work fine.
Footprint Renames (footprint_link_issues)
| KiCad 8 Footprint | KiCad 9 Footprint |
|---|---|
SW_Push_1P1T_NO_6x3.5mm | SW_Push_1P1T_NO_CK_PTS125Sx43SMTR |
SMA_Amphenol_901-143_Vertical | SMA_Amphenol_901-144_Vertical |
Use replace_footprint() for bulk updates.
Annotation Requirements
KiCad 9 requires all reference designators to end with a digit. References like C_RX1B_N or J_PWR will cause "Item not annotated" errors in the GUI (but NOT in CLI ERC).
from kicad_sch_helpers import fix_annotation_suffixes
content = fix_annotation_suffixes(content) # Adds "1" suffix to bare refsMigration Workflow
1. Run ERC → categorize violations 2. Create custom library with KiCad 8 symbol versions (extract from embedded lib_symbols) 3. Update lib_ids from standard libraries to custom library 4. Update renamed footprints 5. Fix annotation suffixes 6. Add PWR_FLAG for any new power_pin_not_driven errors 7. Suppress lib_symbol_mismatch in .kicad_pro (safe — embedded symbols still work) 8. Suppress multiple_net_names if intentional dual-naming exists 9. Run ERC again → verify 0 errors
---
Custom Library Management
When standard KiCad libraries change between versions, create project-level libraries to preserve compatibility.
Creating Project Library Tables
`sym-lib-table` (in project root):
(sym_lib_table
(version 7)
(lib (name "CubeSat_SDR")(type "KiCad")(uri "${KIPRJMOD}/libraries/cubesat_sdr.kicad_sym")(options "")(descr "Project custom symbols"))
)`fp-lib-table` (in project root):
(fp_lib_table
(version 7)
(lib (name "CubeSat_SDR")(type "KiCad")(uri "${KIPRJMOD}/libraries/cubesat_sdr.pretty")(options "")(descr "Project custom footprints"))
)Use ${KIPRJMOD} for portable paths — it resolves to the project directory.
Extracting Embedded Symbols to Library
When migrating from standard library symbols to custom ones:
from kicad_sch_helpers import extract_embedded_symbol, convert_embedded_to_library
# Read schematic
with open("schematic.kicad_sch") as f:
sch = f.read()
# Extract a symbol from the embedded lib_symbols section
block = extract_embedded_symbol(sch, "Connector:Conn_01x04")
# Convert from embedded format (prefix:Name) to library format (Name)
lib_block = convert_embedded_to_library(block, "Connector", "Conn_01x04")
# Append to custom library file (before the final closing paren)
with open("libraries/custom.kicad_sym") as f:
lib = f.read()
close_pos = lib.rstrip().rfind(')')
lib = lib[:close_pos] + '\t' + lib_block + '\n' + lib[close_pos:]
with open("libraries/custom.kicad_sym", "w") as f:
f.write(lib)Embedded vs Library Format
In .kicad_sch embedded lib_symbols: top-level is (symbol "Library:Name" ...), sub-symbols use (symbol "Name_0_1" ...).
In .kicad_sym library files: top-level is (symbol "Name" ...), sub-symbols use (symbol "Name_0_1" ...).
The only difference is the top-level name loses its library prefix.
---
Common Patterns
Decoupling Capacitor Array
for i, (ref, val) in enumerate(zip(refs, values)):
place_2pin_vertical(sch, "Device:C", ref, val,
x=snap(start_x + i * 8), y=snap(cap_y),
top_net=power_net, bottom_net="GND",
footprint=f"Capacitor_SMD:{fp}")2-Pin Passives (R, C, L)
Standard KiCad 2-pin passive symbols have:
- Pin 1 at library (0, 2.54) — top when vertical
- Pin 2 at library (0, -2.54) — bottom when vertical
With rotation 0 at schematic (sx, sy):
- Pin 1 schematic position: (sx, sy - 2.54)
- Pin 2 schematic position: (sx, sy + 2.54)
place_2pin_vertical(sch, "Device:C", "C1", "100nF",
x=snap(100), y=snap(150),
top_net="VCC_3V3", bottom_net="GND",
footprint="Capacitor_SMD:C_0402_1005Metric")Multi-pin IC Connection
# Always use connect_pin — never compute positions manually
signal_map = {
"TX1A_P": "TX1A_P",
"TX1A_N": "TX1A_N",
"SPI_CLK": "SPI_CLK",
# ... all signal pins
}
for pin_name, net_name in signal_map.items():
sch.connect_pin("U1", pin_name, net_name, wire_dx=-7.62)
# Power pins
for pin_name in ["VDDD1P3", "VDDA1P3", "VDDD1P8"]:
sch.connect_pin("U1", pin_name, f"VCC_{pin_name}", wire_dy=5.08)
# Unused pins — EVERY unused pin needs this
for pin_name in ["AUXDAC1", "AUXDAC2", "AUXADC", "TEMP_SENS"]:
sch.connect_pin_noconnect("U1", pin_name)Power Regulator with PWR_FLAG
sch.place("CubeSat_SDR:AP2112K", "U4", "AP2112K-3.3",
x=snap(100), y=snap(180),
footprint="Package_TO_SOT_SMD:SOT-23-5")
wl(sch, 100, 180, 'VIN', SOT5_PINS, "VCC_5V", dx=-7.62)
wl(sch, 100, 180, 'EN', SOT5_PINS, "VCC_5V", dx=-7.62)
wl(sch, 100, 180, 'GND', SOT5_PINS, "GND", dy=5.08)
wl(sch, 100, 180, 'VOUT', SOT5_PINS, "VCC_3V3", dx=7.62)
# NC pin — no-connect flag, NOT a label
nc_x, nc_y = pin_abs(100, 180, *SOT5_PINS['NC'])
sch.nc(nc_x, nc_y)
# PWR_FLAG on output net
sch.place_pwr_flag(x=snap(115), y=snap(178), net_name="VCC_3V3")Lessons Learned (Battle-Tested)
These lessons came from debugging a real 119-component CubeSat SDR schematic:
1. Never guess pin positions. Even being off by 1.27mm (one grid unit) causes ERC errors. Always read the .kicad_sym file and use computed positions.
2. The Y-axis flip is the #1 source of bugs. Library Y-up vs schematic Y-down means you must negate Y. A pin at library (0, 25.4) is at schematic (sx, sy - 25.4) — NOT (sx, sy + 25.4). Getting this wrong places labels 50mm from their pins.
3. SOT-23-5 VOUT vs NC confusion silently breaks LDO circuits. VOUT=(7.62, 2.54), NC=(7.62, -2.54). They differ only in Y sign. After the Y-flip, VOUT is above NC in the schematic. Always verify against the library.
4. Sub-symbol naming breaks KiCad silently. If you write (symbol "Device:R_0_1" ...) instead of (symbol "R_0_1" ...), KiCad may open the file but all symbols appear broken. Always run fix_subsymbol_names().
5. PWR_FLAG is needed more often than you think. Any net driven by a regulator with passive-type output pins needs one. Also add one on GND. Missing PWR_FLAGs cause power_pin_not_driven errors on every component on that net.
6. Grid snapping prevents hundreds of warnings. A single off-grid component cascades into off-grid warnings for all connected wires and labels. Snap everything from the start.
7. Account for every pin. Go through the pin list systematically. Every pin must be either: connected via wire+label, connected to a power symbol, or flagged with no_connect. Missing even one pin produces an error.
8. Parenthesis balance check. KiCad S-expressions must have perfectly balanced parentheses. Add a check at the end of generation:
depth = sum(1 if c == '(' else -1 if c == ')' else 0 for c in content)
assert depth == 0, f"Parenthesis imbalance: depth={depth}"9. Don't replace embedded Device:C/R/L with KiCad 9 versions. KiCad 9 changed passive pin positions from ±2.54 to ±3.81. Replacing embedded symbols breaks every wire connection. Suppress lib_symbol_mismatch instead.
10. kicad-cli JSON goes to file, not stdout. Always use -o /tmp/result.json. Piping to python gives empty stdin. The JSON is nested under sheets[].violations[], not at the top level.
11. CLI ERC doesn't check annotations. The GUI flags "Item not annotated" for references not ending with a digit (e.g., C_RX1B_N), but CLI ERC silently passes. Always run fix_annotation_suffixes() when migrating.
12. macOS kicad-cli needs environment variables. Without KICAD9_SYMBOL_DIR and KICAD9_FOOTPRINT_DIR, kicad-cli can't find the global libraries and produces false lib_symbol_issues warnings.
13. Don't use `grep -P` on macOS. PCRE mode is not supported. Use Python regex for all pattern matching on schematic files.
14. Create project-level library tables for custom symbols. Use ${KIPRJMOD} for portable paths. Extract embedded symbols to populate the library — don't rewrite them from scratch.
15. Back up before running fix scripts. A broken parenthesis balance renders the schematic unloadable. Use shutil.copy2() before any modifications.
16. Don't suppress ERC errors, only warnings. Suppressing lib_symbol_mismatch (warnings) is safe for KiCad 8→9 migration. Never suppress actual errors like pin_not_connected or power_pin_not_driven.
Reference Files
scripts/kicad_sch_helpers.py— Python helper library (always use this)references/kicad_sexpression_format.md— KiCad S-expression format specification, coordinate system, common ERC errors and fixes
Read references/kicad_sexpression_format.md before generating any schematic to understand the coordinate system, sub-symbol naming rules, and PWR_FLAG requirements.
Checklist Before Delivery
New Schematic Generation
1. All coordinates snapped to 1.27mm grid via snap() 2. Every IC pin either connected via connect_pin() / wl() or flagged with connect_pin_noconnect() / nc() 3. Sub-symbol names fixed with fix_subsymbol_names() 4. PWR_FLAG on every voltage regulator output net AND on GND 5. ERC validation run (0 errors target, warnings acceptable) 6. Parenthesis balance verified (depth 0 at end of file) 7. Pin positions verified against .kicad_sym library (not guessed) 8. SOT-23-5 VOUT vs NC positions double-checked for all LDOs
Existing Schematic Fixing / KiCad 9 Migration
9. All reference designators end with a digit (fix_annotation_suffixes()) 10. Renamed/missing symbols handled via project-level custom library 11. Embedded Device:C/R/L symbols NOT replaced (suppress lib_symbol_mismatch instead) 12. Renamed footprints updated (replace_footprint()) 13. PWR_FLAG added on all power input nets including external power (barrel jack, USB VBUS) 14. Schematic backed up before running any fix scripts 15. Environment variables set for kicad-cli on macOS (KICAD9_SYMBOL_DIR, KICAD9_FOOTPRINT_DIR) 16. ERC run with --severity-all to catch all warning types
.DS_Store
__pycache__/
*.pyc
*.pyo
*.egg-info/
dist/
build/
*.erc.json
*.kicad_sch
!references/*.kicad_sch
node_modules/
.env
MIT License
Copyright (c) 2026 Ken
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
kicad-schematic
An agent skill for generating, validating, and fixing KiCad 8 schematic files (.kicad_sch) programmatically. Eliminates the #1 source of broken schematics: guessed pin positions.
Install
npx skills add kicad-schematicOr from GitHub directly:
npx skills add kenchangh/kicad-schematicWhat it does
This skill teaches your AI coding agent to:
1. Read KiCad symbol libraries (.kicad_sym) to get exact pin positions 2. Write Python scripts using the SchematicBuilder helper — never manually guess coordinates 3. Generate .kicad_sch files with computed pin-label connectivity 4. Run ERC validation via kicad-cli sch erc --format json 5. Parse errors, fix the generator script, and re-run (up to 5 iterations)
Prerequisites
- KiCad 8 installed on your system
kicad-cliavailable on PATH (the skill will detect your install and offer to symlink it if needed)- Python 3.8+
Skill contents
kicad-schematic/
├── SKILL.md # Skill instructions (agent reads this)
├── scripts/
│ └── kicad_sch_helpers.py # Python helper library
└── references/
└── kicad_sexpression_format.md # KiCad S-expression format referenceHow it works
The core insight: KiCad symbol libraries use Y-up (math convention), but schematics use Y-down (screen convention). The SchematicBuilder.connect_pin() method handles this coordinate transform automatically, so every label lands exactly on its pin — no guessing.
from kicad_sch_helpers import SchematicBuilder, SymbolLibrary
lib = SymbolLibrary()
lib.load_from_kicad_sym("my_symbols.kicad_sym")
sch = SchematicBuilder(symbol_lib=lib, project_name="my_project")
sch.place("Device:R", "R1", "10k", x=100, y=100, footprint="Resistor_SMD:R_0402")
sch.connect_pin("R1", "1", "VCC", wire_dy=-5.08, by_number=True)
sch.connect_pin("R1", "2", "NET1", wire_dy=5.08, by_number=True)Supported agents
Works with any agent that supports the skills standard: Claude Code, Cursor, Codex, Windsurf, OpenCode, and more.
License
MIT
KiCad 8/9 Schematic S-Expression Format Reference
File Structure
(kicad_sch
(version 20231120)
(generator "name")
(generator_version "8.0")
(uuid "...")
(paper "A1")
(title_block ...)
(lib_symbols
;; Embedded symbol definitions (copied from .kicad_sym libraries)
(symbol "Library:SymbolName" ...)
)
;; Placed components, wires, labels, etc.
(symbol (lib_id "Library:SymbolName") (at X Y ANGLE) ...)
(wire (pts (xy X1 Y1) (xy X2 Y2)) ...)
(label "NetName" (at X Y ANGLE) ...)
(no_connect (at X Y) ...)
(sheet_instances
(path "/" (page "1"))
)
)Coordinate System
- Units: millimeters
- X axis: positive = right
- Y axis: positive = DOWN (screen convention)
- Default grid: 1.27 mm (50 mil)
- Angles: degrees, counterclockwise (0=right, 90=up, 180=left, 270=down)
Library vs Schematic Y-axis (CRITICAL)
Symbol libraries (.kicad_sym) use Y-up (math convention). Schematics (.kicad_sch) use Y-down (screen convention).
This is the #1 source of bugs in generated schematics. If you forget to negate Y when transforming from library to schematic coordinates, every label and wire endpoint will be wrong.
When a symbol is placed at (sx, sy) with rotation R, a pin at library position (px, py) maps to:
- Rotation 0: schematic (sx + px, sy - py) <- note the negation
- Rotation 90: schematic (sx + py, sy + px)
- Rotation 180: schematic (sx - px, sy + py)
- Rotation 270: schematic (sx - py, sy - px)
Worked Example
AD9363 placed at schematic (320, 200) with rotation 0. Pin TX1A_P at library position (-17.78, 25.40).
Schematic position = (320 + (-17.78), 200 - 25.40) = (302.22, 174.60)
Common mistake: using (320 + (-17.78), 200 + 25.40) = (302.22, 225.40) — this is 50.8mm off in Y!
Grid Snapping
All coordinates MUST be multiples of 1.27mm (the default 50-mil grid). Off-grid coordinates cause endpoint_off_grid warnings that cascade through all connected wires and labels.
GRID = 1.27
def snap(v):
return round(v / GRID) * GRIDApply snap() to every coordinate: component positions, wire endpoints, label positions, no-connect positions.
Embedded lib_symbols Section
Every symbol used in the schematic must be defined in (lib_symbols ...). These are copies from the library files, embedded for portability.
Critical Rule: Sub-symbol Naming
Parent symbols use the full library-prefixed name: (symbol "Device:R" ...) Sub-symbols (for units and body styles) must NOT include the library prefix:
- CORRECT:
(symbol "R_0_1" ...)and(symbol "R_1_1" ...) - WRONG:
(symbol "Device:R_0_1" ...)<- causes "Invalid symbol unit name prefix" error
The naming convention is: {SymbolName}_{unit}_{bodystyle}
_0_1: Body style drawings (rectangles, lines, arcs)_1_1: Pin definitions for unit 1
Why this matters: KiCad will open the file without complaining, but symbols will appear as broken boxes with no visible pins. The sub-symbol names must match what KiCad expects internally.
Fix with regex post-processing:
import re
def fix_subsymbol_names(content):
"""Remove library prefixes from sub-symbol names inside lib_symbols."""
def fix_match(m):
lib_prefix = m.group(1) # e.g., "Device:"
sym_name = m.group(2) # e.g., "R"
suffix = m.group(3) # e.g., "_0_1" or "_1_1"
return f'(symbol "{sym_name}{suffix}"'
# Match sub-symbols that incorrectly have a library prefix
pattern = r'\(symbol "([A-Za-z_][A-Za-z0-9_-]*):([A-Za-z_][A-Za-z0-9_-]*)(_\d+_\d+)"'
return re.sub(pattern, fix_match, content)Pin Definition Format
(pin TYPE STYLE (at X Y ANGLE) (length L)
(name "PinName" (effects (font (size 1.0 1.0))))
(number "PinNum" (effects (font (size 1.0 1.0)))))- TYPE: passive, power_in, power_out, input, output, bidirectional
- STYLE: line, inverted, clock, etc.
- (at X Y ANGLE): Connection point position in library coordinates (Y-up)
- ANGLE: Direction the pin points INTO the symbol body
- 0 = pin points right (connection is on LEFT side)
- 90 = pin points up (connection is on BOTTOM)
- 180 = pin points left (connection is on RIGHT side)
- 270 = pin points down (connection is on TOP)
Standard 2-Pin Passive Symbol Positions
KiCad's standard Device:R, Device:C, Device:L symbols have:
- Pin 1 at library (0, 2.54) — becomes schematic (sx, sy - 2.54) = TOP
- Pin 2 at library (0, -2.54) — becomes schematic (sx, sy + 2.54) = BOTTOM
SOT-23-5 Package Pin Positions (Common LDOs)
For AP2112K, ME6211, and similar SOT-23-5 LDOs:
| Pin | Name | Library Position | Schematic Position (rot=0) |
|---|---|---|---|
| 1 | VIN | (-7.62, 2.54) | (sx - 7.62, sy - 2.54) |
| 2 | GND | (0, -7.62) | (sx, sy + 7.62) |
| 3 | EN | (-7.62, -2.54) | (sx - 7.62, sy + 2.54) |
| 4 | NC | (7.62, -2.54) | (sx + 7.62, sy + 2.54) |
| 5 | VOUT | (7.62, 2.54) | (sx + 7.62, sy - 2.54) |
WARNING: VOUT (pin 5) and NC (pin 4) are at the same X but differ only in Y sign. After the Y-flip transform, VOUT is at sy - 2.54 (above center) and NC is at sy + 2.54 (below center). Confusing them makes the LDO output go to a no-connect instead of the power rail.
Placed Symbol Format
(symbol (lib_id "Library:Name") (at X Y ANGLE) [mirror]
(uuid "...")
(property "Reference" "U1" (at X Y 0) (effects ...))
(property "Value" "IC_Name" (at X Y 0) (effects ...))
(property "Footprint" "Package:FP" (at X Y 0) (effects ... hide))
(instances
(project "project_name"
(path "/ROOT_UUID"
(reference "U1")
(unit 1)
)
)
)
)Required fields: lib_id, at, uuid, Reference, Value, Footprint, instances.
The (at X Y ANGLE) positions must be on the 1.27mm grid.
Wire Format
(wire (pts (xy X1 Y1) (xy X2 Y2))
(stroke (width 0) (type default))
(uuid "...")
)Wires connect components via their pin endpoints. A wire endpoint must exactly coincide with a pin's connection point to make a connection. Even a 0.01mm mismatch will cause an ERC error. Always compute wire endpoints from pin positions, never guess them.
Label Format
(label "NetName" (at X Y ANGLE)
(effects (font (size 1.27 1.27)) (justify left))
(uuid "...")
)A label must be placed at a wire endpoint or directly at a pin's connection point. Labels at coordinates that don't match any wire or pin will cause "label_dangling" ERC errors.
Label angle conventions:
- 0 = text reads left-to-right, label connects on the LEFT
- 90 = text reads bottom-to-top, label connects on the BOTTOM
- 180 = text reads right-to-left, label connects on the RIGHT
- 270 = text reads top-to-bottom, label connects on the TOP
Choose the angle so the label text doesn't overlap the component body.
Power Symbol Format
Power symbols (GND, VCC) are special symbols with the (power) flag. They have a single pin that defines the power net.
(symbol (lib_id "power:GND") (at X Y 0)
(uuid "...")
(property "Reference" "#PWR0XX" (at X Y+2.54 0)
(effects (font (size 1.27 1.27)) hide))
(property "Value" "GND" (at X Y+1.27 0)
(effects (font (size 1.27 1.27))))
(property "Footprint" "" (at X Y 0)
(effects (font (size 1.27 1.27)) hide))
(pin "1" (uuid "..."))
(instances ...)
)Power symbols automatically create the named net (e.g., "GND", "VCC").
No-Connect Flag
(no_connect (at X Y)
(uuid "...")
)Place at pin connection points for intentionally unconnected pins. The (at X Y) must exactly match the pin's schematic position (computed using the transform formula, not guessed).
PWR_FLAG
To avoid "power_pin_not_driven" errors, place PWR_FLAG symbols on nets that are driven by components that KiCad doesn't recognize as power sources (e.g., voltage regulators with passive output pins).
When to add PWR_FLAG:
- Every voltage regulator output net (LDO VOUT, switching regulator output)
- GND net if no dedicated GND power symbol is used
- Any net where all connected power_in pins have no power_out driver
Define PWR_FLAG in lib_symbols:
(symbol "power:PWR_FLAG"
(power) (pin_numbers hide) (pin_names hide) (in_bom no) (on_board yes)
(property "Reference" "#FLG" (at 0 2.54 0) (effects (font (size 1.27 1.27)) hide))
(property "Value" "PWR_FLAG" (at 0 3.81 0) (effects (font (size 1.0 1.0))))
(property "Footprint" "" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(symbol "PWR_FLAG_0_1"
(polyline (pts (xy 0 0) (xy 0 1.27) (xy -1.016 2.032) (xy 0 2.794) (xy 1.016 2.032) (xy 0 1.27))
(stroke (width 0) (type default)) (fill (type none)))
)
(symbol "PWR_FLAG_1_1"
(pin power_out line (at 0 0 90) (length 0)
(name "pwr" (effects (font (size 1.0 1.0))))
(number "1" (effects (font (size 1.0 1.0)))))
)
)Note: PWR_FLAG sub-symbols use unprefixed names (PWR_FLAG_0_1, not power:PWR_FLAG_0_1).
Parenthesis Balance
KiCad S-expression files must have perfectly balanced parentheses. An imbalance will cause the file to fail to open or to be parsed incorrectly.
Always verify after generation:
depth = sum(1 if c == '(' else -1 if c == ')' else 0 for c in content)
assert depth == 0, f"Parenthesis imbalance: depth={depth}"Common ERC Error Types
| Error Type | Severity | Cause | Fix |
|---|---|---|---|
| pin_not_connected | error | Pin has no wire/label/no-connect | Add wire+label or no_connect at exact pin position |
| label_dangling | error | Label not at wire/pin endpoint | Move label to computed pin position using transform formula |
| power_pin_not_driven | error | Power input with no power source | Add PWR_FLAG on the net |
| pin_not_driven | error | Input pin with no output driver | Connect to output or add pull-up/pull-down |
| endpoint_off_grid | warning | Wire/pin not on 1.27mm grid | Snap all coordinates with snap() |
| lib_symbol_mismatch | warning | Embedded symbol differs from library | Re-copy symbol from library OR suppress in .kicad_pro (safe for KiCad 8→9 migration) |
| lib_symbol_issues | warning | Symbol not found in referenced library | Create project-level custom library with the symbol; update lib_id to point there |
| unconnected_wire_endpoint | warning | Wire end not connected to anything | Extend wire to pin/label or remove dangling wire |
| no_connect_connected | warning | No-connect flag on a pin that IS connected | Remove the no_connect flag (the pin has a real connection) |
| multiple_net_names | warning | Two labels on same net create dual names | Intentional: suppress in .kicad_pro. Accidental: remove one label |
| footprint_link_issues | warning | Footprint not found in library | Update footprint name to KiCad 9 equivalent using replace_footprint() |
| unannotated | error (GUI) | Reference doesn't end with digit | Use fix_annotation_suffixes() — note: CLI ERC doesn't catch this |
Error Priority (Fix in This Order)
1. Sub-symbol naming — fixes lib_symbol_issues and lib_symbol_mismatch 2. Grid snapping — fixes endpoint_off_grid and prevents cascading errors 3. Pin connectivity — fixes pin_not_connected, label_dangling (requires correct pin positions) 4. PWR_FLAG placement — fixes power_pin_not_driven 5. No-connect flags — fixes remaining pin_not_connected on unused pins
Debugging Tips
Verifying Pin Positions
If you get label_dangling or pin_not_connected errors, the most likely cause is wrong pin positions. To debug:
1. Open the .kicad_sym file and find the pin definition 2. Note the library coordinates (X, Y) 3. Apply the transform formula for the component's rotation 4. Compare with the wire/label position in the .kicad_sch file 5. If they don't match exactly, the connection is broken
Checking Wire Connectivity
A wire connects two points only if both endpoints exactly match pin/label positions. Use search to find all (xy ...) coordinates near a pin's expected position and verify they match.
ERC Report Parsing
kicad-cli sch erc --output report.json --format json --severity-all schematic.kicad_schAlways use `--severity-all` to include warnings (default only shows errors).
Always use `-o file.json` — kicad-cli writes JSON to the output file, NOT to stdout. Piping to python gives empty stdin.
The JSON output contains severity, type, and position information for each error. Group errors by type to identify systematic issues (e.g., all label_dangling errors suggesting a coordinate transform bug).
KiCad 9 JSON format nests violations under sheets[].violations[]:
{
"sheets": [
{
"path": "/",
"uuid_path": "/root-uuid",
"violations": [
{
"description": "Input Power pin not driven...",
"severity": "error",
"type": "power_pin_not_driven",
"items": [...]
}
]
}
]
}This differs from KiCad 8 which uses top-level violations[]. The helper run_erc() handles both formats.
---
KiCad 9 Differences
Symbol Renames
Symbols renamed or removed between KiCad 8 and 9:
| KiCad 8 | KiCad 9 | Notes |
|---|---|---|
Connector:Conn_01x04 | Connector:Conn_01x04_Pin | All Conn_01xNN renamed |
Connector:Conn_01x06 | Connector:Conn_01x06_Pin | Same pattern |
Connector:Conn_02x20 | Connector:Conn_02x20_Pin | All Conn_02xNN renamed |
Connector:SMA | Removed | No direct replacement |
Connector:TestPoint | Connector:TestPoint | May have moved/changed |
Regulator_Linear:AMS1117 | Has 4 pins (ADJ added) | 3-pin schematic breaks |
Pin Position Changes
CRITICAL — Do NOT update these symbols:
| Symbol | KiCad 8 Pins | KiCad 9 Pins | Impact |
|---|---|---|---|
Device:C | (0, ±2.54) | (0, ±3.81) | Breaks every capacitor connection |
Device:R | (0, ±2.54) | (0, ±3.81) | Breaks every resistor connection |
Device:L | (0, ±2.54) | (0, ±3.81) | Breaks every inductor connection |
The embedded symbols in the schematic work correctly because they contain the original pin positions. Suppress lib_symbol_mismatch instead of updating.
Footprint Renames
| KiCad 8 | KiCad 9 |
|---|---|
Button_Switch_SMD:SW_Push_1P1T_NO_6x3.5mm | Button_Switch_SMD:SW_Push_1P1T_NO_CK_PTS125Sx43SMTR |
Connector_Coaxial:SMA_Amphenol_901-143_Vertical | Connector_Coaxial:SMA_Amphenol_901-144_Vertical |
Annotation Requirements
KiCad 9 requires all reference designators to end with a digit. This is enforced by the GUI but NOT by CLI ERC.
Affected references (examples): C_RX1B_N, C_TX2A_P, J_PWR, R_BIAS
Fix: append 1 to each bare reference in both property and instance sections.
---
Environment Variables for kicad-cli
On macOS, kicad-cli may not find the global symbol/footprint libraries without these:
export KICAD9_SYMBOL_DIR="/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols"
export KICAD9_FOOTPRINT_DIR="/Applications/KiCad/KiCad.app/Contents/SharedSupport/footprints"Without these, ERC will report false lib_symbol_issues warnings for every symbol from global libraries.
Full ERC command:
KICAD9_SYMBOL_DIR="/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols" \
KICAD9_FOOTPRINT_DIR="/Applications/KiCad/KiCad.app/Contents/SharedSupport/footprints" \
/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli sch erc \
--format json --severity-all -o /tmp/erc_result.json schematic.kicad_sch---
Project Library Tables
When standard KiCad libraries change between versions, use project-level library tables to add custom symbol/footprint libraries.
sym-lib-table (symbol library table)
Place in project root directory:
(sym_lib_table
(version 7)
(lib (name "CubeSat_SDR")(type "KiCad")(uri "${KIPRJMOD}/libraries/cubesat_sdr.kicad_sym")(options "")(descr "Project custom symbols"))
)fp-lib-table (footprint library table)
Place in project root directory:
(fp_lib_table
(version 7)
(lib (name "CubeSat_SDR")(type "KiCad")(uri "${KIPRJMOD}/libraries/cubesat_sdr.pretty")(options "")(descr "Project custom footprints"))
)Key Notes
${KIPRJMOD}resolves to the project directory — use it for portable paths- Project-level tables supplement (don't replace) global library tables
- Library names must match the prefix used in
lib_idreferences (e.g.,CubeSat_SDR:AMS1117needs a library namedCubeSat_SDR) .kicad_symfiles use the same s-expression format as embeddedlib_symbols, but top-level symbols omit the library prefix
#!/usr/bin/env python3
"""
KiCad Schematic Helper Library — v3 (battle-tested, KiCad 8 + 9)
Provides reliable, computed coordinate transforms and S-expression generation
for KiCad 8/9 .kicad_sch files. Also provides utilities for fixing ERC errors
on existing schematics and handling KiCad 8→9 migration.
TWO MODES OF OPERATION:
A. Generating new schematics: SchematicBuilder, SymbolLibrary, pin_abs, snap
B. Fixing existing schematics: find_block, remove_by_uuid, replace_lib_id,
fix_annotation_suffixes, create_pwr_flag_block, etc.
LESSONS LEARNED from real-world debugging:
1. NEVER guess pin positions — always compute from symbol definitions
2. ALL coordinates must be snapped to 1.27mm grid (snap() everything)
3. Sub-symbols in lib_symbols must NOT have library prefix (Device:R_0_1 → R_0_1)
4. Labels must be at EXACT pin positions or connected via wires
5. PWR_FLAG needed on every power output net (voltage regulator outputs)
6. SOT-23-5 LDOs: VOUT is at (7.62, 2.54), NC is at (7.62, -2.54) — don't mix them up!
7. Every pin must be either: wired+labeled, connected to power, or have no_connect flag
8. Don't replace embedded Device:C/R/L with KiCad 9 versions (pin positions changed)
9. kicad-cli JSON goes to file (-o flag), not stdout — never pipe to python
10. CLI ERC doesn't check annotations — KiCad 9 GUI requires refs ending in digits
Usage (generating):
from kicad_sch_helpers import SchematicBuilder, SymbolLibrary, snap, pin_abs
Usage (fixing):
from kicad_sch_helpers import (find_block, remove_by_uuid, replace_lib_id,
replace_footprint, fix_annotation_suffixes, create_pwr_flag_block, run_erc)
The key insight: KiCad symbol libraries use Y-up (math convention),
but .kicad_sch files use Y-down (screen convention). When placing a
symbol at (sx, sy), a pin defined at library position (px, py) maps
to schematic position using:
Rotation 0: (sx + px, sy - py)
Rotation 90: (sx + py, sy + px)
Rotation 180: (sx - px, sy + py)
Rotation 270: (sx - py, sy - px)
This library handles all of this automatically via pin_abs().
"""
import uuid as _uuid
import re
import json
import subprocess
import sys
from pathlib import Path
from dataclasses import dataclass
from typing import Optional
# =============================================================================
# Grid and coordinate utilities
# =============================================================================
GRID = 1.27 # KiCad default schematic grid in mm (50 mil)
def snap(v: float) -> float:
"""Snap a coordinate to the nearest 1.27mm grid point.
ALWAYS use this for every coordinate in the schematic."""
return round(v / GRID) * GRID
def uid() -> str:
"""Generate a UUID for KiCad elements."""
return str(_uuid.uuid4())
def pin_transform(pin_x: float, pin_y: float, rotation: int = 0) -> tuple:
"""
Transform a pin position from library space to schematic offset space.
In library space: Y-up (positive Y = up on screen)
In schematic space: Y-down (positive Y = down on screen)
Args:
pin_x, pin_y: Pin position in library (symbol) coordinates
rotation: Symbol rotation in degrees (0, 90, 180, 270)
Returns:
(dx, dy): Offset to add to symbol placement position
"""
transforms = {
0: ( pin_x, -pin_y),
90: ( pin_y, pin_x),
180: (-pin_x, pin_y),
270: (-pin_y, -pin_x),
}
if rotation not in transforms:
raise ValueError(f"Rotation must be 0, 90, 180, or 270. Got {rotation}")
return transforms[rotation]
def pin_abs(sx: float, sy: float, px: float, py: float,
rotation: int = 0, mirror_y: bool = False) -> tuple:
"""
Compute absolute schematic position of a pin. THE key function.
Args:
sx, sy: Symbol placement position in schematic (should be grid-snapped)
px, py: Pin position in library (symbol) coordinates
rotation: Symbol rotation (0, 90, 180, 270)
mirror_y: Whether symbol is Y-mirrored
Returns:
(abs_x, abs_y): Absolute pin position, grid-snapped
Example:
# AD9363 at (320, 200), TX1A_P pin at library (-17.78, 25.40)
x, y = pin_abs(320, 200, -17.78, 25.40)
# Returns (302.26, 174.63) — snapped to grid
"""
if mirror_y:
px = -px
dx, dy = pin_transform(px, py, rotation)
return (snap(sx + dx), snap(sy + dy))
# =============================================================================
# Symbol library parser
# =============================================================================
@dataclass
class PinDef:
"""A pin definition from a symbol library."""
name: str
number: str
x: float
y: float
angle: int
length: float
pin_type: str # passive, power_in, power_out, input, output, bidirectional
@dataclass
class SymbolDef:
"""A symbol definition with its pins."""
name: str
pins: list # List[PinDef]
def get_pin(self, name: str) -> Optional[PinDef]:
"""Get pin by name."""
for p in self.pins:
if p.name == name:
return p
return None
def get_pin_by_name(self, name: str) -> Optional[PinDef]:
"""Get pin by name (alias for get_pin)."""
return self.get_pin(name)
def get_pin_by_number(self, number: str) -> Optional[PinDef]:
"""Get pin by number string."""
for p in self.pins:
if p.number == number:
return p
return None
def pin_pos(self, name: str) -> tuple:
"""Get (x, y) library position of a pin by name. Raises if not found."""
p = self.get_pin(name)
if not p:
raise KeyError(f"Pin '{name}' not found in symbol '{self.name}'. "
f"Available: {[pin.name for pin in self.pins]}")
return (p.x, p.y)
class SymbolLibrary:
"""
Parse and store symbol definitions from .kicad_sym files
or from the lib_symbols section of a .kicad_sch file.
Usage:
lib = SymbolLibrary()
lib.load_from_kicad_sym("path/to/library.kicad_sym")
ad9363 = lib.get("AD9363ABCZ")
px, py = ad9363.pin_pos("TX1A_P")
"""
def __init__(self):
self.symbols: dict = {} # name -> SymbolDef
def load_from_kicad_sym(self, filepath: str):
"""Load symbols from a .kicad_sym library file."""
with open(filepath) as f:
content = f.read()
self._parse(content)
# Keep 'load' as alias for backward compatibility
load = load_from_kicad_sym
def _parse(self, content: str):
"""Parse symbol definitions from S-expression content."""
pin_pattern = re.compile(
r'\(pin\s+(\w+)\s+\w+\s+'
r'\(at\s+([-\d.]+)\s+([-\d.]+)\s+(\d+)\)\s+'
r'\(length\s+([-\d.]+)\)\s+'
r'\(name\s+"([^"]*)".*?\)\s+'
r'\(number\s+"([^"]*)".*?\)\)',
re.DOTALL
)
lines = content.split('\n')
i = 0
while i < len(lines):
line = lines[i].strip()
m = re.match(r'^\(symbol\s+"([^"]+)"', line)
# Skip sub-symbols (those ending in _digit_digit)
if m and not re.search(r'_\d+_\d+$', m.group(1)):
sym_name = m.group(1)
depth = line.count('(') - line.count(')')
block_lines = [line]
j = i + 1
while j < len(lines) and depth > 0:
l = lines[j].strip()
depth += l.count('(') - l.count(')')
block_lines.append(l)
j += 1
block = '\n'.join(block_lines)
pins = []
for pm in pin_pattern.finditer(block):
pins.append(PinDef(
name=pm.group(6), number=pm.group(7),
x=float(pm.group(2)), y=float(pm.group(3)),
angle=int(pm.group(4)), length=float(pm.group(5)),
pin_type=pm.group(1),
))
if pins:
self.symbols[sym_name] = SymbolDef(name=sym_name, pins=pins)
i = j
else:
i += 1
def get(self, name: str) -> Optional[SymbolDef]:
"""Get symbol by name (tries with and without library prefix)."""
if name in self.symbols:
return self.symbols[name]
if ':' in name:
short = name.split(':', 1)[1]
if short in self.symbols:
return self.symbols[short]
return None
# =============================================================================
# lib_symbols template generators
# =============================================================================
def lib_sym_2pin(lib_id: str, ref_prefix: str, default_val: str,
pin1_name: str = "1", pin2_name: str = "2",
pin1_type: str = "passive", pin2_type: str = "passive",
body: str = "rect") -> str:
"""
Generate lib_symbol for a 2-pin component.
IMPORTANT: Sub-symbol names use ONLY the symbol name, not the library prefix.
This is handled automatically by this function.
Standard 2-pin pin positions:
Pin 1: (0, 2.54) pointing down (angle 270) — TOP in schematic
Pin 2: (0, -2.54) pointing up (angle 90) — BOTTOM in schematic
"""
sym_name = lib_id.split(':')[-1] if ':' in lib_id else lib_id
bodies = {
"rect": """ (rectangle (start -1.016 1.27) (end 1.016 -1.27)
(stroke (width 0.254) (type default)) (fill (type none)))""",
"cap": """ (polyline (pts (xy -1.27 0.508) (xy 1.27 0.508))
(stroke (width 0.254) (type default)) (fill (type none)))
(polyline (pts (xy -1.27 -0.508) (xy 1.27 -0.508))
(stroke (width 0.254) (type default)) (fill (type none)))""",
"inductor": """ (arc (start 0 -1.27) (mid 0.635 -0.635) (end 0 0)
(stroke (width 0.254) (type default)) (fill (type none)))
(arc (start 0 0) (mid 0.635 0.635) (end 0 1.27)
(stroke (width 0.254) (type default)) (fill (type none)))""",
"diode": """ (polyline (pts (xy -1.27 1.016) (xy -1.27 -1.016) (xy 1.27 0) (xy -1.27 1.016))
(stroke (width 0.254) (type default)) (fill (type none)))
(polyline (pts (xy 1.27 1.016) (xy 1.27 -1.016))
(stroke (width 0.254) (type default)) (fill (type none)))""",
"led": """ (polyline (pts (xy -1.27 1.016) (xy -1.27 -1.016) (xy 1.27 0) (xy -1.27 1.016))
(stroke (width 0.254) (type default)) (fill (type none)))
(polyline (pts (xy 1.27 1.016) (xy 1.27 -1.016))
(stroke (width 0.254) (type default)) (fill (type none)))""",
}
drawing = bodies.get(body, bodies["rect"])
return f""" (symbol "{lib_id}"
(pin_numbers hide) (pin_names hide) (in_bom yes) (on_board yes)
(property "Reference" "{ref_prefix}" (at 2.54 0.508 0)
(effects (font (size 1.27 1.27)) (justify left)))
(property "Value" "{default_val}" (at 2.54 -1.016 0)
(effects (font (size 1.27 1.27)) (justify left)))
(property "Footprint" "" (at 0 0 0)
(effects (font (size 1.27 1.27)) hide))
(symbol "{sym_name}_0_1"
{drawing}
)
(symbol "{sym_name}_1_1"
(pin {pin1_type} line (at 0 2.54 270) (length 1.27)
(name "{pin1_name}" (effects (font (size 1.0 1.0))))
(number "1" (effects (font (size 1.0 1.0)))))
(pin {pin2_type} line (at 0 -2.54 90) (length 1.27)
(name "{pin2_name}" (effects (font (size 1.0 1.0))))
(number "2" (effects (font (size 1.0 1.0)))))
)
)"""
def lib_sym_power(name: str, net_name: str) -> str:
"""Generate a power symbol definition (GND, +3.3V, etc.)."""
sym_name = name.split(':')[-1] if ':' in name else name
if "GND" in name:
drawing = """ (polyline (pts (xy 0 0) (xy 0 -1.27) (xy -1.27 -1.27) (xy 0 -2.54) (xy 1.27 -1.27) (xy 0 -1.27))
(stroke (width 0) (type default)) (fill (type none)))"""
pin_at = "(at 0 0 0)"
else:
drawing = """ (polyline (pts (xy -0.762 1.27) (xy 0.762 1.27))
(stroke (width 0.254) (type default)) (fill (type none)))
(polyline (pts (xy 0 0) (xy 0 1.27))
(stroke (width 0) (type default)) (fill (type none)))"""
pin_at = "(at 0 0 90)"
return f""" (symbol "{name}"
(power) (pin_numbers hide) (pin_names hide) (in_bom no) (on_board yes)
(property "Reference" "#PWR" (at 0 2.54 0)
(effects (font (size 1.27 1.27)) hide))
(property "Value" "{net_name}" (at 0 3.81 0)
(effects (font (size 1.0 1.0))))
(property "Footprint" "" (at 0 0 0)
(effects (font (size 1.27 1.27)) hide))
(symbol "{sym_name}_0_1"
{drawing}
)
(symbol "{sym_name}_1_1"
(pin power_in line {pin_at} (length 0)
(name "{net_name}" (effects (font (size 1.0 1.0))))
(number "1" (effects (font (size 1.0 1.0)))))
)
)"""
def lib_sym_pwr_flag() -> str:
"""Generate PWR_FLAG symbol. Place on every power output net to avoid
'power_pin_not_driven' ERC errors."""
return """ (symbol "power:PWR_FLAG"
(power) (pin_numbers hide) (pin_names hide) (in_bom no) (on_board yes)
(property "Reference" "#FLG" (at 0 2.54 0)
(effects (font (size 1.27 1.27)) hide))
(property "Value" "PWR_FLAG" (at 0 3.81 0)
(effects (font (size 1.0 1.0))))
(property "Footprint" "" (at 0 0 0)
(effects (font (size 1.27 1.27)) hide))
(symbol "PWR_FLAG_0_1"
(polyline (pts (xy 0 0) (xy 0 1.27) (xy -1.016 2.032) (xy 0 2.794) (xy 1.016 2.032) (xy 0 1.27))
(stroke (width 0) (type default)) (fill (type none)))
)
(symbol "PWR_FLAG_1_1"
(pin power_out line (at 0 0 90) (length 0)
(name "pwr" (effects (font (size 1.0 1.0))))
(number "1" (effects (font (size 1.0 1.0)))))
)
)"""
# =============================================================================
# Sub-symbol name fixer (post-processing)
# =============================================================================
def fix_subsymbol_names(content: str) -> str:
"""
Fix sub-symbol names in lib_symbols section.
KiCad REQUIRES that sub-symbols (those with _N_N suffix) do NOT include
the library prefix. This is the most common cause of "Invalid symbol unit
name prefix" errors when opening generated schematics.
Examples of what this fixes:
"Device:R_0_1" → "R_0_1"
"Device:C_Polarized_0_1" → "C_Polarized_0_1"
"CubeSat_SDR:AD9363ABCZ_1_1" → "AD9363ABCZ_1_1"
"Connector:Barrel_Jack_0_1" → "Barrel_Jack_0_1"
"""
def fix_match(m):
full_name = m.group(1)
suffix = m.group(2)
if ':' in full_name:
name = full_name.split(':', 1)[1]
return f'(symbol "{name}{suffix}"'
return m.group(0)
return re.sub(r'\(symbol "([^"]+?)(_\d+_\d+)"', fix_match, content)
# =============================================================================
# Schematic builder
# =============================================================================
@dataclass
class PlacedComponent:
"""A component placed in the schematic."""
lib_id: str
ref: str
value: str
x: float
y: float
rotation: int
footprint: str
lcsc: str
mirror_y: bool
unit: int
uuid: str
class SchematicBuilder:
"""
Build a KiCad 8 schematic with guaranteed pin-label connectivity.
All coordinates are automatically grid-snapped.
Use connect_pin() for IC pins — it computes exact positions.
Use place_2pin_vertical/horizontal for passive components.
"""
def __init__(self, symbol_lib: SymbolLibrary = None, project_name: str = "project"):
self.symbol_lib = symbol_lib
self.project_name = project_name
self.root_uuid = uid()
self.components: list = []
self.placed: dict = {} # ref -> PlacedComponent
self.wires: list = []
self.labels: list = []
self.no_connects: list = []
self.text_notes: list = []
self.pwr_sym_counter = 0
self.flg_counter = 0
self._lib_symbols_content = ""
def set_symbol_library(self, lib: SymbolLibrary):
"""Set the symbol library for pin position lookups."""
self.symbol_lib = lib
def set_lib_symbols(self, content: str):
"""Set raw lib_symbols S-expression content."""
self._lib_symbols_content = content
def place(self, lib_id: str, ref: str, value: str, x: float, y: float,
rotation: int = 0, footprint: str = "", lcsc: str = "",
mirror_y: bool = False, unit: int = 1) -> PlacedComponent:
"""Place a component at grid-snapped coordinates."""
x, y = snap(x), snap(y)
u = uid()
ms = "(mirror y)" if mirror_y else ""
self.components.append(f""" (symbol (lib_id "{lib_id}") (at {x:.2f} {y:.2f} {rotation}) {ms}
(uuid "{u}")
(property "Reference" "{ref}" (at {x:.2f} {y - 3.81:.2f} 0)
(effects (font (size 1.27 1.27))))
(property "Value" "{value}" (at {x:.2f} {y + 3.81:.2f} 0)
(effects (font (size 1.0 1.0))))
(property "Footprint" "{footprint}" (at {x:.2f} {y + 5.08:.2f} 0)
(effects (font (size 1.27 1.27)) hide))
(property "LCSC" "{lcsc}" (at {x:.2f} {y + 6.35:.2f} 0)
(effects (font (size 1.27 1.27)) hide))
(instances
(project "{self.project_name}"
(path "/{self.root_uuid}" (reference "{ref}") (unit {unit}))
)
)
)""")
comp = PlacedComponent(
lib_id=lib_id, ref=ref, value=value,
x=x, y=y, rotation=rotation,
footprint=footprint, lcsc=lcsc,
mirror_y=mirror_y, unit=unit, uuid=u
)
self.placed[ref] = comp
return comp
def place_power(self, lib_id: str, value: str, x: float, y: float, rotation: int = 0):
"""Place a power symbol (GND, VCC, etc.)."""
x, y = snap(x), snap(y)
self.pwr_sym_counter += 1
ref = f"#PWR{self.pwr_sym_counter:03d}"
self.components.append(f""" (symbol (lib_id "{lib_id}") (at {x:.2f} {y:.2f} {rotation})
(uuid "{uid()}")
(property "Reference" "{ref}" (at {x:.2f} {y + 2.54:.2f} 0)
(effects (font (size 1.27 1.27)) hide))
(property "Value" "{value}" (at {x:.2f} {y + 3.81:.2f} 0)
(effects (font (size 0.8 0.8))))
(property "Footprint" "" (at {x:.2f} {y:.2f} 0)
(effects (font (size 1.27 1.27)) hide))
(instances
(project "{self.project_name}"
(path "/{self.root_uuid}" (reference "{ref}") (unit 1))
)
)
)""")
def place_pwr_flag(self, x: float, y: float, net_name: str):
"""Place a PWR_FLAG on a power net. Essential for regulator outputs."""
x, y = snap(x), snap(y)
self.flg_counter += 1
ref = f"#FLG{self.flg_counter:03d}"
self.components.append(f""" (symbol (lib_id "power:PWR_FLAG") (at {x:.2f} {y:.2f} 0)
(uuid "{uid()}")
(property "Reference" "{ref}" (at {x:.2f} {y + 2.54:.2f} 0)
(effects (font (size 1.27 1.27)) hide))
(property "Value" "PWR_FLAG" (at {x:.2f} {y + 3.81:.2f} 0)
(effects (font (size 0.8 0.8))))
(property "Footprint" "" (at {x:.2f} {y:.2f} 0)
(effects (font (size 1.27 1.27)) hide))
(instances
(project "{self.project_name}"
(path "/{self.root_uuid}" (reference "{ref}") (unit 1))
)
)
)""")
self.label(net_name, x, y)
def connect_pin(self, ref: str, pin_name: str, net_label: str,
wire_dx: float = 0, wire_dy: float = 0,
label_angle: int = 0, by_number: bool = False):
"""
THE key method. Connect a component's pin to a net label with exact
computed coordinates and an optional wire stub.
This eliminates dangling label and unconnected pin ERC errors.
Args:
ref: Component reference (e.g., "U1")
pin_name: Pin name (or number if by_number=True)
net_label: Net label text
wire_dx, wire_dy: Wire extension from pin for routing room
label_angle: Label rotation (0, 90, 180, 270)
by_number: Look up pin by number instead of name
"""
comp = self.placed.get(ref)
if not comp:
print(f"WARNING: Component {ref} not found", file=sys.stderr)
return
if not self.symbol_lib:
print(f"WARNING: No symbol library set. Pass symbol_lib to constructor "
f"or call set_symbol_library() first.", file=sys.stderr)
return
sym_def = self.symbol_lib.get(comp.lib_id)
if not sym_def:
print(f"WARNING: Symbol {comp.lib_id} not in library", file=sys.stderr)
return
pin = (sym_def.get_pin_by_number(pin_name) if by_number
else sym_def.get_pin(pin_name))
if not pin:
print(f"WARNING: Pin '{pin_name}' not found on {comp.lib_id}",
file=sys.stderr)
return
abs_x, abs_y = pin_abs(comp.x, comp.y, pin.x, pin.y,
comp.rotation, comp.mirror_y)
end_x = snap(abs_x + wire_dx)
end_y = snap(abs_y + wire_dy)
if wire_dx != 0 or wire_dy != 0:
self.wire(abs_x, abs_y, end_x, end_y)
self.label(net_label, end_x, end_y, label_angle)
else:
self.label(net_label, abs_x, abs_y, label_angle)
def connect_pin_noconnect(self, ref: str, pin_name: str, by_number: bool = False):
"""Place a no-connect flag on an unused pin."""
comp = self.placed.get(ref)
if not comp or not self.symbol_lib:
return
sym_def = self.symbol_lib.get(comp.lib_id)
if not sym_def:
return
pin = (sym_def.get_pin_by_number(pin_name) if by_number
else sym_def.get_pin(pin_name))
if not pin:
return
abs_x, abs_y = pin_abs(comp.x, comp.y, pin.x, pin.y,
comp.rotation, comp.mirror_y)
self.no_connect(abs_x, abs_y)
# Alias for backward compatibility
connect_pin_nc = connect_pin_noconnect
def wire(self, x1: float, y1: float, x2: float, y2: float):
"""Draw a wire (auto-snapped). Skips zero-length wires."""
x1, y1, x2, y2 = snap(x1), snap(y1), snap(x2), snap(y2)
if x1 == x2 and y1 == y2:
return
self.wires.append(f""" (wire (pts (xy {x1:.2f} {y1:.2f}) (xy {x2:.2f} {y2:.2f}))
(stroke (width 0) (type default))
(uuid "{uid()}")
)""")
# Short alias
w = wire
def label(self, name: str, x: float, y: float, angle: int = 0):
"""Place a net label (auto-snapped)."""
x, y = snap(x), snap(y)
self.labels.append(f""" (label "{name}" (at {x:.2f} {y:.2f} {angle})
(effects (font (size 1.27 1.27)) (justify left))
(uuid "{uid()}")
)""")
def no_connect(self, x: float, y: float):
"""Place a no-connect flag (auto-snapped)."""
x, y = snap(x), snap(y)
self.no_connects.append(f""" (no_connect (at {x:.2f} {y:.2f})
(uuid "{uid()}")
)""")
# Short alias
nc = no_connect
def text_note(self, text: str, x: float, y: float, size: float = 2.54):
"""Add a text annotation."""
self.text_notes.append(f""" (text "{text}" (at {x:.2f} {y:.2f} 0)
(effects (font (size {size} {size})) (justify left))
(uuid "{uid()}")
)""")
def build(self, title: str = "Schematic", date: str = "2026-01-01",
rev: str = "1.0", paper: str = "A1", comments: list = None) -> str:
"""Generate the complete .kicad_sch file.
IMPORTANT: Always run fix_subsymbol_names() on the output!"""
comment_lines = ""
if comments:
for i, c in enumerate(comments, 1):
comment_lines += f' (comment {i} "{c}")\n'
header = f"""(kicad_sch
(version 20231120)
(generator "kicad_sch_agent")
(generator_version "8.0")
(uuid "{self.root_uuid}")
(paper "{paper}")
(title_block
(title "{title}")
(date "{date}")
(rev "{rev}")
{comment_lines} )"""
all_items = (self.components + self.wires + self.labels +
self.no_connects + self.text_notes)
return f"""{header}
(lib_symbols
{self._lib_symbols_content}
)
{chr(10).join(all_items)}
(sheet_instances
(path "/"
(page "1")
)
)
)"""
# =============================================================================
# Convenience helpers for 2-pin components
# =============================================================================
def place_2pin_vertical(builder: SchematicBuilder, lib_id: str, ref: str,
value: str, x: float, y: float,
top_net: str, bottom_net: str,
footprint: str = "", lcsc: str = "",
wire_ext: float = 3.81):
"""
Place a 2-pin component vertically and wire both pins to net labels.
Pin layout (rotation 0):
Pin 1 at lib (0, 2.54) -> schematic TOP -> connects to top_net
Pin 2 at lib (0, -2.54) -> schematic BOTTOM -> connects to bottom_net
Wire stubs extend wire_ext mm from each pin.
"""
x, y = snap(x), snap(y)
builder.place(lib_id, ref, value, x, y, footprint=footprint, lcsc=lcsc)
p1y = snap(y - 2.54) # Pin 1 in schematic (Y negated)
p2y = snap(y + 2.54) # Pin 2 in schematic
builder.wire(x, p1y, x, snap(p1y - wire_ext))
builder.label(top_net, x, snap(p1y - wire_ext))
builder.wire(x, p2y, x, snap(p2y + wire_ext))
builder.label(bottom_net, x, snap(p2y + wire_ext))
def place_2pin_horizontal(builder: SchematicBuilder, lib_id: str, ref: str,
value: str, x: float, y: float,
left_net: str, right_net: str,
footprint: str = "", lcsc: str = "",
wire_ext: float = 3.81):
"""
Place a 2-pin component horizontally (rotation=90) and wire both pins.
Pin layout (rotation 90):
Pin 1 at lib (0, 2.54) -> schematic RIGHT -> connects to right_net
Pin 2 at lib (0, -2.54) -> schematic LEFT -> connects to left_net
"""
x, y = snap(x), snap(y)
builder.place(lib_id, ref, value, x, y, rotation=90,
footprint=footprint, lcsc=lcsc)
p1x = snap(x + 2.54) # Pin 1 in schematic (rotation 90)
p2x = snap(x - 2.54) # Pin 2
builder.wire(p1x, y, snap(p1x + wire_ext), y)
builder.label(right_net, snap(p1x + wire_ext), y)
builder.wire(p2x, y, snap(p2x - wire_ext), y)
builder.label(left_net, snap(p2x - wire_ext), y)
# =============================================================================
# ERC validation
# =============================================================================
def run_erc(schematic_path: str, output_path: str = None,
kicad_cli: str = "kicad-cli",
env_vars: dict = None) -> dict:
"""
Run KiCad ERC check via kicad-cli and return structured results.
Args:
schematic_path: Path to the .kicad_sch file
output_path: Where to write JSON results (default: alongside schematic)
kicad_cli: Path to kicad-cli executable
env_vars: Extra environment variables (e.g., KICAD9_SYMBOL_DIR,
KICAD9_FOOTPRINT_DIR for macOS KiCad 9)
Returns:
dict with: success (bool), errors (int), warnings (int),
error_types (dict), details (list)
Note: JSON output uses sheets[].violations[] format, not top-level violations.
This function handles both formats automatically.
"""
import os as _os
if output_path is None:
output_path = str(Path(schematic_path).with_suffix('.erc.json'))
# Build environment with optional extra vars (needed for macOS KiCad 9)
run_env = _os.environ.copy()
if env_vars:
run_env.update(env_vars)
try:
result = subprocess.run(
[kicad_cli, "sch", "erc",
"--output", output_path, "--format", "json",
"--severity-all", schematic_path],
capture_output=True, text=True, timeout=60,
env=run_env
)
except FileNotFoundError:
# Try to auto-discover kicad-cli
found = find_kicad_cli()
if found:
print(f"WARNING: kicad-cli not on PATH but found at: {found}",
file=sys.stderr)
suggest_kicad_cli_symlink()
try:
result = subprocess.run(
[found, "sch", "erc",
"--output", output_path, "--format", "json",
"--severity-all", schematic_path],
capture_output=True, text=True, timeout=60,
env=run_env
)
except Exception as e:
return {"success": False, "errors": -1, "warnings": -1,
"total": -1, "details": [],
"raw": f"kicad-cli found at {found} but failed: {e}"}
else:
suggest_kicad_cli_symlink()
return {"success": False, "errors": -1, "warnings": -1,
"total": -1, "details": [], "raw": "kicad-cli not found"}
except subprocess.TimeoutExpired:
return {"success": False, "errors": -1, "warnings": -1,
"total": -1, "details": [], "raw": "timeout"}
try:
with open(output_path) as f:
report = json.load(f)
except (json.JSONDecodeError, FileNotFoundError):
return _parse_text_erc(result.stdout + result.stderr)
# Handle both KiCad 8 (top-level violations) and KiCad 9 (sheets[].violations[])
all_violations = []
if "violations" in report:
all_violations = report["violations"]
elif "sheets" in report:
for sheet in report["sheets"]:
all_violations.extend(sheet.get("violations", []))
errors = [v for v in all_violations if v.get("severity") == "error"]
warnings = [v for v in all_violations if v.get("severity") == "warning"]
return {
"success": len(errors) == 0,
"errors": len(errors), "warnings": len(warnings),
"total": len(errors) + len(warnings),
"details": all_violations,
"error_types": _categorize(errors),
"warning_types": _categorize(warnings),
}
def validate_and_fix_loop(schematic_path: str, fix_callback,
max_iterations: int = 5,
kicad_cli: str = "kicad-cli") -> dict:
"""
Automated generate -> validate -> fix loop.
Args:
schematic_path: Path to the schematic file
fix_callback: Function(erc_result, iteration) -> bool
Returns True if fixes were applied, False to stop
max_iterations: Maximum fix attempts
kicad_cli: Path to kicad-cli
Returns:
Final ERC result dict
"""
for i in range(max_iterations):
print(f"\n=== ERC Validation Iteration {i+1}/{max_iterations} ===")
result = run_erc(schematic_path, kicad_cli=kicad_cli)
print(f"Errors: {result['errors']}, Warnings: {result['warnings']}")
if result["errors"] == 0:
print("No ERC errors!")
return result
if not fix_callback(result, i):
print("Fix callback returned False, stopping.")
return result
print(f"Reached max iterations ({max_iterations})")
return result
def _categorize(violations):
cats = {}
for v in violations:
cats[v.get("type", "unknown")] = cats.get(v.get("type", "unknown"), 0) + 1
return cats
def _parse_text_erc(text):
errors = len(re.findall(r';\s*error', text))
warnings = len(re.findall(r';\s*warning', text))
return {"success": errors == 0, "errors": errors, "warnings": warnings,
"total": errors + warnings, "details": [], "raw": text}
# =============================================================================
# kicad-cli discovery and symlink helper
# =============================================================================
def find_kicad_cli() -> Optional[str]:
"""
Locate kicad-cli on the system. Returns the path if found, None otherwise.
Checks PATH first, then common installation directories per OS.
"""
import shutil
import platform
found = shutil.which("kicad-cli")
if found:
return found
system = platform.system()
candidates = []
if system == "Darwin":
candidates = [
"/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli",
"/Applications/KiCad 9.0/KiCad.app/Contents/MacOS/kicad-cli",
"/Applications/KiCad 8.0/KiCad.app/Contents/MacOS/kicad-cli",
Path.home() / "Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli",
]
elif system == "Linux":
candidates = [
"/usr/bin/kicad-cli",
"/usr/local/bin/kicad-cli",
"/snap/kicad/current/bin/kicad-cli",
Path.home() / ".local/bin/kicad-cli",
]
elif system == "Windows":
candidates = [
Path(r"C:\Program Files\KiCad\9.0\bin\kicad-cli.exe"),
Path(r"C:\Program Files\KiCad\8.0\bin\kicad-cli.exe"),
Path(r"C:\Program Files\KiCad\bin\kicad-cli.exe"),
Path(r"C:\Program Files (x86)\KiCad\8.0\bin\kicad-cli.exe"),
]
for candidate in candidates:
if Path(candidate).is_file():
return str(candidate)
return None
def suggest_kicad_cli_symlink() -> Optional[str]:
"""
Find kicad-cli and print instructions to make it available on PATH.
Returns the found path, or None if not installed.
"""
import platform
found = find_kicad_cli()
if not found:
system = platform.system()
urls = {
"Darwin": "https://www.kicad.org/download/macos/",
"Linux": "https://www.kicad.org/download/linux/",
"Windows": "https://www.kicad.org/download/windows/",
}
url = urls.get(system, "https://www.kicad.org/download/")
print(f"kicad-cli not found. Install KiCad 8 from: {url}", file=sys.stderr)
return None
import shutil
if shutil.which("kicad-cli"):
return found
system = platform.system()
if system in ("Darwin", "Linux"):
print(f"Found kicad-cli at: {found}", file=sys.stderr)
print(f"To add to PATH, run:", file=sys.stderr)
print(f" sudo ln -sf '{found}' /usr/local/bin/kicad-cli", file=sys.stderr)
elif system == "Windows":
bin_dir = str(Path(found).parent)
print(f"Found kicad-cli at: {found}", file=sys.stderr)
print(f"To add to PATH, run in PowerShell (as admin):", file=sys.stderr)
print(f' [Environment]::SetEnvironmentVariable("PATH", $env:PATH + ";{bin_dir}", "User")',
file=sys.stderr)
return found
# =============================================================================
# ERC fixing utilities — for modifying existing schematics
# =============================================================================
def find_block(content: str, start_pos: int) -> tuple:
"""
Find a balanced parenthesized block starting at start_pos.
Handles quoted strings correctly (parentheses inside quotes are ignored).
Args:
content: The full file content
start_pos: Position of the opening '('
Returns:
(block_text, end_pos) where end_pos is the position after the closing ')'
Raises:
ValueError: If no '(' at start_pos or parentheses are unbalanced
Example:
>>> content = '(symbol "Device:R" (pin passive line))'
>>> text, end = find_block(content, 0)
>>> text
'(symbol "Device:R" (pin passive line))'
"""
if content[start_pos] != '(':
raise ValueError(f"Expected '(' at position {start_pos}, got '{content[start_pos]}'")
depth = 0
i = start_pos
in_string = False
while i < len(content):
c = content[i]
if c == '"' and (i == 0 or content[i-1] != '\\'):
in_string = not in_string
elif not in_string:
if c == '(':
depth += 1
elif c == ')':
depth -= 1
if depth == 0:
return content[start_pos:i+1], i + 1
i += 1
raise ValueError(f"Unbalanced parentheses starting at {start_pos}")
def remove_block_with_whitespace(content: str, block_start: int, block_end: int) -> str:
"""
Remove a block and its surrounding whitespace/newlines cleanly.
Looks backwards for preceding whitespace/tabs and a newline,
and forward for trailing whitespace and a newline. Removes all of it
so no blank lines are left behind.
Args:
content: The full file content
block_start: Start position of the block (the opening paren)
block_end: End position of the block (after the closing paren)
Returns:
Modified content with the block and surrounding whitespace removed
"""
start = block_start
while start > 0 and content[start-1] in ' \t':
start -= 1
if start > 0 and content[start-1] == '\n':
start -= 1
end = block_end
while end < len(content) and content[end] in ' \t':
end += 1
if end < len(content) and content[end] == '\n':
end += 1
return content[:start] + content[end:]
def extract_embedded_symbol(content: str, symbol_name: str) -> Optional[str]:
"""
Extract an embedded lib_symbol block by its full name.
Searches the lib_symbols section of a .kicad_sch file for a symbol
with the given name (e.g., 'Connector:Conn_01x04' or 'CubeSat_SDR:AMS1117')
and returns the complete s-expression block.
Args:
content: The full .kicad_sch file content
symbol_name: Full prefixed symbol name (e.g., 'CubeSat_SDR:AMS1117')
Returns:
The symbol block text, or None if not found
"""
pattern = f'(symbol "{symbol_name}"'
pos = content.find(pattern)
if pos == -1:
return None
block_text, _ = find_block(content, pos)
return block_text
def convert_embedded_to_library(block_text: str, old_prefix: str, new_name: str) -> str:
"""
Convert an embedded lib_symbol to standalone library format.
In embedded format: top-level is (symbol "Prefix:Name" ...)
In library format: top-level is (symbol "Name" ...)
Sub-symbols (Name_0_1, Name_1_1) remain unchanged in both formats.
Args:
block_text: The extracted symbol block
old_prefix: The library prefix to remove (e.g., "Connector", "CubeSat_SDR")
new_name: The symbol name without prefix (e.g., "Conn_01x04")
Returns:
The converted block suitable for a .kicad_sym library file
"""
return block_text.replace(
f'(symbol "{old_prefix}:{new_name}"',
f'(symbol "{new_name}"',
1
)
def find_by_uuid(content: str, uuid: str) -> Optional[int]:
"""
Find the position of a UUID string in the content.
Args:
content: The full file content
uuid: The UUID to search for
Returns:
Position of the UUID marker, or None if not found
"""
marker = f'(uuid "{uuid}")'
pos = content.find(marker)
return pos if pos != -1 else None
def remove_by_uuid(content: str, uuid: str, element_type: str) -> str:
"""
Remove an element (symbol, wire, no_connect, label) by its UUID.
Searches backwards from the UUID to find the containing block of the
specified type, then removes it with surrounding whitespace.
Args:
content: The full file content
uuid: The UUID of the element to remove
element_type: The s-expression type ('symbol', 'wire', 'no_connect', 'label')
Returns:
Modified content with the element removed
Raises:
ValueError: If UUID not found or parent block not found
Example:
>>> content = remove_by_uuid(content, "ac2d9711-...", "symbol")
"""
marker = f'(uuid "{uuid}")'
pos = content.find(marker)
if pos == -1:
raise ValueError(f"UUID '{uuid}' not found in content")
# Search backwards for the containing element
search_term = f'({element_type}'
block_start = content.rfind(search_term, 0, pos)
if block_start == -1:
raise ValueError(f"Could not find parent ({element_type} block for UUID '{uuid}'")
block_text, block_end = find_block(content, block_start)
if uuid not in block_text:
raise ValueError(f"Found ({element_type} block but UUID '{uuid}' not inside it")
return remove_block_with_whitespace(content, block_start, block_end)
def replace_lib_id(content: str, old_id: str, new_id: str) -> tuple:
"""
Replace a lib_id across all symbol instances and embedded lib_symbols.
Updates both:
- (lib_id "old_id") in placed symbol instances
- (symbol "old_id" ...) in the embedded lib_symbols section
Args:
content: The full .kicad_sch file content
old_id: The old lib_id (e.g., "Connector:Conn_01x04")
new_id: The new lib_id (e.g., "CubeSat_SDR:Conn_01x04")
Returns:
(modified_content, count) where count is total replacements made
Example:
>>> content, n = replace_lib_id(content, "Connector:SMA", "CubeSat_SDR:SMA")
>>> print(f"Replaced {n} occurrences")
"""
count = 0
# Replace in placed symbol instances
old_str = f'(lib_id "{old_id}")'
new_str = f'(lib_id "{new_id}")'
c = content.count(old_str)
content = content.replace(old_str, new_str)
count += c
# Replace in embedded lib_symbol key
old_sym = f'(symbol "{old_id}"'
new_sym = f'(symbol "{new_id}"'
if old_sym in content:
content = content.replace(old_sym, new_sym)
count += 1
return content, count
def replace_footprint(content: str, old_fp: str, new_fp: str) -> tuple:
"""
Replace a footprint reference across all symbol instances.
Args:
content: The full .kicad_sch file content
old_fp: The old footprint (e.g., "Button_Switch_SMD:SW_Push_1P1T_NO_6x3.5mm")
new_fp: The new footprint (e.g., "Button_Switch_SMD:SW_Push_1P1T_NO_CK_PTS125Sx43SMTR")
Returns:
(modified_content, count) where count is number of replacements
Example:
>>> content, n = replace_footprint(content,
... "Connector_Coaxial:SMA_Amphenol_901-143_Vertical",
... "Connector_Coaxial:SMA_Amphenol_901-144_Vertical")
"""
old_str = f'"Footprint" "{old_fp}"'
new_str = f'"Footprint" "{new_fp}"'
count = content.count(old_str)
content = content.replace(old_str, new_str)
return content, count
def fix_annotation_suffixes(content: str) -> tuple:
"""
Ensure all reference designators end with a digit (KiCad 9 requirement).
KiCad 9's GUI requires all references to end with a number. References
like 'C_RX1B_N' or 'J_PWR' cause "Item not annotated" errors. This
function appends '1' to any reference that doesn't end with a digit.
Handles both:
- (property "Reference" "C_RX1B_N" ...) in symbol properties
- (reference "C_RX1B_N") in instance paths
Args:
content: The full .kicad_sch file content
Returns:
(modified_content, fixed_refs) where fixed_refs is list of refs that were fixed
Example:
>>> content, refs = fix_annotation_suffixes(content)
>>> print(f"Fixed {len(refs)} references: {refs}")
"""
# Find all instance references (excluding hidden #FLG, #PWR)
refs = re.findall(r'\(reference "([^"]+)"\)', content)
visible_refs = [r for r in refs if not r.startswith('#')]
no_digit = sorted(set(r for r in visible_refs if r and not r[-1].isdigit()))
for ref in no_digit:
new_ref = ref + "1"
# Replace in property "Reference"
old_prop = f'"Reference" "{ref}"'
new_prop = f'"Reference" "{new_ref}"'
content = content.replace(old_prop, new_prop)
# Replace in instance (reference ...)
old_inst = f'(reference "{ref}")'
new_inst = f'(reference "{new_ref}")'
content = content.replace(old_inst, new_inst)
return content, no_digit
def create_pwr_flag_block(x: float, y: float, ref_num: int,
project_name: str, root_uuid: str) -> str:
"""
Generate a PWR_FLAG symbol s-expression block for insertion into a schematic.
Use this to fix 'power_pin_not_driven' ERC errors. Place the PWR_FLAG
on a wire connected to the power input pin.
Args:
x, y: Position in schematic coordinates (should be on a wire)
ref_num: Reference number (e.g., 7 for #FLG07)
project_name: Project name for the instances section
root_uuid: Root sheet UUID for the instances path
Returns:
Complete s-expression block ready to insert into the schematic
Example:
>>> block = create_pwr_flag_block(34.29, 77.47, 7, "cubesat_sdr",
... "5fb33c66-7637-43ae-9eef-34b4f23f6cfb")
"""
sym_uuid = uid()
pin_uuid = uid()
ref = f"#FLG{ref_num:02d}"
return f"""\t(symbol
\t\t(lib_id "power:PWR_FLAG")
\t\t(at {x} {y} 0)
\t\t(unit 1)
\t\t(exclude_from_sim no)
\t\t(in_bom yes)
\t\t(on_board yes)
\t\t(dnp no)
\t\t(uuid "{sym_uuid}")
\t\t(property "Reference" "{ref}"
\t\t\t(at {x} {y - 2.54} 0)
\t\t\t(effects
\t\t\t\t(font
\t\t\t\t\t(size 1.27 1.27)
\t\t\t\t)
\t\t\t\t(hide yes)
\t\t\t)
\t\t)
\t\t(property "Value" "PWR_FLAG"
\t\t\t(at {x} {y - 3.81} 0)
\t\t\t(effects
\t\t\t\t(font
\t\t\t\t\t(size 0.8 0.8)
\t\t\t\t)
\t\t\t)
\t\t)
\t\t(property "Footprint" ""
\t\t\t(at {x} {y} 0)
\t\t\t(effects
\t\t\t\t(font
\t\t\t\t\t(size 1.27 1.27)
\t\t\t\t)
\t\t\t\t(hide yes)
\t\t\t)
\t\t)
\t\t(property "Datasheet" ""
\t\t\t(at {x} {y} 0)
\t\t\t(effects
\t\t\t\t(font
\t\t\t\t\t(size 1.27 1.27)
\t\t\t\t)
\t\t\t)
\t\t)
\t\t(property "Description" ""
\t\t\t(at {x} {y} 0)
\t\t\t(effects
\t\t\t\t(font
\t\t\t\t\t(size 1.27 1.27)
\t\t\t\t)
\t\t\t)
\t\t)
\t\t(pin "1"
\t\t\t(uuid "{pin_uuid}")
\t\t)
\t\t(instances
\t\t\t(project "{project_name}"
\t\t\t\t(path "/{root_uuid}"
\t\t\t\t\t(reference "{ref}")
\t\t\t\t\t(unit 1)
\t\t\t\t)
\t\t\t)
\t\t)
\t)"""
def suppress_erc_warning(pro_path: str, rule_name: str) -> None:
"""
Suppress an ERC warning type in the .kicad_pro file.
Only use for warnings that are known-safe (e.g., lib_symbol_mismatch
during KiCad 8→9 migration). Never suppress errors.
Args:
pro_path: Path to the .kicad_pro file
rule_name: The rule to suppress (e.g., 'lib_symbol_mismatch')
"""
with open(pro_path) as f:
pro = json.load(f)
if 'erc' not in pro:
pro['erc'] = {}
if 'rule_severities' not in pro['erc']:
pro['erc']['rule_severities'] = {}
pro['erc']['rule_severities'][rule_name] = 'ignore'
with open(pro_path, 'w') as f:
json.dump(pro, f, indent=2)
f.write('\n')
if __name__ == "__main__":
print("KiCad Schematic Helper Library v3")
print(f"Grid: {GRID} mm")
print()
# Test coordinate utilities
print("=== Coordinate Utilities ===")
print(f"snap(42.5) = {snap(42.5)}")
print(f"pin_abs(320, 200, -17.78, 25.40, rotation=0) = "
f"{pin_abs(320, 200, -17.78, 25.40, rotation=0)}")
print(f"pin_abs(320, 200, 0, 2.54, rotation=90) = "
f"{pin_abs(320, 200, 0, 2.54, rotation=90)}")
print()
# Test ERC fixing utilities
print("=== ERC Fixing Utilities ===")
test_content = '(symbol (lib_id "test") (at 0 0 0) (uuid "abc-123"))'
block, end = find_block(test_content, 0)
print(f"find_block: found block of {len(block)} chars, end at {end}")
test_sch = 'before\n\t(wire (pts (xy 0 0) (xy 1 1))\n\t\t(uuid "wire-1")\n\t)\nafter'
cleaned = remove_block_with_whitespace(test_sch, 8, test_sch.index(')') + 1)
print(f"remove_block_with_whitespace: '{test_sch[:20]}...' -> '{cleaned[:20]}...'")
# Test annotation suffix fixing
test_refs = '(reference "C_RX1B_N")(reference "R1")(reference "J_PWR")'
fixed, refs = fix_annotation_suffixes(test_refs)
print(f"fix_annotation_suffixes: fixed {len(refs)} refs: {refs}")
print()
# Check kicad-cli
print("=== kicad-cli ===")
cli_path = find_kicad_cli()
if cli_path:
print(f"kicad-cli found: {cli_path}")
else:
suggest_kicad_cli_symlink()