merge: upstream/main (57 commits) — preserve PR #102 net label fix

Merged upstream/main into our fork. Conflict in connection_schematic.py
resolved by taking upstream's file and re-applying our fix:
- all_match_points = connected_wire_points | label positions
- Allows nets where labels are placed directly at pin endpoints (no wire)

Upstream changes include: security fixes (8 vulns), new schematic tools
(get_net_at_point, find_orphaned_wires, snap_to_grid, get_wire_connections),
generate_netlist rewrite via kicad-cli, wire preservation on component move,
schematic analysis tools, KiCad 10 support.
This commit is contained in:
ffindog
2026-04-15 22:27:41 +10:00
159 changed files with 39178 additions and 23947 deletions

24
.flake8 Normal file
View File

@@ -0,0 +1,24 @@
[flake8]
max-line-length = 100
extend-ignore =
E203,
W503,
E501,
F401,
F541,
F841,
F811,
F821,
F824,
E722,
E402,
E741,
E231
exclude =
.git,
__pycache__,
dist,
build,
.eggs,
.venv,
node_modules

View File

@@ -24,7 +24,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache: "npm"
- name: Install dependencies
run: npm ci
@@ -45,7 +45,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-24.04, ubuntu-22.04]
python-version: ['3.10', '3.11', '3.12']
python-version: ["3.10", "3.11", "3.12"]
steps:
- name: Checkout code
@@ -55,7 +55,7 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache: "pip"
- name: Install Python dependencies
run: |
@@ -96,14 +96,14 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
node-version: "20.x"
cache: "npm"
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
python-version: "3.12"
cache: "pip"
- name: Add KiCAD PPA and Install KiCAD 9.0
run: |
@@ -158,7 +158,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
node-version: "20.x"
- name: Install dependencies
run: npm ci

3
.gitignore vendored
View File

@@ -86,5 +86,8 @@ data/
Thumbs.db
Desktop.ini
# Generated local config files (contain machine-specific paths)
windows-mcp-config.json
# Personal notes / local contributions (not for upstream)
myContribution/

View File

@@ -1,71 +1,60 @@
# Pre-commit hooks configuration
# See https://pre-commit.com for more information
repos:
# Python code formatting
- repo: https://github.com/psf/black
rev: 23.7.0
hooks:
- id: black
language_version: python3
files: ^python/
# Python import sorting
- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: isort
files: ^python/
args: ["--profile", "black"]
# Python type checking
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.5.0
hooks:
- id: mypy
files: ^python/
args: [--ignore-missing-imports]
# Python linting
- repo: https://github.com/pycqa/flake8
rev: 6.1.0
hooks:
- id: flake8
files: ^python/
args: [--max-line-length=100, --extend-ignore=E203]
# TypeScript/JavaScript formatting
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.0.3
hooks:
- id: prettier
types_or: [javascript, typescript, json, yaml, markdown]
files: \.(ts|js|json|ya?ml|md)$
# General file checks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-added-large-files
args: [--maxkb=500]
args: ["--maxkb=500"]
- id: check-merge-conflict
- id: detect-private-key
# Python security checks
- repo: https://github.com/PyCQA/bandit
rev: 1.7.5
- repo: https://github.com/psf/black
rev: 26.3.1
hooks:
- id: bandit
args: [-c, pyproject.toml]
- id: black
language_version: python3
args: [--config=pyproject.toml]
- repo: https://github.com/pycqa/isort
rev: 8.0.1
hooks:
- id: isort
args: [--settings-path=pyproject.toml]
- repo: local
hooks:
- id: prettier
name: prettier
entry: npx prettier --write --ignore-unknown
language: node
types_or: [javascript, ts, json, yaml, markdown]
exclude: ^(dist/|package-lock\.json|\.venv/|python/)
- repo: https://github.com/pycqa/flake8
rev: "7.3.0"
hooks:
- id: flake8
files: ^python/
args: [--config=.flake8]
# Markdown linting
- repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.37.0
- repo: https://github.com/pre-commit/mirrors-mypy
rev: "v1.19.1"
hooks:
- id: markdownlint
args: [--fix]
- id: mypy
files: ^python/
exclude: ^python/commands/board\.py$
args: [--config-file=pyproject.toml]
additional_dependencies:
- types-requests
- pytest
- repo: local
hooks:
- id: eslint
name: eslint
entry: npx eslint --fix
language: node
types: [ts]
files: ^src/

4
.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
coverage.xml
htmlcov/
data/
*.kicad_*

7
.prettierrc.json Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": false,
"printWidth": 100,
"tabWidth": 2
}

View File

@@ -71,6 +71,7 @@ enable developer features:
```
**What it does:**
- `export_gerber` automatically copies the current MCP session log into the project's
`logs/` subdirectory as `mcp_log_<timestamp>.txt`.
- `snapshot_project` copies the MCP session log into `logs/` at every checkpoint as
@@ -142,9 +143,10 @@ automatically retains a traceable record of which tools were called and in what
The comments (`;; PASSIVES`, `;; SEMICONDUCTORS`, `;; INTEGRATED CIRCUITS`, `;; CONNECTORS`,
`;; POWER/REGULATORS`, `;; MISC`, `;; TEMPLATE INSTANCES (...)`) caused KiCAD 9 to reject
every schematic created from this template with a hard parse error:
> `Expecting '(' in <file>.kicad_sch, line 8, offset 5`
**Action required for existing projects:** delete every line beginning with `;;` from any
`.kicad_sch` file created between upstream commit `b98c94b` and this fix.
> **Action required for existing projects:** delete every line beginning with `;;` from any
> `.kicad_sch` file created between upstream commit `b98c94b` and this fix.
- `add_schematic_component` / `inject_symbol_into_schematic`: symbol definition in
`lib_symbols` was never refreshed after editing via `create_symbol` / `edit_symbol`.
@@ -214,6 +216,7 @@ automatically retains a traceable record of which tools were called and in what
### New MCP Tools (TypeScript layer previously Python-only)
**Routing tools:**
- `delete_trace` - Delete traces by UUID, position or net name
- `query_traces` - Query/filter traces on the board
- `get_nets_list` - List all nets with net code and class
@@ -223,6 +226,7 @@ automatically retains a traceable record of which tools were called and in what
- `refill_zones` - Refill all copper zones ⚠️ SWIG segfault risk, prefer IPC/UI
**Component tools:**
- `get_component_pads` - Get all pad data for a component
- `get_component_list` - List all components on the board
- `get_pad_position` - Get absolute position of a specific pad
@@ -246,6 +250,7 @@ automatically retains a traceable record of which tools were called and in what
### New MCP Tools (cont.)
**Datasheet tools:**
- `get_datasheet_url` - Return LCSC datasheet PDF URL and product page URL for a given
LCSC number (e.g. `C179739``https://www.lcsc.com/datasheet/C179739.pdf`).
No API key required URL is constructed directly from the LCSC number.
@@ -257,6 +262,7 @@ automatically retains a traceable record of which tools were called and in what
Implementation: `python/commands/datasheet_manager.py` (text-based, no `skip` writes)
**Schematic tools:**
- `delete_schematic_component` - Remove a placed symbol from a `.kicad_sch` file by
reference designator (e.g. `R1`, `U3`).
@@ -280,7 +286,7 @@ automatically retains a traceable record of which tools were called and in what
direct text manipulation (parenthesis-depth tracking, same approach as PR #40).
Does not call `component_schematic.py::remove_component()` at all.
- Error message explicitly guides the user when the wrong tool is used:
*"note: this tool removes schematic symbols, use delete_component for PCB footprints"*
_"note: this tool removes schematic symbols, use delete_component for PCB footprints"_
### Additional Bug Fixes
@@ -310,6 +316,7 @@ automatically retains a traceable record of which tools were called and in what
### Phase 1: Intelligent Schematic Wiring System - Core Infrastructure
**Major Features:**
- Automatic pin location discovery with rotation support
- Smart wire routing (direct, orthogonal horizontal/vertical)
- Net label management (local, global, hierarchical)
@@ -317,24 +324,28 @@ automatically retains a traceable record of which tools were called and in what
- Professional right-angle routing
**New Components:**
- `python/commands/wire_manager.py` - S-expression wire creation engine
- `python/commands/pin_locator.py` - Intelligent pin discovery with rotation
- Updated `python/commands/connection_schematic.py` - High-level connection API
- `docs/SCHEMATIC_WIRING_PLAN.md` - Implementation roadmap
**MCP Tools Enhanced:**
- `add_schematic_wire` - Create wires with stroke customization
- `add_schematic_connection` - Auto-connect pins with routing options (NEW)
- `add_schematic_net_label` - Add labels with type and orientation control (NEW)
- `connect_to_net` - Connect pins to named nets (ENHANCED)
**Technical Implementation:**
- Rotation transformation matrix for pin coordinates
- S-expression injection for guaranteed format compliance
- Pin definition caching for performance
- Orthogonal path generation for professional schematics
**Testing:**
- End-to-end integration test: 100% passing
- MCP handler integration test: 100% passing
- Pin discovery with rotation: Verified working
@@ -345,6 +356,7 @@ automatically retains a traceable record of which tools were called and in what
### Phase 2: Power Nets & Wire Connectivity - COMPLETE
**Major Features:**
- Power symbol support (VCC, GND, +3V3, +5V, etc.) via dynamic loading
- Wire graph analysis for net connectivity tracking
- Geometric wire tracing with tolerance-based point matching
@@ -352,6 +364,7 @@ automatically retains a traceable record of which tools were called and in what
- Critical template mapping bug fixes
**Updates:**
- `connect_to_net()` - Migrated to WireManager + PinLocator
- `get_net_connections()` - Complete rewrite with geometric wire tracing
- `generate_netlist()` - Now uses wire graph analysis for connectivity
@@ -359,12 +372,14 @@ automatically retains a traceable record of which tools were called and in what
- `add_component()` - Fixed template lookup with symbol iteration
**Bug Fixes:**
- CRITICAL: Template mapping after dynamic symbol loading
- Special character handling in symbol names (+ prefix in +3V3, +5V)
- Schematic reload synchronization after S-expression injection
- Multi-format template reference detection
**Wire Graph Analysis Algorithm:**
1. Find all labels matching target net name
2. Trace wires connected to label positions (point coincidence)
3. Collect all wire endpoints and polyline segments
@@ -372,6 +387,7 @@ automatically retains a traceable record of which tools were called and in what
5. Return accurate component/pin connection pairs
**Technical Implementation:**
- Tolerance-based point matching (0.5mm for grid alignment)
- Multi-segment wire (polyline) support
- Rotation-aware pin location matching via PinLocator
@@ -379,6 +395,7 @@ automatically retains a traceable record of which tools were called and in what
- Template existence checking via symbol iteration (handles special characters)
**Testing:**
- Power symbols: 4/4 loaded (VCC, GND, +3V3, +5V)
- Components: 4/4 placed
- Connections: 8/8 created successfully
@@ -387,11 +404,13 @@ automatically retains a traceable record of which tools were called and in what
- Comprehensive integration test: 100% PASSING
**Commits:**
- `c67f400` - Updated connect_to_net to use WireManager
- `b77f008` - Fixed template mapping bug (critical)
- `a5a542b` - Implemented wire graph analysis
**Addresses:**
- Issue #26 - Schematic workflow wiring functionality (Phase 2)
---
@@ -399,6 +418,7 @@ automatically retains a traceable record of which tools were called and in what
### Phase 2: JLCPCB Integration Complete
**Major Features:**
- ✅ Complete JLCPCB parts integration via JLCSearch public API
- ✅ Access to ~100k JLCPCB parts catalog
- ✅ Real-time stock and pricing data
@@ -408,11 +428,13 @@ automatically retains a traceable record of which tools were called and in what
- ✅ Alternative part suggestions
**New Components:**
- `python/commands/jlcsearch.py` - JLCSearch API client (no auth required)
- `python/commands/jlcpcb_parts.py` - Enhanced with `import_jlcsearch_parts()`
- `docs/JLCPCB_INTEGRATION.md` - Comprehensive integration guide
**MCP Tools Available:**
- `download_jlcpcb_database` - Download full parts catalog
- `search_jlcpcb_parts` - Parametric search with filters
- `get_jlcpcb_part` - Part details + footprint suggestions
@@ -420,18 +442,21 @@ automatically retains a traceable record of which tools were called and in what
- `suggest_jlcpcb_alternatives` - Find similar/cheaper parts
**Technical Improvements:**
- SQLite database with full-text search (FTS5)
- Package-to-footprint mapping for standard SMD packages
- Price comparison and cost optimization algorithms
- HMAC-SHA256 authentication support (for official JLCPCB API)
**Testing:**
- All integration tests passing
- Database operations validated
- Live API connectivity confirmed
- End-to-end MCP tool testing complete
**Documentation:**
- Complete API reference with examples
- Package mapping tables (0402, 0603, 0805, SOT-23, etc.)
- Best practices guide
@@ -444,30 +469,36 @@ automatically retains a traceable record of which tools were called and in what
### Phase 1: Schematic Workflow Fix
**Critical Bug Fix:**
- ✅ Fixed completely broken schematic workflow (Issue #26)
- Created template-based symbol cloning approach
- All schematic tests now passing
**Root Cause:**
- kicad-skip library limitation: cannot create symbols from scratch, only clone existing ones
**Solution:**
- Template schematic with cloneable R, C, LED symbols
- Updated `create_project` to create both PCB and schematic
- Rewrote `add_schematic_component` to use `clone()` API
- Proper UUID generation and position setting
**Files Modified:**
- `python/commands/project.py` - Now creates schematic files
- `python/commands/schematic.py` - Uses template approach
- `python/commands/component_schematic.py` - Complete rewrite
**Files Created:**
- `python/templates/template_with_symbols.kicad_sch`
- `python/templates/empty.kicad_sch`
- `docs/SCHEMATIC_WORKFLOW_FIX.md`
**Testing:**
- Created comprehensive test suite
- All 7 tests passing
- KiCad CLI validation successful
@@ -479,17 +510,20 @@ automatically retains a traceable record of which tools were called and in what
### Router Pattern & Tool Organization
**Major Architecture Change:**
- Implemented tool router pattern (70% context reduction)
- 12 direct tools, 47 routed tools in 7 categories
- Smart tool discovery system
**New Router Tools:**
- `list_tool_categories` - Browse available categories
- `get_category_tools` - View tools in category
- `search_tools` - Find tools by keyword
- `execute_tool` - Run any routed tool
**Benefits:**
- Dramatically reduced AI context usage
- Maintained full functionality (64 tools)
- Improved tool discoverability
@@ -502,18 +536,21 @@ automatically retains a traceable record of which tools were called and in what
### IPC Backend Integration
**Experimental Feature:**
- KiCad 9.0 IPC API integration for real-time UI sync
- Changes appear immediately in KiCad (no manual reload)
- Hybrid backend: IPC + SWIG fallback
- 20+ commands with IPC support
**Implementation:**
- Routing operations (interactive push-and-shove)
- Component placement and modification
- Zone operations and fills
- DRC and verification
**Status:**
- Under active development
- Enable via KiCad: Preferences > Plugins > Enable IPC API Server
- Automatic fallback to SWIG when IPC unavailable
@@ -525,14 +562,17 @@ automatically retains a traceable record of which tools were called and in what
### Initial JLCPCB Integration (Local Libraries)
**Features:**
- Local JLCPCB symbol library search
- Integration with KiCad Plugin and Content Manager
- Search by LCSC part number, manufacturer, description
**Credit:**
- Contributed by [@l3wi](https://github.com/l3wi)
**Components:**
- `python/commands/symbol_library.py`
- Basic library search functionality
@@ -543,6 +583,7 @@ automatically retains a traceable record of which tools were called and in what
### Initial Release
**Core Features:**
- 64 fully-documented MCP tools
- JSON Schema validation for all tools
- 8 dynamic resources for project state
@@ -551,6 +592,7 @@ automatically retains a traceable record of which tools were called and in what
- Detailed logging
**Tool Categories:**
- Project Management (4 tools)
- Board Operations (9 tools)
- Component Management (8 tools)
@@ -562,11 +604,13 @@ automatically retains a traceable record of which tools were called and in what
- JLCPCB Integration (5 tools)
**Platform Support:**
- Linux (KiCad 7.x, 8.x, 9.x)
- Windows (KiCad 9.x)
- macOS (KiCad 9.x)
**Documentation:**
- Complete README with setup instructions
- Platform-specific guides
- Tool reference documentation
@@ -583,9 +627,11 @@ automatically retains a traceable record of which tools were called and in what
## Breaking Changes
### 2.1.0-alpha
- None (additive changes only)
### 2.0.0-alpha
- Tool execution now requires router for 47 tools
- Direct tool access limited to 12 high-frequency tools
- Schema validation stricter (catches errors earlier)
@@ -593,6 +639,7 @@ automatically retains a traceable record of which tools were called and in what
## Deprecations
### 2.1.0-alpha
- `docs/JLCPCB_USAGE_GUIDE.md` - Superseded by `docs/JLCPCB_INTEGRATION.md`
- `docs/JLCPCB_INTEGRATION_PLAN.md` - Implementation complete
@@ -601,15 +648,18 @@ automatically retains a traceable record of which tools were called and in what
### Upgrading to 2.1.0-alpha from 2.0.0-alpha
**New Dependencies:**
- No new system dependencies
- Python packages: `requests` (already in requirements.txt)
**Database Setup:**
1. Run `download_jlcpcb_database` tool (one-time, ~5-10 minutes)
2. Database created at `data/jlcpcb_parts.db`
3. Subsequent searches use local database (instant)
**API Changes:**
- All existing tools remain compatible
- 5 new JLCPCB tools available
- No breaking changes to existing functionality
@@ -617,11 +667,13 @@ automatically retains a traceable record of which tools were called and in what
### Upgrading to 2.0.0-alpha from 1.0.0
**Router Pattern:**
- Some tools now accessed via `execute_tool` instead of direct calls
- Use `list_tool_categories` to discover available tools
- Search with `search_tools` to find specific functionality
**IPC Backend (Optional):**
- Enable in KiCad: Preferences > Plugins > Enable IPC API Server
- Set `KICAD_BACKEND=ipc` environment variable
- Falls back to SWIG if unavailable

60
CLAUDE.md Normal file
View File

@@ -0,0 +1,60 @@
# KiCAD MCP Server
## Related Source
- KiCad source code is located at `../kicad-source/` (absolute: `/home/eugene/Projects/kicad-source/`)
## Testing
### When to Write Tests
Write tests for every non-trivial change to Python handler or business logic code:
- **New MCP tools** — add tests for schema validation, handler dispatch, parameter validation, and the core logic path (happy path + key error cases).
- **Changes to existing tools** — add tests covering the changed behaviour; update any tests that no longer reflect reality.
- **Bug fixes** — add a regression test that would have caught the bug before adding the fix.
- **Refactors that delete or rename public methods** — add a test asserting the old name no longer exists (see `TestConnectionManagerOrphanedMethodsRemoved` for the pattern).
You do **not** need tests for TypeScript/TS-layer glue code that only forwards calls to Python (the TS test runner is not yet configured).
### Test Levels
| Level | Use for | Marker |
| ----------- | -------------------------------------------------------------------------------------------------- | -------------------------- |
| Unit | Schema shape, parameter validation, pure logic, mock-heavy handler dispatch | `@pytest.mark.unit` |
| Integration | Real file I/O against a `.kicad_sch` / `.kicad_pcb` copy; WireManager, JunctionManager round-trips | `@pytest.mark.integration` |
Keep unit tests free of file I/O. Keep integration tests free of business-logic assertions that belong in unit tests.
### Where to Put Tests
```
tests/
test_<feature_name>.py # all Python tests go here
```
Group related test classes inside a single file (e.g. `TestSchemas`, `TestHandlerDispatch`, `TestHandleAddSchematicWireRouting` all in `test_wire_junction_changes.py`). Name classes `Test<Area>` and methods `test_<what_is_verified>`.
Use `python/templates/empty.kicad_sch` as the base fixture for integration tests — copy it to a `tempfile` directory, run the handler, then parse the result with `sexpdata`.
### Running Tests
Always use the `.venv` virtualenv for Python commands:
```bash
npm run test:py # pytest tests/ -v
.venv/bin/pytest tests/ -v # all Python tests
.venv/bin/pytest -m unit # unit tests only
.venv/bin/pytest -m integration # integration tests only
.venv/bin/pytest --cov=python # with coverage report
.venv/bin/mypy python/ # type checking
```
## Git Workflow
- **Never open a pull request automatically.** Commit and push when asked, but always wait for explicit instructions before running `gh pr create` or any equivalent command.
## Python Code Style
- **Never use `assert` in production code** — raise a specific exception (`ValueError`, `RuntimeError`, etc.) instead. `assert` is stripped in optimised builds and gives poor error messages.
- **Do not introduce logic-breaking workarounds to satisfy the type checker** (e.g. `x or ""` when `""` is not a valid substitute for `None`). Fix the types or narrow with a proper guard (`if x is None: raise ...`).

View File

@@ -6,6 +6,7 @@ Thank you for your interest in contributing to the KiCAD MCP Server! This guide
- [Development Environment Setup](#development-environment-setup)
- [Project Structure](#project-structure)
- [Architecture Overview](#architecture-overview)
- [Development Workflow](#development-workflow)
- [Testing](#testing)
- [Code Style](#code-style)
@@ -38,7 +39,7 @@ curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
# Clone the repository
git clone https://github.com/yourusername/kicad-mcp-server.git
git clone https://github.com/mixelpixx/KiCAD-MCP-Server.git
cd kicad-mcp-server
# Install Node.js dependencies
@@ -63,7 +64,7 @@ pytest
# Install Node.js from https://nodejs.org/
# Clone the repository
git clone https://github.com/yourusername/kicad-mcp-server.git
git clone https://github.com/mixelpixx/KiCAD-MCP-Server.git
cd kicad-mcp-server
# Install Node.js dependencies
@@ -89,7 +90,7 @@ pytest
brew install node
# Clone the repository
git clone https://github.com/yourusername/kicad-mcp-server.git
git clone https://github.com/mixelpixx/KiCAD-MCP-Server.git
cd kicad-mcp-server
# Install Node.js dependencies
@@ -106,6 +107,25 @@ npm test
pytest
```
### Pre-commit Hooks
This project uses [pre-commit](https://pre-commit.com/) to run linters and formatters automatically before each commit. Pre-commit hooks prevent noisy formatting diffs caused by different IDE configurations across contributors, catch common mistakes and type errors before they reach code review, and ensure every commit in the repository meets the same quality baseline — so reviewers can focus on logic and design rather than style issues.
**All contributors must install pre-commit hooks after cloning the repo:**
```bash
# Install pre-commit (if not already installed)
pip install pre-commit
# Install the git hooks
pre-commit install
# (Optional) Run against all files to verify setup
pre-commit run --all-files
```
> **Important:** Do not use `git commit --no-verify` to bypass pre-commit hooks. The hooks enforce code quality checks (Black, isort, Prettier, flake8, mypy, ESLint) that must pass before code is merged. If a hook fails, fix the underlying issue rather than skipping the check.
---
## Project Structure
@@ -144,6 +164,22 @@ kicad-mcp-server/
---
## Architecture Overview
The KiCAD MCP Server is organized into several key components:
- **TypeScript MCP Server** (`src/`) - Handles MCP protocol communication and tool routing
- **Python KiCAD Interface** (`python/`) - Interfaces with KiCAD's Python API (pcbnew)
- **Tool Router** - Organizes 122+ tools into 8 discoverable categories
- **Resource System** - Provides dynamic project/board state information
- **Prompt System** - Offers context-aware design prompts
**Current Tool Count:** 122+ tools across 8 categories (direct + routed)
For detailed architecture information, see `docs/ROUTER_ARCHITECTURE.md`.
---
## Development Workflow
### 1. Create a Feature Branch
@@ -192,6 +228,7 @@ git commit -m "feat: Add your feature description"
```
**Commit Message Convention:**
- `feat:` - New feature
- `fix:` - Bug fix
- `docs:` - Documentation changes
@@ -285,6 +322,7 @@ pylint python/
```
**Python Style Guidelines:**
- Use type hints for all function signatures
- Use pathlib.Path for file paths (not os.path)
- Use descriptive variable names
@@ -292,6 +330,7 @@ pylint python/
- Follow PEP 8
**Example:**
```python
from pathlib import Path
from typing import List, Optional
@@ -328,6 +367,7 @@ npx eslint src/
```
**TypeScript Style Guidelines:**
- Use interfaces for data structures
- Use async/await for asynchronous code
- Use descriptive variable names

493
README.md
View File

@@ -1,3 +1,6 @@
# Discussions. Get in here.
https://github.com/mixelpixx/KiCAD-MCP-Server/discussions/73
# KiCAD MCP Server
@@ -8,24 +11,24 @@ A Model Context Protocol (MCP) server that enables AI assistants like Claude to
The [Model Context Protocol](https://modelcontextprotocol.io/) is an open standard from Anthropic that allows AI assistants to securely connect to external tools and data sources. This implementation provides a standardized bridge between AI assistants and KiCAD, enabling natural language control of PCB design operations.
**Key Capabilities:**
- 64 fully-documented tools with JSON Schema validation
- 122 tools across 16 categories with JSON Schema validation
- Smart tool discovery with router pattern (reduces AI context by 70%)
- 8 dynamic resources exposing project state
- Complete schematic workflow with 27 tools and dynamic symbol loading (~10,000 symbols)
- Freerouting autorouter integration (Java, Docker, or Podman)
- Custom footprint and symbol creation tools
- JLCPCB parts integration with 2.5M+ component catalog and local library search
- Datasheet enrichment via LCSC
- Full MCP 2025-06-18 protocol compliance
- Cross-platform support (Linux, Windows, macOS)
- Real-time KiCAD UI integration via IPC API (experimental)
- Comprehensive error handling and logging
## Try out Arduino MCP - now you can get Claude to help in the IDE, real time!:
https://github.com/mixelpixx/arduino-ide
## What's New in v2.2.3
### New Tools: FFC/Ribbon Cable Passthrough Workflow
@@ -62,7 +65,7 @@ the MCP session log into the project's `logs/` folder on every `export_gerber` a
}
```
> ⚠️ **Privacy warning:** The session log contains your full tool call history
> **Privacy warning:** The session log contains your full tool call history
> (including file paths and design details). **Review or delete `logs/` before
> sharing a project directory publicly.**
@@ -73,9 +76,11 @@ See [CHANGELOG](CHANGELOG.md) for the full list of changes in this release.
## What's New in v2.1.0
### Critical Schematic Workflow Fix + Complete Wiring System (Issue #26)
The schematic workflow was completely broken in previous versions - **this is now fixed AND dramatically enhanced!**
**What was broken:**
- `create_project` only created PCB files, no schematics
- `add_schematic_component` called non-existent API methods
- Schematics couldn't be created or edited at all
@@ -85,11 +90,13 @@ The schematic workflow was completely broken in previous versions - **this is no
**Complete Implementation (3 Phases):**
**Phase 1: Component Placement Foundation**
- `create_project` now creates both .kicad_pcb and .kicad_sch files
- Added pre-configured template schematics with 13 common component types
- Rewrote component placement to use proper `clone()` API
**Phase 2: Dynamic Symbol Loading (BREAKTHROUGH!)**
- **Access to ALL ~10,000 KiCad symbols** from standard libraries
- Automatic detection and dynamic loading from `.kicad_sym` library files
- Zero configuration required - just specify library and symbol name
@@ -97,6 +104,7 @@ The schematic workflow was completely broken in previous versions - **this is no
- Full S-expression parsing and injection system
**Phase 3: Intelligent Wiring System (NEW in v2.1.0)**
- **Automatic pin location discovery** with rotation support (0°, 90°, 180°, 270°)
- **Smart wire routing** (direct, orthogonal horizontal-first, orthogonal vertical-first)
- **Power symbol support** (VCC, GND, +3V3, +5V, etc.)
@@ -119,6 +127,7 @@ The kicad-skip library cannot create symbols or wires from scratch. We implement
5. **Connectivity Analysis:** Geometric wire tracing to build net connection graphs
**Example - Complete Circuit Creation:**
```python
# Load power symbols dynamically
loader.load_symbol_dynamically(sch_path, "power", "VCC")
@@ -143,6 +152,7 @@ connections = ConnectionManager.get_net_connections(sch, "VCC", sch_path)
```
**Test Results:**
- Component placement: 100% passing
- Dynamic symbol loading: 10,000+ symbols accessible
- Wire creation: 100% passing (8/8 connections in test circuit)
@@ -150,10 +160,12 @@ connections = ConnectionManager.get_net_connections(sch, "VCC", sch_path)
- Net connectivity: 100% accurate (VCC: 2 connections, GND: 4 connections)
- Netlist generation: Working with accurate pin-level connections
See [Dynamic Loading Status](docs/DYNAMIC_LOADING_STATUS.md) and [Wiring Implementation Plan](docs/SCHEMATIC_WIRING_PLAN.md) for technical details.
See [Schematic Tools Reference](docs/SCHEMATIC_TOOLS_REFERENCE.md) for the complete schematic tool documentation.
### IPC Backend (Experimental)
We are currently implementing and testing the KiCAD 9.0 IPC API for real-time UI synchronization:
- Changes made via MCP tools appear immediately in the KiCAD UI
- No manual reload required when IPC is active
- Hybrid backend: uses IPC when available, falls back to SWIG API
@@ -162,29 +174,35 @@ We are currently implementing and testing the KiCAD 9.0 IPC API for real-time UI
Note: IPC features are under active development and testing. Enable IPC in KiCAD via Preferences > Plugins > Enable IPC API Server.
### Tool Discovery & Router Pattern
We've implemented an intelligent tool router to keep AI context efficient while maintaining full functionality:
- **12 direct tools** always visible for high-frequency operations
- **47 routed tools** organized into 7 categories (board, component, export, drc, schematic, library, routing)
- **18 direct tools** always visible for high-frequency operations
- **65 routed tools** organized into 8 categories (board, component, export, drc, schematic, library, routing, autoroute)
- **35 additional tools** always visible (symbol/footprint creators, JLCPCB, datasheet, advanced routing)
- **4 router tools** for discovery and execution:
- `list_tool_categories` - Browse all available categories
- `get_category_tools` - View tools in a specific category
- `search_tools` - Find tools by keyword
- `execute_tool` - Run any tool with parameters
**Why this matters:** By organizing tools into discoverable categories, Claude can intelligently find and use the right tool for your task without loading all 64 tool schemas into every conversation. This reduces context consumption by up to 70% while maintaining full access to all functionality.
**Why this matters:** By organizing tools into discoverable categories, Claude can intelligently find and use the right tool for your task without loading all 122 tool schemas into every conversation. This reduces context consumption while maintaining full access to all functionality.
**Usage is seamless:** Just ask naturally - "export gerber files" or "add mounting holes" - and Claude will discover and execute the appropriate tools automatically.
### NEEDS TESTING - REPORT ISSUES
### JLCPCB Parts Integration (New!)
Complete integration with JLCPCB's parts catalog, providing two complementary approaches for component selection:
**Dual-Mode Architecture:**
1. **Local Symbol Libraries** - Search JLCPCB libraries installed via KiCAD Plugin and Content Manager (contributed by [@l3wi](https://github.com/l3wi))
2. **JLCPCB API Integration** - Access the complete 2.5M+ parts catalog with real-time pricing and stock data
**Key Features:**
- Real-time pricing with quantity breaks (1+, 10+, 100+, 1000+)
- Stock availability checking
- Basic vs Extended library type identification (Basic = free assembly)
@@ -199,7 +217,9 @@ Complete integration with JLCPCB's parts catalog, providing two complementary ap
See [JLCPCB Usage Guide](docs/JLCPCB_USAGE_GUIDE.md) for detailed setup and usage instructions.
### Comprehensive Tool Schemas
Every tool now includes complete JSON Schema definitions with:
- Detailed parameter descriptions and constraints
- Input validation with type checking
- Required vs. optional parameter specifications
@@ -207,7 +227,9 @@ Every tool now includes complete JSON Schema definitions with:
- Clear documentation of what each tool does
### Resources Capability
Access project state without executing tools:
- `kicad://project/current/info` - Project metadata
- `kicad://project/current/board` - Board properties
- `kicad://project/current/components` - Component list (JSON)
@@ -218,6 +240,7 @@ Access project state without executing tools:
- `kicad://board/preview.png` - Board visualization (PNG)
### Protocol Compliance
- Updated to MCP SDK 1.21.0 (latest)
- Full JSON-RPC 2.0 support
- Proper capability negotiation
@@ -225,108 +248,167 @@ Access project state without executing tools:
## Available Tools
The server provides 64 tools organized into functional categories. With the new router pattern, tools are automatically discovered as needed - just ask Claude what you want to accomplish!
The server provides **122 tools** organized into 16 functional categories. With the router pattern, tools are automatically discovered as needed -- just ask Claude what you want to accomplish.
For the complete tool reference with access types (direct/routed/additional), see [Tool Inventory](docs/TOOL_INVENTORY.md).
### Project Management (5 tools)
### Project Management (4 tools)
- `create_project` - Initialize new KiCAD projects
- `open_project` - Load existing project files
- `save_project` - Save current project state
- `get_project_info` - Retrieve project metadata
- `snapshot_project` - Save named checkpoint snapshot
### Board Operations (12 tools)
### Board Operations (9 tools)
- `set_board_size` - Configure PCB dimensions
- `add_board_outline` - Create board edge (rectangle, circle, polygon)
- `add_board_outline` - Create board edge (rectangle, circle, polygon, rounded rectangle)
- `add_layer` - Add custom layers to stack
- `set_active_layer` - Switch working layer
- `get_layer_list` - List all board layers
- `get_board_info` - Retrieve board properties
- `get_board_2d_view` - Generate board preview image
- `get_board_extents` - Get board bounding box
- `add_mounting_hole` - Place mounting holes
- `add_board_text` - Add text annotations
- `add_zone` - Add copper zone/pour with clearance settings
- `import_svg_logo` - Import SVG file as PCB silkscreen polygons
### Component Management (16 tools)
### Component Placement (10 tools)
- `place_component` - Place single component with footprint
- `move_component` - Reposition existing component
- `rotate_component` - Rotate component by angle
- `delete_component` - Remove component from board
- `edit_component` - Modify component properties
- `find_component` - Search by reference or value
- `get_component_properties` - Query component details
- `add_component_annotation` - Add annotation/comment
- `group_components` - Group multiple components
- `replace_component` - Replace with different footprint
- `get_component_pads` - Get all pad information
- `get_component_list` - List all placed components
- `get_pad_position` - Get precise pad position
- `place_component_array` - Create component grids/patterns
- `align_components` - Align multiple components
- `duplicate_component` - Copy existing component
### Routing & Nets (8 tools)
### Routing (13 tools)
- `add_net` - Create electrical net
- `route_trace` - Route copper traces
- `route_trace` - Route copper traces between XY points
- `route_pad_to_pad` - Route between pads with auto-via insertion
- `add_via` - Place vias for layer transitions
- `delete_trace` - Remove traces
- `get_nets_list` - List all nets
- `delete_trace` - Remove traces (by UUID, position, or net)
- `query_traces` - Query/filter traces
- `get_nets_list` - List all nets with statistics
- `modify_trace` - Change trace width, layer, or net
- `create_netclass` - Define net class with rules
- `add_copper_pour` - Create copper zones/pours
- `route_differential_pair` - Route differential signals
- `refill_zones` - Refill all copper zones
- `copy_routing_pattern` - Replicate routing between component groups
### Library Management (4 tools)
- `list_libraries` - List available footprint libraries
- `search_footprints` - Search for footprints
- `list_library_footprints` - List footprints in library
- `get_footprint_info` - Get footprint details
### Schematic (27 tools)
Complete schematic workflow with dynamic symbol loading (~10,000 symbols) and intelligent wiring.
**Component Operations:**
- `add_schematic_component` - Place symbols from any KiCad library
- `delete_schematic_component` - Remove component
- `edit_schematic_component` - Edit properties and fields
- `get_schematic_component` - Get component info with field positions
- `list_schematic_components` - List all components
- `move_schematic_component` - Reposition component
- `rotate_schematic_component` - Rotate component
- `annotate_schematic` - Auto-assign reference designators
**Wiring and Connections:**
- `add_wire` - Create wire between points
- `delete_schematic_wire` - Remove wire segment
- `add_schematic_connection` - Auto-connect pins with routing
- `add_schematic_net_label` - Add net labels (VCC, GND, signals)
- `delete_schematic_net_label` - Remove net label
- `connect_to_net` - Connect pin to named net
- `connect_passthrough` - Wire all matching pins between connectors (FFC/ribbon)
- `get_schematic_pin_locations` - Get pin locations for component
**Analysis and Export:**
- `get_net_connections` - Trace net connectivity
- `list_schematic_nets` / `list_schematic_wires` / `list_schematic_labels`
- `create_schematic` - Create new schematic file
- `get_schematic_view` - Rasterized schematic preview
- `export_schematic_svg` / `export_schematic_pdf`
- `run_erc` - Electrical rule check
- `generate_netlist` - Generate netlist from schematic
- `sync_schematic_to_board` - Import nets/pads to PCB (F8 equivalent)
See [Schematic Tools Reference](docs/SCHEMATIC_TOOLS_REFERENCE.md) for details and examples.
### Design Rules / DRC (8 tools)
- `set_design_rules` / `get_design_rules` - Configure and inspect rules
- `run_drc` - Execute design rule check
- `get_drc_violations` - Get violation list by severity
- `add_net_class` / `assign_net_to_class` - Net class management
- `set_layer_constraints` / `check_clearance` - Layer and clearance rules
### Export (8 tools)
- `export_gerber` - Gerber fabrication files
- `export_pdf` / `export_svg` - Documentation and vector graphics
- `export_3d` - 3D models (STEP, STL, VRML, OBJ)
- `export_bom` - Bill of materials (CSV, XML, HTML, JSON)
- `export_netlist` - Netlist (KiCad, Spice, Cadstar, OrcadPCB2)
- `export_position_file` - Component positions for pick and place
- `export_vrml` - VRML 3D model
### Footprint Libraries (4 tools) and Symbol Libraries (4 tools)
- `list_libraries` / `list_symbol_libraries` - Browse available libraries
- `search_footprints` / `search_symbols` - Search across all libraries
- `list_library_footprints` / `list_library_symbols` - Browse specific library
- `get_footprint_info` / `get_symbol_info` - Detailed information
### Footprint Creator (4 tools) and Symbol Creator (4 tools)
Create custom components when existing libraries do not have what you need.
- `create_footprint` / `create_symbol` - Build from scratch with pads/pins
- `edit_footprint_pad` - Modify pad properties
- `register_footprint_library` / `register_symbol_library` - Register in lib-table
- `list_footprint_libraries` / `list_symbols_in_library` - Browse custom libraries
- `delete_symbol` - Remove symbol from library
See [Footprint and Symbol Creator Guide](docs/FOOTPRINT_SYMBOL_CREATOR_GUIDE.md) for details.
### Datasheet Tools (2 tools)
- `enrich_datasheets` - Auto-populate datasheet URLs using LCSC part numbers
- `get_datasheet_url` - Get LCSC datasheet URL for a component
### JLCPCB Integration (5 tools)
- `download_jlcpcb_database` - Download complete JLCPCB parts catalog (one-time setup)
- `search_jlcpcb_parts` - Search 2.5M+ parts with parametric filters
- `get_jlcpcb_part` - Get detailed part info with pricing and footprints
- `get_jlcpcb_database_stats` - View database statistics and coverage
- `suggest_jlcpcb_alternatives` - Find cheaper or more available alternatives
### Design Rules (4 tools)
- `set_design_rules` - Configure DRC parameters
- `get_design_rules` - Retrieve current rules
- `run_drc` - Execute design rule check
- `get_drc_violations` - Get DRC error report
- `download_jlcpcb_database` - Download 2.5M+ parts catalog (one-time setup)
- `search_jlcpcb_parts` - Search with parametric filters
- `get_jlcpcb_part` - Detailed part info with pricing
- `get_jlcpcb_database_stats` - Database statistics
- `suggest_jlcpcb_alternatives` - Find cheaper or in-stock alternatives
### Export (5 tools)
- `export_gerber` - Generate Gerber fabrication files
- `export_pdf` - Export PDF documentation
- `export_svg` - Create SVG vector graphics
- `export_3d` - Generate 3D models (STEP/VRML)
- `export_bom` - Produce bill of materials
### Freerouting Autorouter (4 tools)
### Schematic Design (9 tools)
**Now fully functional with DYNAMIC SYMBOL LOADING + INTELLIGENT WIRING!** (Fixed in v2.1.0 - see Issue #26)
- `autoroute` - Run Freerouting autorouter (DSN export, route, SES import)
- `export_dsn` / `import_ses` - Manual Specctra DSN/SES workflow
- `check_freerouting` - Verify Java and Freerouting availability
**Component Placement:**
- `create_schematic` - Initialize new schematic from template
- `load_schematic` - Open existing schematic
- `add_schematic_component` - Place symbols with automatic dynamic loading from KiCad libraries
- `list_schematic_libraries` - List symbol libraries
- `export_schematic_pdf` - Export schematic PDF
**Wiring & Connections:** NEW in v2.1.0
- `add_schematic_wire` - Create wires between points with customizable stroke
- `add_schematic_connection` - Auto-connect pins with intelligent routing (direct, orthogonal)
- `add_schematic_net_label` - Add net labels (VCC, GND, signals) with orientation control
- `connect_to_net` - Connect component pins to named nets
**Major Enhancements:**
1. **Dynamic Symbol Loading** - Access to **ALL ~10,000 KiCad symbols**! Specify any `library` and `type` (e.g., `"library": "MCU_ST_STM32F1", "type": "STM32F103C8Tx"`) and the system automatically:
- Searches KiCad symbol libraries
- Injects symbol definition into your schematic
- Creates cloneable template instance
- Places component seamlessly
- Fallback to 13 static templates (R, C, L, LED, etc.) when needed
2. **Intelligent Wiring System** - Professional schematic wiring with automation:
- **Automatic pin discovery** - rotation-aware (0°, 90°, 180°, 270°)
- **Smart routing** - direct lines or orthogonal (right-angle) paths
- **Power symbol support** - VCC, GND, +3V3, +5V, etc.
- **Wire graph analysis** - geometric tracing for accurate net connectivity
- **Net label management** - local, global, and hierarchical labels
- **Netlist generation** - accurate component/pin connection tracking
- **S-expression precision** - guaranteed KiCad format compliance
See [Freerouting Guide](docs/FREEROUTING_GUIDE.md) for setup and usage.
### UI Management (2 tools)
- `check_kicad_ui` - Check if KiCAD is running
- `launch_kicad_ui` - Launch KiCAD application
@@ -335,6 +417,7 @@ The server provides 64 tools organized into functional categories. With the new
### Required Software
**KiCAD 9.0 or Higher**
- Download from [kicad.org/download](https://www.kicad.org/download/)
- Must include Python module (pcbnew)
- Verify installation:
@@ -343,10 +426,12 @@ The server provides 64 tools organized into functional categories. With the new
```
**Node.js 18 or Higher**
- Download from [nodejs.org](https://nodejs.org/)
- Verify: `node --version` and `npm --version`
**Python 3.10 or Higher**
- Usually included with KiCAD
- Required packages (auto-installed):
- kicad-python (kipy) >= 0.5.0 (IPC API support, optional but recommended)
@@ -360,11 +445,13 @@ The server provides 64 tools organized into functional categories. With the new
**MCP Client**
Choose one:
- [Claude Desktop](https://claude.ai/download) - Official Anthropic desktop app
- [Claude Code](https://docs.claude.com/claude-code) - Official CLI tool
- [Cline](https://github.com/cline/cline) - VSCode extension
### Supported Platforms
- **Linux** (Ubuntu 22.04+, Fedora, Arch) - Primary platform, fully tested
- **Windows 10/11** - Fully supported with automated setup
- **macOS** - Experimental support
@@ -397,6 +484,7 @@ python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())"
### Windows 10/11
**Automated Setup (Recommended):**
```powershell
git clone https://github.com/mixelpixx/KiCAD-MCP-Server.git
cd KiCAD-MCP-Server
@@ -404,6 +492,7 @@ cd KiCAD-MCP-Server
```
The script will:
- Detect KiCAD installation
- Verify prerequisites
- Install dependencies
@@ -416,7 +505,9 @@ See [Windows Installation Guide](docs/WINDOWS_SETUP.md) for detailed instruction
### macOS
**Important:** On macOS, use KiCAD's bundled Python to ensure proper access to pcbnew module.
**Important:** On macOS, use KiCAD's bundled Python to ensure proper access to the `pcbnew` module.
#### Manual Setup
```bash
# Install KiCAD 9.0 from kicad.org/download/macos
@@ -440,17 +531,148 @@ pip install -r requirements.txt
npm run build
```
**Note:** The `--system-site-packages` flag is required to access KiCAD's pcbnew module from the virtual environment.
**Note:** The `--system-site-packages` flag is required to access KiCAD's `pcbnew` module from the virtual environment.
#### Automated Setup
To simplify configuration with Claude Desktop, this repository provides a macOS setup script:
```bash
./setup-macos.sh
```
In case of error `zsh: permission denied: ./setup-macos.sh` you can either:
- always allow the script to be executed by running: `chmod +x setup-macos.sh`.
- alternatively explicitly run it with bash: `bash setup-macos.sh` so no chmod change needed.
This script does **not replace the manual setup above** — it assumes dependencies are already installed and the project is built. Instead, it automates:
- detection of your environment (Node.js, KiCad Python, `pcbnew`)
- resolving the correct macOS `PYTHONPATH`
- generating the correct Claude Desktop MCP configuration
- safely merging the configuration into your existing Claude config
- optionally writing the configuration with backup support
##### Basic Usage
###### Verify setup (no changes)
```bash
./setup-macos.sh --verify
```
###### Preview configuration (dry run)
```bash
./setup-macos.sh --dry-run
```
###### Apply configuration
```bash
./setup-macos.sh --apply
```
After applying, restart Claude Desktop.
##### Parameters
###### Required parameters
None. The script works out-of-the-box using sensible defaults.
###### Optional parameters
##### `--name NAME`
Specify the MCP server name in Claude Desktop.
Default:
```text
kicad
```
Example:
```bash
./setup-macos.sh --apply --name kicad-dev
```
Use this when:
- running multiple MCP configurations
- testing forks or development versions
- avoiding overwriting an existing setup
##### `--claude-config PATH`
Specify a custom Claude Desktop configuration file.
Default:
```text
~/Library/Application Support/Claude/claude_desktop_config.json
```
Example:
```bash
./setup-macos.sh --dry-run --claude-config ~/tmp/claude_config.json
```
Use this when:
- testing configurations safely
- using non-standard config locations
- debugging without modifying your main setup
##### `--yes`
Skip confirmation prompt when applying changes.
Example:
```bash
./setup-macos.sh --apply --yes
```
##### After Setup
1. Fully quit Claude Desktop
2. Reopen Claude Desktop
3. Open a new chat
4. Click **+ → Connectors**
5. Verify the server appears (e.g. `kicad` or your custom name)
Test with prompt in Claude Desktop:
```text
Use the kicad MCP server to run check_kicad_ui.
```
##### Notes
- The script only modifies the `mcpServers` section and leaves all other configuration untouched
- Existing configurations are automatically backed up before changes
- macOS support relies on KiCads bundled Python; system Python will not work correctly
- If KiCad is updated or moved, re-run the script to refresh paths
---
## Configuration
### Claude Desktop
Edit configuration file:
- **Linux/macOS:** `~/.config/Claude/claude_desktop_config.json`
- **Linux:** `~/.config/Claude/claude_desktop_config.json`
- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
**Configuration:**
```json
{
"mcpServers": {
@@ -467,6 +689,7 @@ Edit configuration file:
```
**Platform-specific PYTHONPATH:**
- **Linux:** `/usr/lib/kicad/lib/python3/dist-packages`
- **Windows:** `C:\Program Files\KiCad\9.0\lib\python3\dist-packages`
- **macOS:** `/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages`
@@ -503,6 +726,7 @@ If you see "Python executable not found: python3", you can manually specify the
```
To find your Python path:
```bash
which python3 # Example output: /usr/bin/python3
python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())" # Verify pcbnew access
@@ -525,12 +749,14 @@ The JLCPCB integration provides two modes that can be used independently or toge
**Mode 1: JLCSearch Public API (Recommended - No Setup Required)**
The easiest way to access JLCPCB's parts catalog:
- No API credentials needed
- No JLCPCB account required
- Access to 2.5M+ parts with pricing and stock data
- Download time: 40-60 minutes for full catalog (100-part batches due to API limit)
To download the database:
```
Ask Claude: "Download the JLCPCB parts database"
```
@@ -540,6 +766,7 @@ This creates a local SQLite database at `data/jlcpcb_parts.db` (3-5 GB for full
**Mode 2: Local Symbol Libraries (No Setup Required)**
Install JLCPCB libraries via KiCAD's Plugin and Content Manager:
1. Open KiCAD
2. Go to Tools > Plugin and Content Manager
3. Search for "JLCPCB" or "JLC"
@@ -559,12 +786,14 @@ For users with JLCPCB enterprise accounts and order history:
2. **Configure Environment Variables**
Add to your shell profile (`~/.bashrc`, `~/.zshrc`, or `~/.profile`):
```bash
export JLCPCB_API_KEY="your_app_key_here"
export JLCPCB_API_SECRET="your_app_secret_here"
```
Or create a `.env` file in the project root:
```
JLCPCB_API_KEY=your_app_key_here
JLCPCB_API_SECRET=your_app_secret_here
@@ -703,13 +932,15 @@ How many Basic parts are available?
## Architecture
### MCP Protocol Layer
- **JSON-RPC 2.0 Transport:** Bi-directional communication via STDIO
- **Protocol Version:** MCP 2025-06-18
- **Capabilities:** Tools (59), Resources (8)
- **Tool Router:** Intelligent discovery system with 7 categories
- **Capabilities:** Tools (122), Resources (8)
- **Tool Router:** Intelligent discovery system with 8 categories
- **Error Handling:** Standard JSON-RPC error codes
### TypeScript Server (`src/`)
- Implements MCP protocol specification
- Manages Python subprocess lifecycle
- Handles message routing and validation
@@ -720,6 +951,7 @@ How many Basic parts are available?
- Reduces AI context usage by 70% while maintaining full functionality
### Python Interface (`python/`)
- **kicad_interface.py:** Main entry point, MCP message handler, command routing
- **kicad_api/:** Backend implementations
- `base.py` - Abstract base classes for backends
@@ -742,6 +974,7 @@ How many Basic parts are available?
- `jlcpcb_parts.py` - JLCPCB parts database manager
### KiCAD Integration
- **pcbnew API (SWIG):** Direct Python bindings to KiCAD for file operations
- **IPC API (kipy):** Real-time communication with running KiCAD instance (experimental)
- **Hybrid Backend:** Automatically uses IPC when available, falls back to SWIG
@@ -795,6 +1028,7 @@ npm run format
**Symptoms:** MCP server doesn't show up in Claude Desktop or Cline
**Solutions:**
1. Verify build completed: `ls dist/index.js`
2. Check configuration paths are absolute
3. Restart MCP client completely
@@ -805,6 +1039,7 @@ npm run format
**Symptoms:** `ModuleNotFoundError: No module named 'pcbnew'`
**Solutions:**
1. Verify KiCAD installation: `python3 -c "import pcbnew"`
2. Check PYTHONPATH in configuration matches your KiCAD installation
3. Ensure KiCAD was installed with Python support
@@ -814,6 +1049,7 @@ npm run format
**Symptoms:** Tools fail with unclear errors
**Solutions:**
1. Check server logs: `~/.kicad-mcp/logs/kicad_interface.log`
2. Verify a project is loaded before running board operations
3. Ensure file paths are absolute, not relative
@@ -824,6 +1060,7 @@ npm run format
**Symptoms:** Server fails to start on Windows
**Solutions:**
1. Run automated diagnostics: `.\setup-windows.ps1`
2. Verify Python path uses double backslashes: `C:\\Program Files\\KiCad\\9.0`
3. Check Windows Event Viewer for Node.js errors
@@ -842,62 +1079,49 @@ npm run format
## Project Status
**Current Version:** 2.1.0-alpha
**Current Version:** 2.2.3
**Working Features:**
- Project creation and management (PCB + Schematic)
- Board outline and sizing
- Layer management
- Component placement with footprint library loading
- Mounting holes and text annotations
- Design rule checking
- Export to Gerber, PDF, SVG, 3D
- **Schematic creation and editing (Issue #26 RESOLVED - fully functional!)**
- **DYNAMIC SYMBOL LOADING - Access to ALL ~10,000 KiCad symbols! 🚀**
- Template-based schematic workflow with automatic dynamic injection
- Symbol cloning from static templates (13 types) and dynamic libraries
- UI auto-launch
- Full MCP protocol compliance
- JLCPCB parts integration (local libraries + JLCSearch API)
- Cost optimization and component selection with 2.5M+ parts catalog
See [STATUS_SUMMARY.md](docs/STATUS_SUMMARY.md) for the complete status matrix and [CHANGELOG.md](CHANGELOG.md) for detailed release notes.
**Working Features (122 tools):**
- Project management with snapshot checkpointing
- Complete board design (outline, layers, zones, mounting holes, text, SVG logos)
- Component placement with arrays, alignment, and duplication
- Advanced routing (pad-to-pad with auto-via, differential pairs, pattern copying)
- Complete schematic workflow with dynamic symbol loading (~10,000 symbols)
- Intelligent wiring system with pin discovery and smart routing
- FFC/ribbon cable passthrough workflow
- Schematic-to-board synchronization
- Design rule checking (DRC and ERC)
- Export to Gerber, PDF, SVG, 3D, BOM, netlist, position file
- Custom footprint and symbol creation
- JLCPCB parts integration (2.5M+ parts catalog)
- Datasheet enrichment via LCSC
- Freerouting autorouter integration (Java, Docker, Podman)
- UI auto-launch and management
- Full MCP 2025-06-18 protocol compliance
**IPC Backend (Experimental):**
**Under Active Development (IPC Backend):**
- Real-time UI synchronization via KiCAD 9.0 IPC API
- IPC-enabled commands: route_trace, add_via, place_component, move_component, delete_component, add_copper_pour, refill_zones, add_board_outline, add_mounting_hole, and more
- 21 IPC-enabled commands with automatic SWIG fallback
- Hybrid footprint loading (SWIG for library access, IPC for placement)
- Zone/copper pour support via IPC
Note: IPC features are experimental and under testing. Some commands may not work as expected in all scenarios.
**Developer Mode:**
Set `KICAD_MCP_DEV=1` to capture MCP session logs for debugging. See CHANGELOG v2.2.3 for details.
**Planned:**
- Digikey API integration
- Mouser API integration
- Advanced routing algorithms
- Smart BOM management with real-time pricing
- AI-assisted component selection and optimization
- Design pattern library (Arduino shields, RPi HATs)
- Panelization support
See [ROADMAP.md](docs/ROADMAP.md) for detailed development timeline.
See [ROADMAP.md](docs/ROADMAP.md) for planned features.
## What Do You Want to See Next?
We're actively developing new features and tools for the KiCAD MCP Server. **Your input matters!**
**We'd love to hear from you:**
- What PCB design workflows could be automated?
- Which component suppliers should we integrate next (Digikey, Mouser, Arrow, etc.)?
- What export formats or manufacturing outputs do you need?
- Are there specific routing algorithms or design patterns you want?
- What pain points in your KiCAD workflow could AI help solve?
- How can we improve the JLCPCB integration?
We are actively developing new features. Your feedback directly shapes development priorities.
**Share your ideas:**
1. 💡 [Open a feature request](https://github.com/mixelpixx/KiCAD-MCP-Server/issues/new?labels=enhancement&template=feature_request.md)
2. 💬 [Join the discussion](https://github.com/mixelpixx/KiCAD-MCP-Server/discussions)
3. ⭐ Star the repo if you find it useful!
Your feedback directly shapes our development priorities. Whether it's a small quality-of-life improvement or a major new capability, we want to hear about it.
1. [Open a feature request](https://github.com/mixelpixx/KiCAD-MCP-Server/issues/new?labels=enhancement&template=feature_request.md)
2. [Join the discussion](https://github.com/mixelpixx/KiCAD-MCP-Server/discussions)
3. Star the repo if you find it useful
## Contributing
@@ -924,10 +1148,21 @@ This project is licensed under the MIT License. See [LICENSE](LICENSE) for detai
- Built on the [Model Context Protocol](https://modelcontextprotocol.io/) by Anthropic
- Powered by [KiCAD](https://www.kicad.org/) open-source PCB design software
- Uses [kicad-skip](https://github.com/kicad-skip) for schematic manipulation
- JLCPCB local library search contributed by [@l3wi](https://github.com/l3wi) - [PR #25](https://github.com/mixelpixx/KiCAD-MCP-Server/pull/25)
- [JLCSearch API](https://jlcsearch.tscircuit.com/) by [@tscircuit](https://github.com/tscircuit/jlcsearch) - Public JLCPCB parts API
- [JLCParts Database](https://github.com/yaqwsx/jlcparts) by [@yaqwsx](https://github.com/yaqwsx) - JLCPCB parts data
### Community Contributors
- [@Kletternaut](https://github.com/Kletternaut) - Routing/component tools, footprint/symbol creators, passthrough workflow, template fixes (PRs #44, #48, #49, #51, #53, #57, #59)
- [@Mehanik](https://github.com/Mehanik) - Schematic inspection/editing tools, component field positions (PRs #60, #66, #67)
- [@jflaflamme](https://github.com/jflaflamme) - Freerouting autorouter integration with Docker/Podman support (PR #68)
- [@l3wi](https://github.com/l3wi) - Local symbol library search, JLCPCB third-party library support (PR #25)
- [@gwall-ceres](https://github.com/gwall-ceres) - MCP protocol compliance, Windows compatibility (PR #10)
- [@fariouche](https://github.com/fariouche) - Bug fixes (PR #17)
- [@shuofengzhang](https://github.com/shuofengzhang) - XDG relative path handling (PR #58)
- [@sid115](https://github.com/sid115) - Windows setup script improvements (PR #13)
- [@pasrom](https://github.com/pasrom) - MCP server bug fixes (PR #50)
## Citation
If you use this project in your research or publication, please cite:
@@ -938,12 +1173,6 @@ If you use this project in your research or publication, please cite:
author = {mixelpixx},
year = {2025},
url = {https://github.com/mixelpixx/KiCAD-MCP-Server},
version = {2.1.0-alpha}
version = {2.2.3}
}
```

324
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,324 @@
# KiCAD MCP Server Architecture
This document describes the system architecture for contributors who want to understand, modify, or extend the server.
---
## System Overview
```
AI Assistant (Claude, etc.)
|
| MCP Protocol (JSON-RPC 2.0 over STDIO)
v
TypeScript MCP Server (src/)
|
| Spawn Python subprocess, pass JSON commands
v
Python KiCAD Interface (python/)
|
| pcbnew SWIG API or KiCAD IPC API
v
KiCAD 9.0+
```
The server has two layers:
1. **TypeScript layer** -- implements the MCP protocol, registers tools with schemas, validates input, manages the Python subprocess
2. **Python layer** -- interfaces with KiCAD's pcbnew API (SWIG bindings) or IPC API for actual PCB/schematic operations
---
## Directory Structure
```
KiCAD-MCP-Server/
src/ # TypeScript MCP server
server.ts # Main server, tool registration, Python subprocess
logger.ts # Logging configuration
tools/ # Tool definitions (one file per category)
registry.ts # Tool category definitions and lookup
router.ts # Router tools (list/search/execute)
project.ts # Project management tools
board.ts # Board operations tools
component.ts # Component tools
routing.ts # Routing tools
design-rules.ts # DRC tools
export.ts # Export tools
schematic.ts # Schematic tools
library.ts # Footprint library tools
library-symbol.ts # Symbol library tools
footprint.ts # Footprint creator tools
symbol-creator.ts # Symbol creator tools
datasheet.ts # Datasheet tools
jlcpcb-api.ts # JLCPCB integration tools
freerouting.ts # Autorouter tools
ui.ts # UI management tools
resources/ # MCP resource definitions
prompts/ # MCP prompt templates
utils/ # Utility functions
python/ # Python KiCAD interface
kicad_interface.py # Main entry point, command router
commands/ # Command implementations
project.py # Project operations
board.py # Board manipulation
component.py # PCB component operations
component_schematic.py # Schematic component operations
connection_schematic.py # Schematic wiring and connections
schematic.py # Schematic file management
routing.py # Trace routing
design_rules.py # DRC operations
export.py # File export
library.py # Footprint library access
library_symbol.py # Symbol library access
footprint.py # Custom footprint creation
symbol_creator.py # Custom symbol creation
datasheet_manager.py # Datasheet enrichment
jlcpcb.py # JLCPCB API client
jlcsearch.py # JLCSearch public API client
jlcpcb_parts.py # JLCPCB parts database
freerouting.py # Freerouting autorouter
svg_import.py # SVG to PCB polygon conversion
dynamic_symbol_loader.py # Dynamic symbol injection
wire_manager.py # S-expression wire creation
pin_locator.py # Pin position discovery
layers.py # Layer utilities
outline.py # Board outline utilities
size.py # Size/dimension utilities
view.py # Board rendering utilities
kicad_api/ # Backend abstraction
base.py # Abstract base class
factory.py # Backend auto-detection
swig_backend.py # pcbnew SWIG API backend
ipc_backend.py # KiCAD 9.0 IPC API backend
schemas/ # JSON Schema definitions
tool_schemas.py # Tool parameter schemas
resources/ # Resource handlers
templates/ # Schematic/project templates
tests/ # Python test suite
utils/ # Platform detection, helpers
docs/ # Documentation
config/ # Configuration examples
```
---
## TypeScript Layer
### Server Startup (`src/server.ts`)
1. Creates an MCP server instance
2. Registers all tools from each tool file (registerProjectTools, registerBoardTools, etc.)
3. Registers resources and prompts
4. Starts the STDIO transport for MCP communication
5. On first tool call, spawns the Python subprocess
### Tool Registration
Each tool file exports a `register*Tools(server, callKicadScript)` function that:
- Defines tool name, description, and Zod schema for parameters
- Registers a handler that calls `callKicadScript(command, args)`
Example from `src/tools/project.ts`:
```typescript
server.tool(
"create_project",
"Create a new KiCAD project",
{ name: z.string(), path: z.string() },
async (args) => {
const result = await callKicadScript("create_project", args);
return { content: [{ type: "text", text: JSON.stringify(result) }] };
},
);
```
### Tool Router (`src/tools/router.ts` and `src/tools/registry.ts`)
The router pattern reduces AI context usage:
- `registry.ts` defines tool categories and which tools are "direct" (always visible) vs "routed" (discoverable)
- `router.ts` provides 4 meta-tools: `list_tool_categories`, `get_category_tools`, `search_tools`, `execute_tool`
- Routed tools are not registered as individual MCP tools -- they are invoked through `execute_tool`
### Python Subprocess Communication
`callKicadScript(command, args)` in `server.ts`:
1. Spawns `python3 python/kicad_interface.py` (if not already running)
2. Sends a JSON message: `{"command": "...", "params": {...}}`
3. Reads the JSON response
4. Returns the result to the MCP tool handler
---
## Python Layer
### Main Entry Point (`python/kicad_interface.py`)
- Reads JSON commands from stdin
- Routes commands to the appropriate handler
- Manages the pcbnew board object lifecycle
- Handles backend selection (SWIG vs IPC)
- Auto-saves after board-modifying operations
### Command Routing
Commands are routed by name to handler methods. The mapping is defined in `kicad_interface.py`. Each handler:
1. Receives a params dict
2. Calls the appropriate command class method
3. Returns a result dict with `success`, `message`, and any additional data
### Backend System (`python/kicad_api/`)
Two backends for interacting with KiCAD:
**SWIG Backend** (default):
- Direct Python bindings to KiCAD's C++ API via SWIG
- Operates on files -- loads .kicad_pcb, modifies in memory, saves back
- Works without KiCAD running
- Requires manual UI reload to see changes
**IPC Backend** (experimental):
- Communicates with running KiCAD via IPC API socket
- Changes appear in the UI immediately
- Requires KiCAD 9.0+ running with IPC enabled
- Falls back to SWIG when unavailable
`factory.py` auto-detects which backend to use.
### Schematic System
Schematic manipulation uses a different stack than PCB operations:
- **kicad-skip** library for reading/modifying schematic files
- **S-expression parsing** for direct file manipulation (wires, symbols)
- **DynamicSymbolLoader** for injecting any KiCad symbol into a schematic
- **WireManager** for creating wires via S-expression injection
- **PinLocator** for discovering pin positions with rotation support
---
## Adding a New Tool
### Step 1: Define the TypeScript Schema
Create or edit a file in `src/tools/`. Register the tool with `server.tool()`:
```typescript
server.tool(
"my_new_tool",
"Description of what the tool does",
{
param1: z.string().describe("Description of param1"),
param2: z.number().optional().describe("Optional param2"),
},
async (args) => {
const result = await callKicadScript("my_new_tool", args);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
},
);
```
### Step 2: Add to Registry (if routed)
If the tool should be discoverable via the router (not always visible), add it to a category in `src/tools/registry.ts`:
```typescript
{
name: "category_name",
tools: ["existing_tool", "my_new_tool"]
}
```
If the tool should always be visible, add it to `directToolNames` instead.
### Step 3: Import in server.ts
Import and call the registration function in `src/server.ts`:
```typescript
import { registerMyTools } from "./tools/my-tools.js";
registerMyTools(server, callKicadScript);
```
### Step 4: Implement the Python Handler
Add a handler in `python/kicad_interface.py` or create a new command module in `python/commands/`:
```python
def handle_my_new_tool(self, params):
# Implementation using pcbnew API
return {"success": True, "message": "Done", "data": result}
```
Route the command in the main handler:
```python
elif command == "my_new_tool":
return self.handle_my_new_tool(params)
```
### Step 5: Build and Test
```bash
npm run build # Compile TypeScript
npm run test:py # Run Python tests
```
---
## Testing
### Python Tests
Located in `python/tests/`. Run with:
```bash
pytest python/tests/ -v
```
Key test files:
- `test_schematic_tools.py` -- schematic tool tests
- `test_freerouting.py` -- autorouter tests
- `test_delete_schematic_component.py` -- component deletion tests
- `test_schematic_component_fields.py` -- field inspection tests
- `test_platform_helper.py` -- platform detection tests
### Manual Testing
1. Build the server: `npm run build`
2. Configure in Claude Desktop or Claude Code
3. Test tools interactively through your MCP client
---
## Key Design Decisions
- **TypeScript + Python split**: TypeScript handles MCP protocol (well-supported SDK), Python handles KiCAD (only available API)
- **Router pattern**: Reduces AI context from ~80K tokens (122 tools) to manageable size
- **Auto-save**: Every board-modifying SWIG operation auto-saves to prevent data loss
- **Dynamic symbol loading**: Works around kicad-skip's inability to create symbols from scratch
- **S-expression wire injection**: Works around kicad-skip's inability to create wires
---
## Source Files Reference
| File | Purpose |
| ------------------------------------------ | ----------------------------------- |
| `src/server.ts` | MCP server, subprocess management |
| `src/tools/registry.ts` | Tool categories and organization |
| `src/tools/router.ts` | Router meta-tools |
| `python/kicad_interface.py` | Python entry point, command routing |
| `python/kicad_api/factory.py` | Backend selection |
| `python/commands/dynamic_symbol_loader.py` | Symbol injection system |
| `python/commands/wire_manager.py` | Wire creation engine |
| `python/commands/pin_locator.py` | Pin position discovery |

View File

@@ -7,7 +7,7 @@ This guide shows how to configure the KiCAD MCP Server with various MCP-compatib
## Quick Reference
| Client | Config File Location |
|--------|---------------------|
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Claude Desktop** | Linux: `~/.config/Claude/claude_desktop_config.json`<br>macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`<br>Windows: `%APPDATA%\Claude\claude_desktop_config.json` |
| **Cline (VSCode)** | VSCode Settings → Extensions → Cline → MCP Settings |
| **Claude Code** | `~/.config/claude-code/mcp_config.json` |
@@ -194,11 +194,7 @@ For any MCP-compatible client that supports STDIO transport:
```json
{
"command": "node",
"args": [
"/path/to/KiCAD-MCP-Server/dist/index.js",
"--config",
"/path/to/custom-config.json"
],
"args": ["/path/to/KiCAD-MCP-Server/dist/index.js", "--config", "/path/to/custom-config.json"],
"transport": "stdio"
}
```
@@ -210,16 +206,18 @@ For any MCP-compatible client that supports STDIO transport:
### Required
| Variable | Description | Example |
|----------|-------------|---------|
| ------------ | ---------------------------- | ------------------------------------------ |
| `PYTHONPATH` | Path to KiCAD Python modules | `/usr/lib/kicad/lib/python3/dist-packages` |
### Optional
| Variable | Description | Default |
|----------|-------------|---------|
| ----------------- | ------------------------------------------------- | -------------- |
| `LOG_LEVEL` | Logging verbosity | `info` |
| `NODE_ENV` | Node environment | `development` |
| `KICAD_BACKEND` | Force backend (`swig` or `ipc`) | Auto-detect |
| `KICAD_MCP_DEV` | Enable developer mode (auto-save logs to project) | `0` (disabled) |
| `FREEROUTING_JAR` | Path to FreeRouting JAR file for autorouting | Not set |
---
@@ -310,6 +308,7 @@ claude-code
**Error:** `Cannot find module 'pcbnew'`
**Solution:** Verify `PYTHONPATH` is correct:
```bash
python3 -c "import sys; sys.path.append('/usr/lib/kicad/lib/python3/dist-packages'); import pcbnew; print(pcbnew.GetBuildVersion())"
```
@@ -317,6 +316,7 @@ python3 -c "import sys; sys.path.append('/usr/lib/kicad/lib/python3/dist-package
**Error:** `ENOENT: no such file or directory`
**Solution:** Check that `dist/index.js` exists:
```bash
cd /path/to/KiCAD-MCP-Server
npm run build
@@ -328,6 +328,7 @@ ls -lh dist/index.js
**Issue:** Claude Desktop doesn't show KiCAD tools
**Solutions:**
1. Restart Claude Desktop completely (quit, not just close window)
2. Check config file syntax with `jq`:
```bash
@@ -343,6 +344,7 @@ ls -lh dist/index.js
**Error:** `ModuleNotFoundError: No module named 'kicad_api'`
**Solution:** Server is looking for the wrong Python modules. This is an internal error. Check:
```bash
# Verify PYTHONPATH in server config includes both KiCAD and our modules
"PYTHONPATH": "/usr/lib/kicad/lib/python3/dist-packages:/path/to/KiCAD-MCP-Server/python"
@@ -396,17 +398,14 @@ Then use it:
```json
{
"command": "node",
"args": [
"/path/to/dist/index.js",
"--config",
"/path/to/config/production.json"
]
"args": ["/path/to/dist/index.js", "--config", "/path/to/config/production.json"]
}
```
### Development vs Production
Development (verbose logging):
```json
{
"env": {
@@ -417,6 +416,7 @@ Development (verbose logging):
```
Production (minimal logging):
```json
{
"env": {
@@ -471,10 +471,7 @@ Running server in WSL2, client on Windows:
"mcpServers": {
"kicad": {
"command": "wsl",
"args": [
"node",
"/home/user/KiCAD-MCP-Server/dist/index.js"
],
"args": ["node", "/home/user/KiCAD-MCP-Server/dist/index.js"],
"env": {
"PYTHONPATH": "/usr/lib/kicad/lib/python3/dist-packages"
}
@@ -537,5 +534,5 @@ For bugs or feature requests, open an issue on GitHub.
---
**Last Updated:** October 25, 2025
**Version:** 2.0.0-alpha.1
**Last Updated:** March 21, 2026
**Version:** 2.2.3+

View File

@@ -0,0 +1,108 @@
# Datasheet Management Tools
**Added in:** v2.2.0-alpha
Two tools for managing component datasheets using LCSC part numbers. Datasheet URLs are constructed directly from LCSC numbers -- no API key or network requests required.
---
## Tools Reference
### `enrich_datasheets`
Scans a KiCAD schematic and fills in missing Datasheet URLs for components that have an LCSC part number set.
**How it works:**
For every placed symbol that has:
- An LCSC property set (e.g., `(property "LCSC" "C123456")`)
- An empty or missing Datasheet field
The tool sets the Datasheet field to: `https://www.lcsc.com/datasheet/C123456.pdf`
The URL is then visible in KiCAD's footprint browser, symbol properties dialog, and any tool that reads the standard Datasheet field.
**Parameters:**
| Parameter | Type | Required | Default | Description |
| ---------------- | ------- | -------- | ------- | --------------------------------------- |
| `schematic_path` | string | Yes | -- | Path to the .kicad_sch file to enrich |
| `dry_run` | boolean | No | false | Preview changes without writing to disk |
**Returns:**
- Number of components updated
- Number already set (skipped)
- Number without LCSC number
- Details of each updated component (reference, LCSC number, URL)
**Example:**
```
Enrich datasheets for all components in ~/Projects/MyBoard/MyBoard.kicad_sch
```
Use `dry_run=true` to preview what would change:
```
Preview datasheet enrichment for ~/Projects/MyBoard/MyBoard.kicad_sch with dry run enabled.
```
---
### `get_datasheet_url`
Get the LCSC datasheet URL for a single component by LCSC number.
**Parameters:**
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | -------------------------------------------------------------------------- |
| `lcsc` | string | Yes | LCSC part number, with or without "C" prefix (e.g., "C179739" or "179739") |
**Returns:**
- Datasheet PDF URL
- Product page URL
**Example:**
```
Get the datasheet URL for LCSC part C179739.
```
---
## Workflow
### Adding Datasheets to a Design
1. **Add components with LCSC numbers** -- When placing components from JLCPCB libraries or manually setting the LCSC property, each component gets an LCSC part number
2. **Run enrich_datasheets** -- Scans all components and fills in any missing Datasheet URLs
3. **Verify in KiCAD** -- Open the schematic in KiCAD and check that Datasheet fields are populated. Double-clicking a component shows the URL in its properties
### Integration with JLCPCB Workflow
These tools complement the JLCPCB integration:
1. Use `search_jlcpcb_parts` to find components
2. Place components with LCSC numbers from the search results
3. Run `enrich_datasheets` to auto-populate datasheet URLs
4. Use `export_bom` to generate a BOM with datasheet links
---
## Notes
- The datasheet URL format (`https://www.lcsc.com/datasheet/<LCSC#>.pdf`) works for the vast majority of LCSC parts
- No network request is made -- the URL is constructed from the part number alone
- Components without an LCSC property are skipped silently
- Components that already have a Datasheet URL set are not overwritten
---
## Source Files
- TypeScript tool definitions: `src/tools/datasheet.ts`
- Python implementation: `python/commands/datasheet_manager.py`

View File

@@ -0,0 +1,415 @@
# Creating Custom Footprints and Symbols
Added in: v2.2.1-alpha (PRs #48, #49, contributor: @Kletternaut)
When existing KiCAD libraries don't have the component you need, these 8 tools let you create custom footprints and symbols programmatically. This enables automated part creation for custom PCB designs, specialized components, or rapid prototyping workflows where manual library editing would be time-consuming.
## Part 1: Footprint Creator
Footprints define the physical copper pads, silkscreen markings, and courtyard boundaries for PCB components. The footprint creator tools generate `.kicad_mod` files inside `.pretty` library directories.
### create_footprint
Create a new KiCAD footprint (.kicad_mod) inside a .pretty library directory. Supports SMD and THT pads, courtyard, silkscreen, and fab-layer rectangles.
| Parameter | Type | Required | Description |
| --------------- | ------- | -------- | ------------------------------------------------------------------------------------------ |
| `libraryPath` | string | Yes | Path to the .pretty library directory (created if missing). E.g. C:/MyProject/MyLib.pretty |
| `name` | string | Yes | Footprint name, e.g. 'R_0603_Custom' |
| `description` | string | No | Human-readable description |
| `tags` | string | No | Space-separated tag string, e.g. 'resistor SMD 0603' |
| `pads` | array | No | List of pad objects (see Pad Schema below). Can be empty for outlines-only footprints |
| `courtyard` | object | No | Courtyard rectangle on F.CrtYd (recommended: 0.25 mm clearance around pads) |
| `silkscreen` | object | No | Silkscreen rectangle on F.SilkS |
| `fabLayer` | object | No | Fab-layer rectangle on F.Fab (shows component body) |
| `refPosition` | object | No | Position of the REF\*\* text, e.g. {x: 0, y: -1.27} (default: 0, -1.27) |
| `valuePosition` | object | No | Position of the Value text, e.g. {x: 0, y: 1.27} (default: 0, 1.27) |
| `overwrite` | boolean | No | Replace existing footprint file (default: false) |
#### Pad Schema
Each pad object in the `pads` array supports:
| Parameter | Type | Required | Description |
| ----------------- | ---------------- | -------- | -------------------------------------------------------------------------------------------- |
| `number` | string | Yes | Pad number / name, e.g. '1', '2', 'A1' |
| `type` | enum | Yes | Pad type: `smd`, `thru_hole`, or `np_thru_hole` |
| `shape` | enum | No | Pad shape: `rect`, `circle`, `oval`, or `roundrect` (default: rect for SMD, circle for THT) |
| `at` | object | Yes | Pad centre position: {x: number, y: number, angle?: number} in mm |
| `size` | object | Yes | Pad size: {w: number, h: number} in mm |
| `drill` | number or object | No | Round drill diameter (mm) or oval drill {w: number, h: number} (required for thru_hole pads) |
| `layers` | array | No | Override default layer list, e.g. ['F.Cu','F.Paste','F.Mask'] |
| `roundrect_ratio` | number | No | Corner radius ratio for roundrect shape (0.0-0.5, default 0.25) |
#### Rectangle Schema (courtyard, silkscreen, fabLayer)
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ---------------- |
| `x1` | number | Yes | Left X in mm |
| `y1` | number | Yes | Top Y in mm |
| `x2` | number | Yes | Right X in mm |
| `y2` | number | Yes | Bottom Y in mm |
| `width` | number | No | Line width in mm |
#### Pad Types
- **SMD (smd)**: Surface-mount pads for components that sit on top of the PCB. Default layers: F.Cu, F.Paste, F.Mask
- **THT (thru_hole)**: Through-hole pads for components with leads that pass through the PCB. Requires `drill` parameter. Default layers: \*.Cu, F.Mask, B.Mask
- **NPTH (np_thru_hole)**: Non-plated through-holes for mechanical mounting. Requires `drill` parameter. Default layers: \*.Mask
### edit_footprint_pad
Edit an existing pad inside a .kicad_mod footprint file. Updates size, position, drill, or shape without recreating the whole footprint.
| Parameter | Type | Required | Description |
| --------------- | ---------------- | -------- | ---------------------------------------------------------------------------- |
| `footprintPath` | string | Yes | Full path to the .kicad_mod file, e.g. C:/MyLib.pretty/R_Custom.kicad_mod |
| `padNumber` | string or number | Yes | Pad number to edit, e.g. '1' or 2 |
| `size` | object | No | New pad size: {w: number, h: number} in mm |
| `at` | object | No | New pad position: {x: number, y: number, angle?: number} in mm |
| `drill` | number or object | No | New drill size: number (round) or {w: number, h: number} (oval) for THT pads |
| `shape` | enum | No | New pad shape: `rect`, `circle`, `oval`, or `roundrect` |
**When to use:** Use this tool when you need to adjust an existing footprint's pad dimensions or positions without recreating the entire footprint. Useful for fine-tuning after initial creation or adapting existing footprints.
### register_footprint_library
Register a .pretty footprint library in KiCAD's fp-lib-table so KiCAD can find the footprints. Run this after create_footprint when KiCAD shows 'library not found in footprint library table'.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------- |
| `libraryPath` | string | Yes | Full path to the .pretty directory to register |
| `libraryName` | string | No | Nickname for the library in KiCAD (default: directory name without .pretty) |
| `description` | string | No | Optional description |
| `scope` | enum | No | `project` = writes fp-lib-table next to the .kicad_pro file (default); `global` = writes to the user's global KiCAD config |
| `projectPath` | string | No | Path to the .kicad_pro file or its directory (required for scope=project when the library is not in the project folder) |
**How fp-lib-table works:** KiCAD maintains a table mapping library nicknames to filesystem paths. Project-scope tables (fp-lib-table in the project directory) take precedence over global tables. This allows project-specific libraries without polluting the global configuration.
### list_footprint_libraries
List available .pretty footprint libraries and their contents (first 20 footprints per library). Searches KiCAD standard install paths by default.
| Parameter | Type | Required | Description |
| ------------- | ----- | -------- | --------------------------------------------------------------------------------------------- |
| `searchPaths` | array | No | Override default search paths. Each entry should be a directory that contains .pretty subdirs |
### Example: Creating a Custom SOT-23 Footprint
This example creates a simple 3-pad SMD footprint for a SOT-23 transistor package:
```javascript
// Step 1: Create the footprint
{
"libraryPath": "/home/user/myproject/CustomParts.pretty",
"name": "SOT-23_Custom",
"description": "SOT-23 3-pin SMD package, custom pitch",
"tags": "SOT-23 transistor SMD",
"pads": [
{
"number": "1",
"type": "smd",
"shape": "rect",
"at": {"x": -0.95, "y": 1.0},
"size": {"w": 0.6, "h": 0.7}
},
{
"number": "2",
"type": "smd",
"shape": "rect",
"at": {"x": 0.95, "y": 1.0},
"size": {"w": 0.6, "h": 0.7}
},
{
"number": "3",
"type": "smd",
"shape": "rect",
"at": {"x": 0, "y": -1.0},
"size": {"w": 0.6, "h": 0.7}
}
],
"courtyard": {
"x1": -1.5,
"y1": -1.5,
"x2": 1.5,
"y2": 1.5,
"width": 0.05
},
"silkscreen": {
"x1": -1.3,
"y1": -0.3,
"x2": 1.3,
"y2": 0.3,
"width": 0.12
},
"fabLayer": {
"x1": -1.25,
"y1": -0.25,
"x2": 1.25,
"y2": 0.25,
"width": 0.1
}
}
// Step 2: Register the library so KiCAD can find it
{
"libraryPath": "/home/user/myproject/CustomParts.pretty",
"scope": "project",
"projectPath": "/home/user/myproject/myproject.kicad_pro"
}
```
The footprint will be saved as `/home/user/myproject/CustomParts.pretty/SOT-23_Custom.kicad_mod` and will be available in KiCAD's footprint browser under the library name "CustomParts".
## Part 2: Symbol Creator
Symbols define the schematic representation of electronic components, including pins, graphical body shapes, and electrical properties. The symbol creator tools generate and manage `.kicad_sym` library files.
### create_symbol
Create a new schematic symbol in a .kicad_sym library file (created if missing). After creation, use register_symbol_library so KiCAD finds it.
Pin positions are where the wire connects; the symbol body is drawn between them.
**Coordinate tips:**
- Body rectangle typically spans ±2.54 to ±5.08 mm
- Pins on left side: at.x = body_left - length, angle=0 (wire goes right)
- Pins on right side: at.x = body_right + length, angle=180 (wire goes left)
- Pins on top: at.y = body_top + length, angle=270 (wire goes down)
- Pins on bottom: at.y = body_bottom - length, angle=90 (wire goes up)
- Standard pin length: 2.54 mm, standard grid: 2.54 mm
| Parameter | Type | Required | Description |
| ----------------- | ------- | -------- | ---------------------------------------------------------------------------------------- |
| `libraryPath` | string | Yes | Path to the .kicad_sym file (created if missing) |
| `name` | string | Yes | Symbol name, e.g. 'TMC2209', 'MyOpAmp' |
| `referencePrefix` | string | No | Schematic reference prefix: 'U' (IC), 'R' (resistor), 'J' (connector), etc. Default: 'U' |
| `description` | string | No | Human-readable description |
| `keywords` | string | No | Space-separated search keywords |
| `datasheet` | string | No | Datasheet URL or '~' |
| `footprint` | string | No | Default footprint, e.g. 'Package_SO:SOIC-8_3.9x4.9mm_P1.27mm' |
| `inBom` | boolean | No | Include in BOM (default true) |
| `onBoard` | boolean | No | Include in netlist for PCB (default true) |
| `pins` | array | No | List of pin objects (see Pin Schema below). Can be empty for graphical-only symbols |
| `rectangles` | array | No | Body rectangle(s). Typically one rectangle defining the IC body |
| `polylines` | array | No | Polyline graphics for custom body shapes (op-amp triangles, etc.) |
| `overwrite` | boolean | No | Replace existing symbol with same name (default false) |
#### Pin Schema
Each pin object in the `pins` array supports:
| Parameter | Type | Required | Description |
| --------- | ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `name` | string | Yes | Pin name, e.g. 'VCC', 'GND', 'IN+', '~' for unnamed |
| `number` | string or number | Yes | Pin number, e.g. '1', '2', 'A1' |
| `type` | enum | Yes | Electrical pin type (see Pin Types below) |
| `at` | object | Yes | Pin endpoint position: {x: number, y: number, angle: number} where angle is the direction the pin wire extends FROM the symbol body |
| `length` | number | No | Pin length in mm (default 2.54) |
| `shape` | enum | No | Pin graphic shape (default: line) |
**Pin angle conventions:**
- 0 = right (wire extends to the right from the symbol body)
- 90 = up (wire extends upward)
- 180 = left (wire extends to the left)
- 270 = down (wire extends downward)
#### Pin Types (Electrical)
| Type | Description |
| ---------------- | ----------------------------------------- |
| `input` | Input pin |
| `output` | Output pin |
| `bidirectional` | Bidirectional I/O |
| `tri_state` | Tri-state output |
| `passive` | Passive component (resistors, capacitors) |
| `free` | Free pin (no electrical rule checking) |
| `unspecified` | Unspecified type |
| `power_in` | Power input (VCC, VDD) |
| `power_out` | Power output (regulators) |
| `open_collector` | Open collector output |
| `open_emitter` | Open emitter output |
| `no_connect` | Not connected |
#### Pin Shapes (Graphical)
| Shape | Description |
| -------------------- | -------------------------- |
| `line` | Standard pin (default) |
| `inverted` | Pin with inversion bubble |
| `clock` | Clock input (triangle) |
| `inverted_clock` | Inverted clock with bubble |
| `input_low` | Active-low input |
| `clock_low` | Active-low clock |
| `output_low` | Active-low output |
| `falling_edge_clock` | Falling edge triggered |
| `non_logic` | Non-logic pin |
#### Rectangle Schema
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ------------------------------------------------------------------- |
| `x1` | number | Yes | Left X in mm |
| `y1` | number | Yes | Top Y in mm |
| `x2` | number | Yes | Right X in mm |
| `y2` | number | Yes | Bottom Y in mm |
| `width` | number | No | Stroke width in mm (default 0.254) |
| `fill` | enum | No | Fill type: `none`, `outline`, or `background` (default: background) |
#### Polyline Schema
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ------------------------------------------------------ |
| `points` | array | Yes | List of XY points: [{x: number, y: number}, ...] in mm |
| `width` | number | No | Stroke width in mm (default 0.254) |
| `fill` | enum | No | Fill type: `none`, `outline`, or `background` |
### delete_symbol
Remove a symbol from a .kicad_sym library file.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | --------------------------- |
| `libraryPath` | string | Yes | Path to the .kicad_sym file |
| `name` | string | Yes | Symbol name to delete |
### list_symbols_in_library
List all symbol names in a .kicad_sym library file.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | --------------------------- |
| `libraryPath` | string | Yes | Path to the .kicad_sym file |
### register_symbol_library
Register a .kicad_sym library in KiCAD's sym-lib-table so symbols can be used in schematics. Run this after create_symbol when KiCAD shows 'library not found'.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | ------------------------------------------------------------------------------------- |
| `libraryPath` | string | Yes | Full path to the .kicad_sym file |
| `libraryName` | string | No | Nickname (default: file name without extension) |
| `description` | string | No | Optional description |
| `scope` | enum | No | `project` = writes sym-lib-table next to .kicad_pro (default); `global` = user config |
| `projectPath` | string | No | Path to .kicad_pro or its directory (for scope=project) |
### Example: Creating a Simple IC Symbol
This example creates a 4-pin IC symbol (VCC, GND, IN, OUT):
```javascript
// Step 1: Create the symbol
{
"libraryPath": "/home/user/myproject/CustomSymbols.kicad_sym",
"name": "MyRegulator",
"referencePrefix": "U",
"description": "Simple voltage regulator",
"keywords": "regulator power",
"datasheet": "~",
"footprint": "Package_TO_SOT_SMD:SOT-23",
"pins": [
{
"name": "VIN",
"number": "1",
"type": "power_in",
"at": {"x": -7.62, "y": 2.54, "angle": 0},
"length": 2.54
},
{
"name": "GND",
"number": "2",
"type": "power_in",
"at": {"x": 0, "y": -7.62, "angle": 90},
"length": 2.54
},
{
"name": "VOUT",
"number": "3",
"type": "power_out",
"at": {"x": 7.62, "y": 2.54, "angle": 180},
"length": 2.54
}
],
"rectangles": [
{
"x1": -5.08,
"y1": -5.08,
"x2": 5.08,
"y2": 5.08,
"width": 0.254,
"fill": "background"
}
]
}
// Step 2: Register the library
{
"libraryPath": "/home/user/myproject/CustomSymbols.kicad_sym",
"scope": "project",
"projectPath": "/home/user/myproject/myproject.kicad_pro"
}
```
**Pin positioning explained:**
- VIN pin at (-7.62, 2.54, angle=0): Wire extends to the right, so the symbol body should be to the right. Body left edge is at -5.08, and pin length is 2.54, so -7.62 = -5.08 - 2.54
- GND pin at (0, -7.62, angle=90): Wire extends upward, body bottom is at -5.08, so -7.62 = -5.08 - 2.54
- VOUT pin at (7.62, 2.54, angle=180): Wire extends to the left, body right is at 5.08, so 7.62 = 5.08 + 2.54
## Coordinate Systems
### Footprint Coordinates
- Origin (0, 0) is typically at the component center or pin 1
- Positive X extends right, positive Y extends down (PCB view from top)
- All dimensions in millimeters
- Courtyard should extend 0.25mm beyond pads for IPC-7351 compliance
- Silkscreen should not overlap pads (typically 0.1-0.2mm clearance)
### Symbol Coordinates
- Origin (0, 0) is typically at the symbol center
- Positive X extends right, positive Y extends up (schematic convention)
- All dimensions in millimeters
- Standard grid is 2.54mm (100 mil) for pin spacing
- Pin positions define where wires connect, not where the pin graphic starts
- Body graphics are drawn independently of pin positions
### Key Difference
Footprints use a "Y-down" coordinate system (like screen coordinates), while symbols use a "Y-up" coordinate system (like mathematical graphs). This is a KiCAD convention that matches industry standards for PCB layout vs schematic capture.
## Integration with Design Workflow
### Typical Workflow
1. **Create the symbol** using `create_symbol` with pin definitions and body graphics
2. **Register the symbol library** using `register_symbol_library` so it appears in the schematic editor
3. **Create the footprint** using `create_footprint` with pad definitions and courtyard
4. **Register the footprint library** using `register_footprint_library` so it appears in the PCB editor
5. **Link symbol to footprint** by setting the `footprint` parameter in `create_symbol`, or assign it later in the schematic editor
### Library Organization
- **Project-scope libraries**: Store in the project directory, register with `scope: "project"`. Best for project-specific custom parts.
- **Global libraries**: Store in a central location, register with `scope: "global"`. Best for reusable parts across multiple projects.
- **Naming conventions**: Use descriptive names. For footprints: `PackageType_Variant`, e.g. `SOIC-8_Custom`. For symbols: `PartNumber` or `FunctionDescription`.
### Validation
After creating custom parts:
- Open KiCAD schematic editor and verify the symbol appears in the "Add Symbol" dialog
- Check pin numbers, names, and electrical types in symbol properties
- Open KiCAD PCB editor and verify the footprint appears in the footprint browser
- Use the 3D viewer to check pad positions and courtyard clearances
- Run Design Rules Check (DRC) to ensure courtyard and clearance compliance
## Source Files
- TypeScript tool definitions: `/home/chris/MCP/KiCAD-MCP-Server/src/tools/footprint.ts`
- TypeScript symbol definitions: `/home/chris/MCP/KiCAD-MCP-Server/src/tools/symbol-creator.ts`
- Python footprint implementation: `/home/chris/MCP/KiCAD-MCP-Server/python/commands/footprint.py`
- Python symbol implementation: `/home/chris/MCP/KiCAD-MCP-Server/python/commands/symbol_creator.py`

234
docs/FREEROUTING_GUIDE.md Normal file
View File

@@ -0,0 +1,234 @@
# Freerouting Integration Guide
**Added in:** v2.2.3 (PR #68, contributor: @jflaflamme)
Freerouting is an open-source autorouter that can automatically route PCB traces. This integration lets you run Freerouting directly from MCP tools without leaving your AI-assisted design workflow.
---
## How It Works
The autorouter uses the Specctra DSN/SES interchange format:
1. Export the current PCB to Specctra DSN format
2. Run Freerouting CLI on the DSN file
3. Import the routed SES result back into the PCB
4. Save the board
The `autoroute` tool performs all four steps in a single call.
---
## Prerequisites
### Freerouting JAR
Download the Freerouting executable JAR:
```bash
mkdir -p ~/.kicad-mcp
curl -L -o ~/.kicad-mcp/freerouting.jar \
https://github.com/freerouting/freerouting/releases/download/v2.0.1/freerouting-2.0.1-executable.jar
```
The default location is `~/.kicad-mcp/freerouting.jar`. You can override this with:
- The `freeroutingJar` parameter on any tool call
- The `FREEROUTING_JAR` environment variable
### Java Runtime (Option A -- Direct Execution)
Freerouting 2.x requires Java 21 or higher.
```bash
# Ubuntu/Debian
sudo apt install openjdk-21-jre
# Verify
java -version
```
### Docker or Podman (Option B -- No Java Install Needed)
If you do not have Java 21+ installed, the integration automatically falls back to Docker or Podman using the `eclipse-temurin:21-jre` image.
```bash
# Pull the image (one-time)
docker pull eclipse-temurin:21-jre
# Or with Podman
podman pull eclipse-temurin:21-jre
```
### Automatic Runtime Detection
The autorouter checks for runtimes in this order:
1. Local Java 21+ (direct execution, fastest)
2. Docker (container execution)
3. Podman (container execution)
If none are available, an error is returned with installation instructions.
---
## Tools Reference
### `check_freerouting`
Verify that prerequisites are installed before running the autorouter.
**Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `freeroutingJar` | string | No | Path to freerouting.jar to check |
**Returns:** Java availability, version, Docker status, JAR location
**Example:**
```
Check if Freerouting is ready on my system.
```
### `autoroute`
Run the full autorouting workflow (export DSN, route, import SES).
**Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `boardPath` | string | No | Current board | Path to .kicad_pcb file |
| `freeroutingJar` | string | No | ~/.kicad-mcp/freerouting.jar | Path to freerouting.jar |
| `maxPasses` | number | No | 20 | Maximum routing passes |
| `timeout` | number | No | 300 | Timeout in seconds |
**Example:**
```
Autoroute the current board using Freerouting with a 5-minute timeout.
```
### `export_dsn`
Export the PCB to Specctra DSN format for manual routing workflows.
**Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `boardPath` | string | No | Path to .kicad_pcb file (default: current board) |
| `outputPath` | string | No | Output DSN file path (default: same directory as board) |
### `import_ses`
Import a routed Specctra SES file back into the PCB.
**Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sesPath` | string | Yes | Path to the .ses file to import |
| `boardPath` | string | No | Path to .kicad_pcb file (default: current board) |
---
## Workflows
### Automated (Recommended)
A single tool call handles everything:
```
1. Open the project
2. Check Freerouting dependencies
3. Run autoroute with max 10 passes
4. Run DRC to verify the result
5. Export Gerbers
```
### Manual DSN/SES Workflow
For advanced users or external autorouters:
```
1. Export the board to Specctra DSN format
2. (Run Freerouting GUI or another autorouter externally)
3. Import the routed SES file
```
This is useful when you want to:
- Use the Freerouting GUI for interactive routing
- Use a different autorouter that supports DSN/SES
- Route the board on a different machine
---
## Configuration
### Environment Variable
Set `FREEROUTING_JAR` in your MCP client configuration to avoid specifying the path on every call:
```json
{
"mcpServers": {
"kicad": {
"command": "node",
"args": ["/path/to/KiCAD-MCP-Server/dist/index.js"],
"env": {
"FREEROUTING_JAR": "/path/to/freerouting.jar"
}
}
}
}
```
---
## Troubleshooting
### "Neither Java 21+ nor Docker found"
Install either Java 21+ or Docker/Podman. See the Prerequisites section above.
### "Java found but version < 21"
Freerouting 2.x requires Java 21+. Either:
- Upgrade your Java installation
- Install Docker as a fallback
### Timeout Errors
For complex boards, increase the timeout:
```
Autoroute with timeout 600 and max passes 30.
```
### Routing Quality
If the autorouter does not route all connections:
- Increase `maxPasses` (default: 20)
- Check that your design rules allow the autorouter enough clearance
- Run DRC after autorouting to identify any violations
- Consider routing critical traces manually first, then autorouting the rest
### Docker Permission Errors
If Docker reports permission errors:
```bash
# Add your user to the docker group
sudo usermod -aG docker $USER
# Log out and back in for the change to take effect
```
---
## Source Files
- TypeScript tool definitions: `src/tools/freerouting.ts`
- Python implementation: `python/commands/freerouting.py`
- Tests: `python/tests/test_freerouting.py`

89
docs/INDEX.md Normal file
View File

@@ -0,0 +1,89 @@
# Documentation Index
KiCAD MCP Server -- AI-assisted PCB design via Model Context Protocol
**Version:** 2.2.3 | **Tools:** 122 | **Last Updated:** 2026-03-21
---
## Getting Started
| Document | Description |
| ----------------------------------------------- | -------------------------------------------------------------- |
| [README](../README.md) | Project overview, installation, configuration, quick start |
| [Client Configuration](CLIENT_CONFIGURATION.md) | MCP client setup (Claude Desktop, Cline, Claude Code) |
| [Platform Guide](PLATFORM_GUIDE.md) | Linux vs Windows vs macOS differences |
| [PCB Design Workflow](PCB_DESIGN_WORKFLOW.md) | End-to-end design guide from project creation to manufacturing |
---
## Tool References
| Document | Description |
| ----------------------------------------------------------------------- | ----------------------------------------------------------- |
| [Tool Inventory](TOOL_INVENTORY.md) | Complete list of all 122 tools with access types |
| [Schematic Tools Reference](SCHEMATIC_TOOLS_REFERENCE.md) | 27 schematic tools -- components, wiring, analysis, export |
| [Routing Tools Reference](ROUTING_TOOLS_REFERENCE.md) | 13 routing tools -- traces, vias, differential pairs, zones |
| [Footprint and Symbol Creator Guide](FOOTPRINT_SYMBOL_CREATOR_GUIDE.md) | 8 tools for creating custom footprints and symbols |
| [Freerouting Guide](FREEROUTING_GUIDE.md) | 4 autorouter tools -- setup, usage, Docker support |
| [SVG Import Guide](SVG_IMPORT_GUIDE.md) | Import SVG logos onto PCB layers |
| [Datasheet Tools Guide](DATASHEET_TOOLS_GUIDE.md) | Datasheet enrichment via LCSC |
---
## Integration Guides
| Document | Description |
| --------------------------------------------- | -------------------------------------------------- |
| [JLCPCB Integration](JLCPCB_INTEGRATION.md) | JLCPCB parts catalog, pricing, component selection |
| [JLCPCB Usage Guide](JLCPCB_USAGE_GUIDE.md) | Detailed JLCPCB setup and usage |
| [Library Integration](LIBRARY_INTEGRATION.md) | Footprint and symbol library setup |
| [IPC Backend Status](IPC_BACKEND_STATUS.md) | Real-time KiCAD UI synchronization (experimental) |
---
## Workflows
| Document | Description |
| --------------------------------------------- | ----------------------------------------- |
| [Realtime Workflow](REALTIME_WORKFLOW.md) | Working with IPC backend for live updates |
| [Visual Feedback](VISUAL_FEEDBACK.md) | UI visual feedback guide |
| [UI Auto Launch](UI_AUTO_LAUNCH.md) | Automatic KiCAD UI launch feature |
| [Router Guide](mcp-router-guide.md) | Tool router pattern usage |
| [Router Architecture](ROUTER_ARCHITECTURE.md) | Router pattern design |
| [Router Quick Start](ROUTER_QUICK_START.md) | Quick start for the router pattern |
---
## Troubleshooting
| Document | Description |
| --------------------------------------------------------- | ------------------------------ |
| [Known Issues](KNOWN_ISSUES.md) | Current issues and workarounds |
| [Windows Troubleshooting](WINDOWS_TROUBLESHOOTING.md) | Windows-specific problems |
| [Linux Compatibility Audit](LINUX_COMPATIBILITY_AUDIT.md) | Linux platform details |
---
## Project Information
| Document | Description |
| ----------------------------------- | ----------------------------------------- |
| [Status Summary](STATUS_SUMMARY.md) | Current project status and feature matrix |
| [Roadmap](ROADMAP.md) | Development roadmap and planned features |
| [Changelog](../CHANGELOG.md) | Detailed release notes for all versions |
---
## For Contributors
| Document | Description |
| ---------------------------------- | ---------------------------------------- |
| [Contributing](../CONTRIBUTING.md) | How to contribute to the project |
| [Architecture](ARCHITECTURE.md) | System architecture and adding new tools |
---
## Archive
Historical planning documents are preserved in [docs/archive/](archive/README.md).

View File

@@ -1,9 +1,9 @@
# KiCAD IPC Backend Implementation Status
**Status:** Under Active Development and Testing
**Date:** 2025-12-02
**KiCAD Version:** 9.0.6
**kicad-python Version:** 0.5.0
**Date:** 2026-03-21
**KiCAD Version:** 9.0+
**kicad-python Version:** 0.5.0+
---
@@ -16,7 +16,7 @@ This feature is experimental and under active testing. The server uses a hybrid
## Key Differences
| Feature | SWIG | IPC |
|---------|------|-----|
| -------------- | ---------------------- | ------------------------ |
| UI Updates | Manual reload required | Immediate (when working) |
| Undo/Redo | Not supported | Transaction support |
| API Stability | Deprecated in KiCAD 9 | Official, versioned |
@@ -28,7 +28,7 @@ This feature is experimental and under active testing. The server uses a hybrid
The following MCP commands have IPC handlers:
| Command | IPC Handler | Status |
|---------|-------------|--------|
| -------------------------- | ------------------------------- | -------------------- |
| `route_trace` | `_ipc_route_trace` | Implemented |
| `add_via` | `_ipc_add_via` | Implemented |
| `add_net` | `_ipc_add_net` | Implemented |
@@ -54,6 +54,7 @@ The following MCP commands have IPC handlers:
### Implemented Backend Features
**Core Connection:**
- Connect to running KiCAD instance
- Auto-detect socket path (`/tmp/kicad/api.sock`)
- Version checking and validation
@@ -61,6 +62,7 @@ The following MCP commands have IPC handlers:
- Change notification callbacks
**Board Operations:**
- Get board reference
- Get/Set board size
- List enabled layers
@@ -69,6 +71,7 @@ The following MCP commands have IPC handlers:
- Add mounting holes
**Component Operations:**
- List all components
- Place component (hybrid: SWIG for library loading, IPC for placement)
- Move component
@@ -77,6 +80,7 @@ The following MCP commands have IPC handlers:
- Get component properties
**Routing Operations:**
- Add track
- Add via
- Get all tracks
@@ -84,16 +88,19 @@ The following MCP commands have IPC handlers:
- Get all nets
**Zone Operations:**
- Add copper pour zones
- Get zones list
- Refill zones
**UI Integration:**
- Add text to board
- Get current selection
- Clear selection
**Transaction Support:**
- Begin transaction
- Commit transaction (with description for undo)
- Rollback transaction
@@ -162,20 +169,24 @@ Run the test script to verify IPC functionality:
## Troubleshooting
### "Connection failed"
- Ensure KiCAD is running
- Enable IPC API: `Preferences > Plugins > Enable IPC API Server`
- Check if a board is open
### "kicad-python not found"
```bash
pip install kicad-python
```
### "Version mismatch"
- Update kicad-python: `pip install --upgrade kicad-python`
- Ensure KiCAD 9.0+ is installed
### "No board open"
- Open a board in KiCAD's PCB editor before connecting
## File Structure
@@ -209,4 +220,4 @@ python/
---
**Last Updated:** 2025-12-02
**Last Updated:** 2026-03-21

View File

@@ -98,14 +98,17 @@ footprints = db.map_package_to_footprint("0603")
### JLCSearchClient
#### `search_resistors(resistance, package, limit)`
Search for resistors by value and package.
**Parameters:**
- `resistance` (int, optional): Resistance in ohms
- `package` (str, optional): Package size ("0402", "0603", "0805", etc.)
- `limit` (int): Maximum results (default: 100)
**Returns:** List of resistor dicts with fields:
- `lcsc`: LCSC number (integer)
- `mfr`: Manufacturer part number
- `package`: Package size
@@ -117,9 +120,11 @@ Search for resistors by value and package.
- `price1`: Unit price in USD
#### `search_capacitors(capacitance, package, limit)`
Search for capacitors by value and package.
**Parameters:**
- `capacitance` (float, optional): Capacitance in farads (e.g., 1e-7 for 100nF)
- `package` (str, optional): Package size
- `limit` (int): Maximum results
@@ -127,9 +132,11 @@ Search for capacitors by value and package.
**Returns:** List of capacitor dicts
#### `search_components(category, limit, offset, **filters)`
General component search.
**Parameters:**
- `category` (str): "resistors", "capacitors", "components", etc.
- `limit` (int): Maximum results
- `offset` (int): Pagination offset
@@ -138,9 +145,11 @@ General component search.
**Returns:** List of component dicts
#### `download_all_components(callback, batch_size)`
Download entire JLCPCB parts catalog.
**Parameters:**
- `callback` (callable, optional): Progress callback(parts_count, status_msg)
- `batch_size` (int): Parts per batch (default: 1000)
@@ -151,16 +160,20 @@ Download entire JLCPCB parts catalog.
### JLCPCBPartsManager
#### `import_jlcsearch_parts(parts, progress_callback)`
Import parts from JLCSearch into local SQLite database.
**Parameters:**
- `parts` (list): List of part dicts from JLCSearchClient
- `progress_callback` (callable, optional): Progress updates
#### `search_parts(query, category, package, library_type, manufacturer, in_stock, limit)`
Search local database with filters.
**Parameters:**
- `query` (str, optional): Free-text search
- `category` (str, optional): Category filter
- `package` (str, optional): Package filter
@@ -172,17 +185,21 @@ Search local database with filters.
**Returns:** List of matching parts
#### `get_part_info(lcsc_number)`
Get detailed part information.
**Parameters:**
- `lcsc_number` (str): LCSC part number (e.g., "C25804")
**Returns:** Part dict or None
#### `get_database_stats()`
Get database statistics.
**Returns:** Dict with:
- `total_parts`: Total parts count
- `basic_parts`: Basic library count
- `extended_parts`: Extended library count
@@ -190,9 +207,11 @@ Get database statistics.
- `db_path`: Database file path
#### `map_package_to_footprint(package)`
Map JLCPCB package to KiCad footprints.
**Parameters:**
- `package` (str): JLCPCB package name
**Returns:** List of KiCad footprint library references
@@ -239,7 +258,7 @@ CREATE TABLE components (
## Package to Footprint Mappings
| JLCPCB Package | KiCad Footprints |
|----------------|------------------|
| -------------- | ------------------------------------------------------------------------------------------------ |
| 0402 | Resistor_SMD:R_0402_1005Metric<br>Capacitor_SMD:C_0402_1005Metric<br>LED_SMD:LED_0402_1005Metric |
| 0603 | Resistor_SMD:R_0603_1608Metric<br>Capacitor_SMD:C_0603_1608Metric<br>LED_SMD:LED_0603_1608Metric |
| 0805 | Resistor_SMD:R_0805_2012Metric<br>Capacitor_SMD:C_0805_2012Metric |
@@ -254,6 +273,7 @@ CREATE TABLE components (
## Best Practices
### 1. Always Use Basic Library Parts First
Basic library parts have **no assembly fee** ($0/part), while Extended parts cost **$3/part**.
```python
@@ -262,6 +282,7 @@ basic_parts = [p for p in results if p['is_basic']]
```
### 2. Check Stock Availability
Ensure sufficient stock before committing to a design.
```python
@@ -270,6 +291,7 @@ high_stock = [p for p in results if p['stock'] > 1000]
```
### 3. Compare Prices
Even within Basic library, prices vary significantly.
```python
@@ -278,9 +300,11 @@ cheapest = min(results, key=lambda x: x.get('price1', 999))
```
### 4. Use Standardized Packages
Stick to common packages (0402, 0603, 0805) for better availability and pricing.
### 5. Cache Database Locally
Download the full parts database once and search locally for faster results.
```python
@@ -296,23 +320,29 @@ results = db.search_parts(...)
## Troubleshooting
### API Rate Limiting
JLCSearch is a community service. If you hit rate limits:
- Add delays between requests (`time.sleep(0.1)`)
- Use the local database instead of repeated API calls
- Download the full database once and work offline
### Missing Data
JLCSearch may not have all fields that official JLCPCB API provides:
- No datasheets (use manufacturer website)
- Limited category information
- No solder joint count
### Stock Discrepancies
Stock levels are updated periodically but may lag real-time JLCPCB data by a few hours.
## Official JLCPCB API (Alternative)
The project also includes an implementation of the official JLCPCB API with HMAC-SHA256 authentication. However, this requires:
1. API approval from JLCPCB (not all applications are approved)
2. APP_ID, ACCESS_KEY, and SECRET_KEY credentials
3. Previous order history with JLCPCB

View File

@@ -1,5 +1,7 @@
# JLCPCB Integration Guide
> **Note:** This document provides usage examples and workflow guidance. For complete API reference and setup instructions, see [JLCPCB_INTEGRATION.md](JLCPCB_INTEGRATION.md).
The KiCAD MCP Server provides **three complementary approaches** for working with JLCPCB parts:
1. **JLCSearch Public API** - No authentication required, 2.5M+ parts with pricing (Recommended)
@@ -18,6 +20,7 @@ All approaches can be used together to give you maximum flexibility.
## Approach 1: JLCSearch Public API (Recommended)
### What It Does
- Access to 2.5M+ JLCPCB parts with pricing and stock data
- **No authentication required** - works immediately
- **No JLCPCB account needed**
@@ -37,6 +40,7 @@ download_jlcpcb_database({ force: false })
This downloads ~2.5M parts from JLCSearch API and creates a local SQLite database (`data/jlcpcb_parts.db`).
**Expected Output:**
```
Downloading JLCPCB parts database...
Downloaded 100 parts...
@@ -57,6 +61,7 @@ See "Approach 2" usage examples below - the API is the same.
## Approach 2: Local Symbol Libraries (Good for Offline Use)
### What It Does
- Searches symbol libraries you've installed via KiCad's Plugin and Content Manager (PCM)
- Works with community JLCPCB libraries like `JLCPCB-KiCad-Library`
- No API credentials needed
@@ -79,6 +84,7 @@ See "Approach 2" usage examples below - the API is the same.
### Usage Examples
#### Search for Components
```
search_symbols({
query: "ESP32",
@@ -87,6 +93,7 @@ search_symbols({
```
Returns:
```
Found 12 symbols matching "ESP32":
@@ -96,6 +103,7 @@ PCM_JLCPCB-MCUs:ESP32-S2 | LCSC: C701342 | ESP32-S2 WiFi SoC
```
#### Search by LCSC ID
```
search_symbols({
query: "C2934196" // Direct LCSC ID search
@@ -103,6 +111,7 @@ search_symbols({
```
#### Get Symbol Details
```
get_symbol_info({
symbol: "PCM_JLCPCB-MCUs:ESP32-C3"
@@ -110,6 +119,7 @@ get_symbol_info({
```
Returns:
```
Symbol: PCM_JLCPCB-MCUs:ESP32-C3
Description: ESP32-C3 RISC-V WiFi/BLE SoC
@@ -121,6 +131,7 @@ Class: Extended
```
### Advantages
- ✅ No API credentials required
- ✅ Works offline after library installation
- ✅ Symbols pre-configured with correct footprints
@@ -128,6 +139,7 @@ Class: Extended
- ✅ Instant availability
### Limitations
- ❌ Only parts in installed libraries (typically 1k-10k parts)
- ❌ No real-time pricing or stock information
- ❌ Requires manual library updates via PCM
@@ -137,6 +149,7 @@ Class: Extended
## Approach 3: Official JLCPCB API (Advanced - Enterprise Accounts Only)
### What It Does
- Downloads from the **official JLCPCB API** (requires enterprise account)
- Provides **real-time pricing and stock information**
- Automatic **Basic vs Extended** library type identification (Basic = free assembly)
@@ -148,6 +161,7 @@ Class: Extended
#### 1. Get JLCPCB API Credentials
Visit [JLCPCB](https://jlcpcb.com/) and get your API credentials:
1. Log in to your JLCPCB account
2. Go to: **Account → API Management**
3. Click "Create API Key"
@@ -180,6 +194,7 @@ download_jlcpcb_database({ force: false })
This downloads ~100k parts from JLCPCB and creates a local SQLite database (`data/jlcpcb_parts.db`).
**Output:**
```
✓ Successfully downloaded JLCPCB parts database
@@ -203,6 +218,7 @@ search_jlcpcb_parts({
```
**Returns:**
```
Found 15 JLCPCB parts:
@@ -223,6 +239,7 @@ get_jlcpcb_part({
```
**Returns:**
```
LCSC: C58972
MFR Part: 0603WAF1002T5E
@@ -255,6 +272,7 @@ suggest_jlcpcb_alternatives({
```
**Returns:**
```
Alternative parts for C25804:
@@ -287,6 +305,7 @@ get_jlcpcb_database_stats({})
```
**Returns:**
```
JLCPCB Database Statistics:
@@ -298,6 +317,7 @@ Database path: /home/user/KiCAD-MCP-Server/data/jlcpcb_parts.db
```
### Advantages
- ✅ Complete JLCPCB catalog (100k+ parts)
- ✅ Real-time pricing and stock data
- ✅ Automatic Basic/Extended identification
@@ -306,6 +326,7 @@ Database path: /home/user/KiCAD-MCP-Server/data/jlcpcb_parts.db
- ✅ Fast parametric search
### Limitations
- ❌ Requires API credentials
- ❌ Initial download takes 5-10 minutes
- ❌ Database needs periodic updates for latest parts
@@ -318,6 +339,7 @@ Database path: /home/user/KiCAD-MCP-Server/data/jlcpcb_parts.db
### Workflow 1: Design with Known Components
**Use Local Libraries:**
```
1. search_symbols({ query: "STM32F103", library: "JLCPCB" })
2. Select component from installed library
@@ -329,6 +351,7 @@ Database path: /home/user/KiCAD-MCP-Server/data/jlcpcb_parts.db
### Workflow 2: Find Optimal Part for Cost
**Use JLCPCB API:**
```
1. search_jlcpcb_parts({
query: "10k resistor",
@@ -344,6 +367,7 @@ Database path: /home/user/KiCAD-MCP-Server/data/jlcpcb_parts.db
### Workflow 3: Explore Unknown Parts
**Start with API, verify with Libraries:**
```
1. search_jlcpcb_parts({ query: "ESP32", limit: 20 })
2. Find interesting part (e.g., C2934196)
@@ -391,6 +415,7 @@ search_jlcpcb_parts({
### 4. Calculate BOM Cost
For each part in your design:
1. Use `get_jlcpcb_part()` to get price breaks
2. Sum up total cost based on order quantity
3. Check library_type count (each unique Extended part = $3 fee)
@@ -420,6 +445,7 @@ Future versions will support incremental updates that only fetch new/changed par
### "JLCPCB API credentials not configured"
**Solution:** Set environment variables:
```bash
export JLCPCB_API_KEY="your_key"
export JLCPCB_API_SECRET="your_secret"
@@ -428,6 +454,7 @@ export JLCPCB_API_SECRET="your_secret"
### "Database not found or empty"
**Solution:** Run:
```
download_jlcpcb_database({ force: false })
```
@@ -435,6 +462,7 @@ download_jlcpcb_database({ force: false })
### "No symbols found" (Local Libraries)
**Solution:**
1. Install JLCPCB libraries via KiCad PCM
2. Verify library is enabled in KiCad symbol library table
3. Restart KiCad MCP server
@@ -442,6 +470,7 @@ download_jlcpcb_database({ force: false })
### "Authentication failed"
**Solution:**
1. Verify your API credentials are correct
2. Check JLCPCB account has API access enabled
3. Try regenerating API key/secret in JLCPCB dashboard
@@ -451,7 +480,7 @@ download_jlcpcb_database({ force: false })
## API vs Libraries: Quick Reference
| Feature | Local Libraries | JLCPCB API |
|---------|----------------|------------|
| --------------------- | ------------------ | -------------------------- |
| **Parts Count** | 1k-10k (installed) | 100k+ (complete catalog) |
| **Setup** | Install via PCM | API credentials + download |
| **Offline Use** | ✅ Yes | ✅ Yes (after download) |
@@ -503,12 +532,14 @@ search_symbols({ query: "USB-C" })
## Summary
**Use Local Libraries when:**
- Starting a new design with common components
- You want pre-configured, tested symbols
- Working offline
- Components are in installed libraries
**Use JLCPCB API when:**
- Optimizing cost (find cheapest Basic parts)
- Checking real-time stock availability
- Exploring parts outside installed libraries

View File

@@ -1,7 +1,7 @@
# Known Issues & Workarounds
**Last Updated:** 2025-12-02
**Version:** 2.1.0-alpha
**Last Updated:** 2026-03-21
**Version:** 2.2.3
This document tracks known issues and provides workarounds where available.
@@ -14,6 +14,7 @@ This document tracks known issues and provides workarounds where available.
**Status:** KNOWN - Non-critical
**Symptoms:**
```
AttributeError: 'BOARD' object has no attribute 'LT_USER'
```
@@ -31,12 +32,15 @@ AttributeError: 'BOARD' object has no attribute 'LT_USER'
**Status:** KNOWN - Workaround available
**Symptoms:**
- Copper pours created but not filled automatically when using SWIG backend
- Calling `ZONE_FILLER` via SWIG causes segfault
**Workaround Options:**
1. Use IPC backend (zones fill correctly via IPC)
2. Open the board in KiCAD UI - zones fill automatically when opened
2. Open the board in KiCAD UI -- zones fill automatically when opened
3. Use `refill_zones` tool (may still segfault in some configurations)
**Impact:** Medium - affects copper pour visualization until opened in KiCAD
@@ -44,94 +48,85 @@ AttributeError: 'BOARD' object has no attribute 'LT_USER'
### 3. UI Manual Reload Required (SWIG Backend)
**Status:** BY DESIGN - Fixed by IPC
**Status:** BY DESIGN
**Symptoms:**
- MCP makes changes via SWIG backend
- KiCAD doesn't show changes until file is reloaded
- KiCAD does not show changes until file is reloaded
**Current Workflow:**
```
1. MCP makes change via SWIG
2. KiCAD shows: "File has been modified. Reload? [Yes] [No]"
3. User clicks "Yes"
4. Changes appear in UI
```
**Why:** SWIG-based backend modifies files directly and cannot push changes to a running UI
**Why:** SWIG-based backend requires file I/O, can't push changes to running UI
**Fix:** Use IPC backend for real-time updates (requires KiCAD running with IPC enabled)
**Fix:** Use IPC backend for real-time updates (requires KiCAD to be running with IPC enabled)
**Workaround:** Click reload prompt or use File > Revert
**Workaround:** Click the reload prompt in KiCAD or use File > Revert
---
### 4. IPC Backend Experimental
### 4. IPC Backend Limitations
**Status:** UNDER DEVELOPMENT
**Status:** EXPERIMENTAL
**Description:**
The IPC backend is currently being implemented and tested. Some commands may not work as expected in all scenarios.
**Known Limitations:**
**Known IPC Limitations:**
- KiCAD must be running with IPC enabled
- KiCAD must be running with IPC enabled (Preferences > Plugins > Enable IPC API Server)
- Some commands fall back to SWIG (e.g., delete_trace)
- Footprint loading uses hybrid approach (SWIG for library, IPC for placement)
- Error handling may not be comprehensive in all cases
**Workaround:** If IPC fails, the server automatically falls back to SWIG backend
**Workaround:** The server automatically falls back to SWIG backend when IPC is unavailable
---
### 5. package.json Version Mismatch
**Status:** KNOWN - Non-critical
**Symptoms:** package.json shows version 2.1.0-alpha while CHANGELOG documents version 2.2.3
**Impact:** Cosmetic only. CHANGELOG.md is the authoritative version reference.
---
## Recently Fixed
## Recently Fixed (v2.2.0 - v2.2.3)
### Schematic Component Corruption (Fixed 2026-02-26)
### B.Cu Footprint Routing (Fixed v2.2.3)
**Was:** `add_schematic_component` corrupted .kicad_sch files due to sexpdata formatting issues
**Now:** Rewritten to use text manipulation, preserves KiCAD file formatting perfectly
**Impact:** Schematic workflow fully functional with all component types
**Fixed in:** PR #40, commit a69d288
- `route_pad_to_pad` now correctly detects B.Cu footprints and inserts vias
- KiCAD 9 SWIG `pad.GetLayerName()` always returned F.Cu for flipped footprints -- fixed using `footprint.GetLayer()`
### DRC Violations API KiCAD 9.0 (Fixed 2026-02-26)
### B.Cu Placement Hang (Fixed v2.2.3)
**Was:** `get_drc_violations` failed with `AttributeError: 'BOARD' object has no attribute 'GetDRCMarkers'`
**Now:** Reimplemented to use `run_drc()` internally which calls kicad-cli
**Impact:** Maintains backward compatibility while using stable kicad-cli interface
- Placing footprints on B.Cu no longer causes ~30s freeze
- Fix: call `board.Add()` before `Flip()`
### Component Library Integration (Fixed 2025-11-01)
### Board Outline Rounded Corners (Fixed v2.2.3)
**Was:** Could not find footprint libraries
**Now:** Auto-discovers 153 KiCAD footprint libraries, search and list working
- `add_board_outline` now correctly applies cornerRadius for rounded_rectangle shape
### Routing Operations KiCAD 9.0 (Fixed 2025-11-01)
### Project-Local Library Resolution (Fixed v2.2.2)
**Was:** Multiple API compatibility issues with KiCAD 9.0
**Now:** All routing commands tested and working:
- `netinfo.FindNet()` -> `netinfo.NetsByName()[name]`
- `zone.SetPriority()` -> `zone.SetAssignedPriority()`
- `ZONE_FILL_MODE_POLYGON` -> `ZONE_FILL_MODE_POLYGONS`
- `add_schematic_component` and `place_component` now search project-local sym-lib-table and fp-lib-table
- Previously only global KiCAD library paths were searched
### KiCAD Process Detection (Fixed 2025-10-26)
### Template File Corruption (Fixed v2.2.2)
**Was:** `check_kicad_ui` detected MCP server's own processes
**Now:** Properly filters to only detect actual KiCAD binaries
- Removed invalid `;;` comment lines from template schematics
- Restored KiCAD 9 format version (20250114) in templates
### set_board_size KiCAD 9.0 (Fixed 2025-10-26)
### copy_routing_pattern Empty Results (Fixed v2.2.2)
**Was:** Failed with `BOX2I_SetSize` type error
**Now:** Works with KiCAD 9.0 API
- Added geometric fallback when pads have no net assignments
### add_board_text KiCAD 9.0 (Fixed 2025-10-26)
### Schematic Component Corruption (Fixed v2.2.1)
**Was:** Failed with `EDA_ANGLE` type error
**Now:** Works with KiCAD 9.0 API
- `add_schematic_component` no longer corrupts .kicad_sch files
- Rewritten to use text manipulation instead of sexpdata formatting
### Schematic Parameter Mismatch (Fixed 2025-12-02)
### SWIG/UUID Comparison Bugs (Fixed v2.2.0)
**Was:** `create_schematic` failed due to parameter name differences between TypeScript and Python
**Now:** Accepts multiple parameter naming conventions (`name`, `projectName`, `title`, `filename`)
- Fixed SwigPyObject UUID comparison
- Fixed SWIG iterator invalidation after board.Remove()
- Added board.SetModified() to prevent dangling pointer crashes
---
@@ -140,31 +135,21 @@ The IPC backend is currently being implemented and tested. Some commands may not
If you encounter an issue not listed here:
1. **Check MCP logs:** `~/.kicad-mcp/logs/kicad_interface.log`
2. **Check KiCAD version:** `python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())"` (must be 9.0+)
3. **Try the operation in KiCAD directly** - is it a KiCAD issue?
4. **Open GitHub issue** with:
- Error message
- Log excerpt
2. **Enable developer mode:** Set `KICAD_MCP_DEV=1` to capture session logs
3. **Check KiCAD version:** `python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())"` (must be 9.0+)
4. **Try the operation in KiCAD directly** -- is it a KiCAD issue?
5. **Open a GitHub issue** with:
- Error message and log excerpt
- Steps to reproduce
- KiCAD version
- OS and version
---
## Priority Matrix
| Issue | Priority | Impact | Status |
|-------|----------|--------|--------|
| IPC Backend Testing | High | Medium | In Progress |
| get_board_info Fix | Low | Low | Known |
| Zone Filling (SWIG) | Medium | Medium | Workaround Available |
| Schematic Support | Medium | Medium | Partial |
- KiCAD version and OS
- MCP session log (from `logs/` folder if dev mode is enabled)
---
## General Workarounds
### Server Won't Start
### Server Will Not Start
```bash
# Check Python can import pcbnew
python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())"
@@ -174,12 +159,14 @@ python3 python/utils/platform_helper.py
```
### Commands Fail After Server Restart
```
# Board reference is lost on restart
# Always run open_project after server restart
```
### KiCAD UI Doesn't Show Changes (SWIG Mode)
### KiCAD UI Does Not Show Changes (SWIG Mode)
```
# File > Revert (or click reload prompt)
# Or: Close and reopen file in KiCAD
@@ -187,6 +174,7 @@ python3 python/utils/platform_helper.py
```
### IPC Not Connecting
```
# Ensure KiCAD is running
# Enable IPC: Preferences > Plugins > Enable IPC API Server
@@ -197,7 +185,7 @@ python3 python/utils/platform_helper.py
---
**Need Help?**
- Check [IPC_BACKEND_STATUS.md](IPC_BACKEND_STATUS.md) for IPC details
- Check [REALTIME_WORKFLOW.md](REALTIME_WORKFLOW.md) for workflow tips
- Check logs: `~/.kicad-mcp/logs/kicad_interface.log`
- Open an issue on GitHub

View File

@@ -1,27 +1,38 @@
# KiCAD Footprint Library Integration
# KiCAD Library Integration
**Status:** ✅ COMPLETE (Week 2 - Component Library Integration)
**Date:** 2025-11-01
**Version:** 2.1.0-alpha
**Status:** ✅ COMPLETE
**Date:** 2026-03-21
**Version:** 2.2.3+
## Overview
The KiCAD MCP Server now includes full footprint library integration, enabling:
The KiCAD MCP Server includes full library integration for both footprints and symbols, enabling:
- ✅ Automatic discovery of all installed KiCAD footprint libraries
-Search and browse footprints across all libraries
-Automatic discovery of KiCAD symbol libraries (including project-local)
- ✅ Search and browse footprints/symbols across all libraries
- ✅ Component placement using library footprints
- ✅ Symbol creation and editing with project-local library support (v2.2.2+)
- ✅ Support for both `Library:Footprint` and `Footprint` formats
## How It Works
### Library Discovery
The `LibraryManager` class automatically discovers footprint libraries by:
The library system automatically discovers both footprint and symbol libraries:
**Footprint Libraries** - `LibraryManager` class:
1. **Parsing fp-lib-table files:**
- Global: `~/.config/kicad/9.0/fp-lib-table`
- Project-specific: `project-dir/fp-lib-table`
**Symbol Libraries** - `DynamicSymbolLoader` class (v2.2.2+):
1. **Parsing sym-lib-table files:**
- Global: `~/.config/kicad/9.0/sym-lib-table`
- Project-local: `project-dir/sym-lib-table` (added v2.2.2)
2. **Resolving environment variables:**
- `${KICAD9_FOOTPRINT_DIR}``/usr/share/kicad/footprints`
- `${K IPRJMOD}` → project directory
@@ -35,6 +46,7 @@ The `LibraryManager` class automatically discovers footprint libraries by:
### Supported Formats
**Library:Footprint format (recommended):**
```json
{
"componentId": "Resistor_SMD:R_0603_1608Metric"
@@ -42,6 +54,7 @@ The `LibraryManager` class automatically discovers footprint libraries by:
```
**Footprint-only format (searches all libraries):**
```json
{
"componentId": "R_0603_1608Metric"
@@ -57,6 +70,7 @@ List all available footprint libraries.
**Parameters:** None
**Returns:**
```json
{
"success": true,
@@ -70,6 +84,7 @@ List all available footprint libraries.
Search for footprints matching a pattern.
**Parameters:**
```json
{
"pattern": "*0603*", // Supports wildcards
@@ -78,6 +93,7 @@ Search for footprints matching a pattern.
```
**Returns:**
```json
{
"success": true,
@@ -97,6 +113,7 @@ Search for footprints matching a pattern.
List all footprints in a specific library.
**Parameters:**
```json
{
"library": "Resistor_SMD"
@@ -104,6 +121,7 @@ List all footprints in a specific library.
```
**Returns:**
```json
{
"success": true,
@@ -118,6 +136,7 @@ List all footprints in a specific library.
Get detailed information about a specific footprint.
**Parameters:**
```json
{
"footprint": "Resistor_SMD:R_0603_1608Metric"
@@ -125,6 +144,7 @@ Get detailed information about a specific footprint.
```
**Returns:**
```json
{
"success": true,
@@ -153,6 +173,7 @@ The `place_component` tool now uses the library system:
```
**Features:**
- ✅ Automatic footprint discovery across all libraries
- ✅ Helpful error messages with suggestions
- ✅ Supports KiCAD 9.0 API (EDA_ANGLE, GetFPIDAsString)
@@ -160,6 +181,7 @@ The `place_component` tool now uses the library system:
## Example Usage (Claude Code)
**Search for a resistor footprint:**
```
User: "Find me a 0603 resistor footprint"
@@ -168,6 +190,7 @@ Claude: [uses search_footprints tool with pattern "*R_0603*"]
```
**Place a component:**
```
User: "Place a 10k 0603 resistor at 50,40mm"
@@ -176,6 +199,7 @@ Claude: [uses place_component with "Resistor_SMD:R_0603_1608Metric"]
```
**List available capacitors:**
```
User: "What capacitor footprints are available?"
@@ -213,18 +237,22 @@ The system automatically detects KiCAD installations, but you can add custom lib
The library integration includes full KiCAD 9.0 API support:
### Fixed API Changes:
1.`SetOrientation()` → now uses `EDA_ANGLE(degrees, DEGREES_T)`
2.`GetOrientation()` → returns `EDA_ANGLE`, call `.AsDegrees()`
3.`GetFootprintName()` → now `GetFPIDAsString()`
### Example Fixes:
**Old (KiCAD 8.0):**
```python
module.SetOrientation(90 * 10) # Decidegrees
rotation = module.GetOrientation() / 10
```
**New (KiCAD 9.0):**
```python
angle = pcbnew.EDA_ANGLE(90, pcbnew.DEGREES_T)
module.SetOrientation(angle)
@@ -238,6 +266,7 @@ rotation = module.GetOrientation().AsDegrees()
**Location:** `python/commands/library.py`
**Key Methods:**
- `_load_libraries()` - Parse fp-lib-table files
- `_parse_fp_lib_table()` - S-expression parser
- `_resolve_uri()` - Handle environment variables
@@ -246,6 +275,7 @@ rotation = module.GetOrientation().AsDegrees()
- `list_footprints()` - List library contents
**Performance:**
- Libraries loaded once at startup
- Footprint lists cached on first access
- Fast search using Python regex
@@ -271,6 +301,7 @@ rotation = module.GetOrientation().AsDegrees()
## Testing
**Test Coverage:**
- ✅ Library path discovery (Linux/Windows/macOS)
- ✅ fp-lib-table parsing
- ✅ Environment variable resolution
@@ -279,6 +310,7 @@ rotation = module.GetOrientation().AsDegrees()
- ✅ Error handling and suggestions
**Verified With:**
- KiCAD 9.0.5 on Ubuntu 24.04
- 153 standard libraries (8,000+ footprints)
- pcbnew Python API
@@ -306,6 +338,7 @@ rotation = module.GetOrientation().AsDegrees()
**Cause:** fp-lib-table not found or empty
**Solution:**
1. Verify KiCAD is installed
2. Open KiCAD and ensure libraries are configured
3. Check `~/.config/kicad/9.0/fp-lib-table` exists
@@ -315,6 +348,7 @@ rotation = module.GetOrientation().AsDegrees()
**Cause:** Footprint doesn't exist or library not loaded
**Solution:**
1. Use `search_footprints` to find similar footprints
2. Check library name is correct
3. Verify library is in fp-lib-table
@@ -324,6 +358,7 @@ rotation = module.GetOrientation().AsDegrees()
**Cause:** Corrupt .kicad_mod file or permissions issue
**Solution:**
1. Check file permissions on library directories
2. Reinstall KiCAD libraries if corrupt
3. Check logs for detailed error
@@ -337,7 +372,10 @@ rotation = module.GetOrientation().AsDegrees()
## Changelog
**2025-11-01 - v2.1.0-alpha**
**2026-03-21 - v2.2.3+**
- Project-local symbol library support (v2.2.2)
- Project-local footprint library support (v2.2.2)
- Implemented LibraryManager class
- Added 4 new MCP library tools
- Updated component placement to use libraries

View File

@@ -1,4 +1,5 @@
# Linux Compatibility Audit Report
**Date:** 2025-10-25
**Target Platform:** Ubuntu 24.04 LTS (primary), Fedora, Arch (secondary)
**Current Status:** Windows-optimized, partial Linux support
@@ -10,6 +11,7 @@
The KiCAD MCP Server was originally developed for Windows and has several compatibility issues preventing smooth operation on Linux. This audit identifies all platform-specific issues and provides remediation priorities.
**Overall Status:** 🟡 **PARTIAL COMPATIBILITY**
- ✅ TypeScript server: Good cross-platform support
- 🟡 Python interface: Mixed (some hardcoded paths)
- ❌ Configuration: Windows-specific examples
@@ -20,7 +22,9 @@ The KiCAD MCP Server was originally developed for Windows and has several compat
## Critical Issues (P0 - Must Fix)
### 1. Hardcoded Windows Paths in Config Examples
**File:** `config/claude-desktop-config.json`
```json
"cwd": "c:/repo/KiCAD-MCP",
"PYTHONPATH": "C:/Program Files/KiCad/9.0/lib/python3/dist-packages"
@@ -33,7 +37,9 @@ The KiCAD MCP Server was originally developed for Windows and has several compat
---
### 2. Library Search Paths (Mixed Approach)
**File:** `python/commands/library_schematic.py:16`
```python
search_paths = [
"C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym", # Windows
@@ -49,7 +55,9 @@ search_paths = [
---
### 3. Python Path Detection
**File:** `python/kicad_interface.py:38-45`
```python
kicad_paths = [
os.path.join(os.path.dirname(sys.executable), 'Lib', 'site-packages'),
@@ -66,9 +74,11 @@ kicad_paths = [
## High Priority Issues (P1)
### 4. Documentation is Windows-Only
**Files:** `README.md`, installation instructions
**Issues:**
- Installation paths reference `C:\Program Files`
- VSCode settings path is Windows format
- No Linux-specific troubleshooting
@@ -79,6 +89,7 @@ kicad_paths = [
---
### 5. Missing Python Dependencies Documentation
**File:** None (no requirements.txt)
**Impact:** Users don't know what Python packages to install
@@ -88,6 +99,7 @@ kicad_paths = [
---
### 6. Path Handling Uses os.path Instead of pathlib
**Files:** All Python files (11 files)
**Impact:** Code is less readable and more error-prone
@@ -99,6 +111,7 @@ kicad_paths = [
## Medium Priority Issues (P2)
### 7. No Linux-Specific Testing
**Impact:** Can't verify Linux compatibility
**Fix:** Add GitHub Actions with Ubuntu runner
**Priority:** P2
@@ -106,9 +119,11 @@ kicad_paths = [
---
### 8. Log File Paths May Differ
**File:** `src/logger.ts:13`
```typescript
const DEFAULT_LOG_DIR = join(os.homedir(), '.kicad-mcp', 'logs');
const DEFAULT_LOG_DIR = join(os.homedir(), ".kicad-mcp", "logs");
```
**Impact:** `.kicad-mcp` is okay for Linux, but best practice is `~/.config/kicad-mcp`
@@ -118,6 +133,7 @@ const DEFAULT_LOG_DIR = join(os.homedir(), '.kicad-mcp', 'logs');
---
### 9. No Bash/Shell Scripts for Linux
**Impact:** Manual setup is harder on Linux
**Fix:** Create `install.sh` and `run.sh` scripts
**Priority:** P2
@@ -127,6 +143,7 @@ const DEFAULT_LOG_DIR = join(os.homedir(), '.kicad-mcp', 'logs');
## Low Priority Issues (P3)
### 10. TypeScript Build Uses Windows Conventions
**File:** `package.json`
**Impact:** Works but could be more Linux-friendly
@@ -151,6 +168,7 @@ const DEFAULT_LOG_DIR = join(os.homedir(), '.kicad-mcp', 'logs');
### **Week 1 - Critical Fixes (P0)**
1. **Create Platform-Specific Config Templates**
```bash
config/
├── linux-config.example.json
@@ -159,6 +177,7 @@ const DEFAULT_LOG_DIR = join(os.homedir(), '.kicad-mcp', 'logs');
```
2. **Fix Python Path Detection**
```python
# Detect platform and set appropriate paths
import platform
@@ -187,6 +206,7 @@ const DEFAULT_LOG_DIR = join(os.homedir(), '.kicad-mcp', 'logs');
### **Week 1 - High Priority (P1)**
4. **Create requirements.txt**
```txt
# requirements.txt
kicad-skip>=0.1.0
@@ -209,6 +229,7 @@ const DEFAULT_LOG_DIR = join(os.homedir(), '.kicad-mcp', 'logs');
## Testing Checklist
### Ubuntu 24.04 LTS Testing
- [ ] Install KiCAD 9.0 from official PPA
- [ ] Install Node.js 18+ from NodeSource
- [ ] Clone repository
@@ -220,10 +241,12 @@ const DEFAULT_LOG_DIR = join(os.homedir(), '.kicad-mcp', 'logs');
- [ ] Test: Export Gerbers
### Fedora Testing
- [ ] Install KiCAD from Fedora repos
- [ ] Test same workflow
### Arch Testing
- [ ] Install KiCAD from AUR
- [ ] Test same workflow

311
docs/PCB_DESIGN_WORKFLOW.md Normal file
View File

@@ -0,0 +1,311 @@
# End-to-End PCB Design Workflow
This guide walks through the complete PCB design process using the KiCAD MCP Server, from project creation to manufacturing-ready output.
---
## Overview
A typical PCB design follows this flow:
```
Project Setup -> Schematic Design -> PCB Layout -> Verification -> Manufacturing Output
```
Each stage maps to specific MCP tools. You can ask your AI assistant to perform any of these steps using natural language.
---
## Stage 1: Project Setup
### Create a New Project
```
Create a new KiCAD project named "LEDBoard" in ~/Projects/
```
This uses `create_project` to generate:
- `.kicad_pro` -- project file
- `.kicad_pcb` -- PCB layout file
- `.kicad_sch` -- schematic file (with template symbols pre-loaded)
### Set Up the Board
```
Set the board size to 50mm x 50mm.
Add a rectangular board outline.
Add mounting holes at each corner, 3mm from the edges, 3mm diameter.
```
**Tools used:** `set_board_size`, `add_board_outline`, `add_mounting_hole`
---
## Stage 2: Schematic Design
### Place Components
```
Add an LED from the Device library to the schematic at position 100, 50.
Add a 1K resistor at position 100, 70.
Add a connector from the Connector_Generic library with 2 pins at position 60, 60.
```
**Tool:** `add_schematic_component`
The dynamic symbol loader provides access to all ~10,000 KiCad standard symbols. Specify any library and symbol name.
### Wire Components
```
Connect R1 pin 2 to LED1 pin 1.
Add a net label "VCC" at position 60, 50.
Connect J1 pin 1 to the VCC net.
Connect LED1 pin 2 to GND.
```
**Tools:** `add_schematic_connection`, `add_schematic_net_label`, `connect_to_net`
### FFC/Ribbon Cable Passthrough (Special Workflow)
For passthrough adapter boards (e.g., Raspberry Pi CSI adapters):
```
Connect all pins from J1 to J2 as a passthrough with net prefix "CSI_".
```
**Tool:** `connect_passthrough` -- automatically wires matching pins between two connectors
### Annotate and Validate
```
Annotate the schematic to assign reference designators.
Run an electrical rule check.
```
**Tools:** `annotate_schematic`, `run_erc`
### Preview the Schematic
```
Show me the schematic as an image.
Export the schematic to PDF.
```
**Tools:** `get_schematic_view`, `export_schematic_pdf`
---
## Stage 3: PCB Layout
### Synchronize Schematic to PCB
```
Sync the schematic to the board.
```
**Tool:** `sync_schematic_to_board` -- imports all component footprints and net assignments from the schematic into the PCB (equivalent to pressing F8 in KiCAD)
### Place Components
```
Move R1 to position x=15, y=25.
Move LED1 to position x=25, y=25.
Align all resistors horizontally.
```
**Tools:** `move_component`, `align_components`
### Route Traces
**Preferred approach -- pad-to-pad routing:**
```
Route R1 pad 2 to LED1 pad 1 with 0.3mm trace width.
```
**Tool:** `route_pad_to_pad` -- auto-detects pad positions, nets, and inserts vias when pads are on different layers
**Manual approach:**
```
Route a trace from x=15, y=25 to x=25, y=25 on the front copper layer.
```
**Tool:** `route_trace`
### Advanced Routing
**Differential pairs:**
```
Route a differential pair for USB_P and USB_N with 0.2mm width and 0.15mm gap.
```
**Copper zones:**
```
Add a GND copper pour on the bottom layer covering the entire board.
```
**Tools:** `route_differential_pair`, `add_copper_pour`
### Autorouting
For boards with many connections:
```
Check if Freerouting is available.
Autoroute the board using Freerouting.
```
**Tools:** `check_freerouting`, `autoroute`
See [Freerouting Guide](FREEROUTING_GUIDE.md) for setup details.
---
## Stage 4: Verification
### Design Rule Check
```
Set design rules with 0.15mm clearance and 0.2mm minimum track width.
Run the design rule check.
Show me all DRC violations.
```
**Tools:** `set_design_rules`, `run_drc`, `get_drc_violations`
### Visual Inspection
```
Show me a 2D view of the board.
```
**Tool:** `get_board_2d_view`
### Save a Checkpoint
```
Save a snapshot named "post-routing" with label "All traces routed, DRC clean".
```
**Tool:** `snapshot_project`
---
## Stage 5: Manufacturing Output
### Gerber Files
```
Export Gerber files to the fabrication folder.
```
**Tool:** `export_gerber`
### Bill of Materials
```
Export BOM as CSV.
```
**Tool:** `export_bom` (supports CSV, XML, HTML, JSON)
### Pick and Place
```
Export the component position file.
```
**Tool:** `export_position_file`
### 3D Preview
```
Export a 3D STEP model of the board.
```
**Tool:** `export_3d` (supports STEP, STL, VRML, OBJ)
### Documentation
```
Export a PDF of the board layout.
Export an SVG of the board.
```
**Tools:** `export_pdf`, `export_svg`
---
## Optional: JLCPCB Component Selection
Before placing components, you can search JLCPCB's catalog for optimal parts:
```
Search JLCPCB for 10K resistors in 0603 package, Basic parts only.
Show me the cheapest option with good stock.
Suggest alternatives to part C25804.
```
After selecting parts, enrich datasheets:
```
Enrich datasheets for all components in the schematic.
```
**Tools:** `search_jlcpcb_parts`, `get_jlcpcb_part`, `suggest_jlcpcb_alternatives`, `enrich_datasheets`
See [JLCPCB Integration](JLCPCB_INTEGRATION.md) for details.
---
## Optional: Custom Components
When existing libraries do not have the part you need:
```
Create a custom footprint for a 4-pin SOT-23 package.
Create a custom symbol for the XYZ IC with 8 pins.
Register the custom library so it can be used in the project.
```
**Tools:** `create_footprint`, `create_symbol`, `register_footprint_library`, `register_symbol_library`
See [Footprint and Symbol Creator Guide](FOOTPRINT_SYMBOL_CREATOR_GUIDE.md) for details.
---
## Optional: Add a Logo
```
Import our company logo from ~/logos/logo.svg onto the front silkscreen at position x=25 y=45 with width 10mm.
```
**Tool:** `import_svg_logo`
See [SVG Import Guide](SVG_IMPORT_GUIDE.md) for requirements and tips.
---
## Tips
- **Save frequently** -- use `save_project` after major changes
- **Use snapshots** -- `snapshot_project` creates named checkpoints you can return to
- **Validate early** -- run ERC after schematic changes and DRC after routing
- **Start with schematic** -- always design the schematic first, then sync to PCB
- **Use route_pad_to_pad** -- it is faster and more reliable than manual XY coordinate routing
- **Check the KiCAD UI** -- use `launch_kicad_ui` to open the design for visual verification
---
## Related Documentation
- [Tool Inventory](TOOL_INVENTORY.md) -- complete list of all 122 tools
- [Schematic Tools Reference](SCHEMATIC_TOOLS_REFERENCE.md) -- detailed schematic tool docs
- [Routing Tools Reference](ROUTING_TOOLS_REFERENCE.md) -- detailed routing tool docs
- [Freerouting Guide](FREEROUTING_GUIDE.md) -- autorouter setup and usage
- [JLCPCB Integration](JLCPCB_INTEGRATION.md) -- parts selection and cost optimization

View File

@@ -1,22 +1,22 @@
# Platform Guide: Linux vs Windows
# Platform Guide: Linux, macOS & Windows
This guide explains the differences between using KiCAD MCP Server on Linux and Windows platforms.
This guide explains the differences between using KiCAD MCP Server on Linux, macOS, and Windows platforms.
**Last Updated:** 2025-11-05
**Last Updated:** 2026-04-11
---
## Quick Comparison
| Feature | Linux | Windows |
|---------|-------|---------|
| **Primary Support** | Full (tested extensively) | Community tested |
| **Setup Complexity** | Moderate | Easy (automated script) |
| **Prerequisites** | Manual package management | Automated detection |
| **KiCAD Python Access** | System paths | Bundled with KiCAD |
| **Path Separators** | Forward slash (/) | Backslash (\\) or forward slash |
| **Virtual Environments** | Recommended | Optional |
| **Troubleshooting** | Standard Linux tools | PowerShell diagnostics |
| Feature | Linux | Windows | macOS |
| ------------------------ | ------------------------- | ------------------------------- | ----------------------- |
| **Primary Support** | Full (tested extensively) | Community tested | Community tested |
| **Setup Complexity** | Moderate | Easy (automated script) | Easy (automated script) |
| **Prerequisites** | Manual package management | Automated detection | Automated detection |
| **KiCAD Python Access** | System paths | Bundled with KiCAD | Bundled with KiCAD |
| **Path Separators** | Forward slash (/) | Backslash (\\) or forward slash | Forward slash (/) |
| **Virtual Environments** | Recommended | Optional | Optional |
| **Troubleshooting** | Standard Linux tools | PowerShell diagnostics | Bash diagnostics |
---
@@ -25,12 +25,14 @@ This guide explains the differences between using KiCAD MCP Server on Linux and
### Linux Installation
**Advantages:**
- Native package manager integration
- Better tested and documented
- More predictable Python environments
- Standard Unix paths
**Process:**
1. Install KiCAD 9.0 via package manager (apt, dnf, pacman)
2. Install Node.js via package manager or nvm
3. Clone repository
@@ -40,6 +42,7 @@ This guide explains the differences between using KiCAD MCP Server on Linux and
7. Set PYTHONPATH environment variable
**Typical paths:**
```bash
KiCAD Python: /usr/lib/kicad/lib/python3/dist-packages
Node.js: /usr/bin/node
@@ -47,6 +50,7 @@ Python: /usr/bin/python3
```
**Configuration example:**
```json
{
"mcpServers": {
@@ -61,15 +65,62 @@ Python: /usr/bin/python3
}
```
### macOS Installation
**Advantages:**
- Automated setup script (`setup-macos.sh`) handles detection and configuration
- KiCAD includes bundled Python (no system Python needed for pcbnew)
- Prerequisite checks with clear pass/fail output
- Generates and merges Claude Desktop configuration automatically
**Process:**
1. Install KiCAD 9.0 from the official `.dmg` installer
2. Install Node.js (e.g. via Homebrew or nvm)
3. Clone repository
4. Run `npm install && npm run build`
5. Run `setup-macos.sh`:
- `bash setup-macos.sh --verify` — check prerequisites and detected paths
- `bash setup-macos.sh --dry-run` — preview the merged Claude Desktop config
- `bash setup-macos.sh --apply` — write the configuration
**Typical paths:**
```bash
KiCAD Python: /Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/Current/bin/python3
KiCAD Libraries: /Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.x/lib/python3.x/site-packages
Node.js: /usr/local/bin/node # or via nvm
```
**Configuration example:**
```json
{
"mcpServers": {
"kicad": {
"command": "node",
"args": ["/Users/username/KiCAD-MCP-Server/dist/index.js"],
"env": {
"KICAD_PYTHON": "/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/Current/bin/python3",
"PYTHONPATH": "/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages"
}
}
}
}
```
### Windows Installation
**Advantages:**
- Automated setup script handles everything
- KiCAD includes bundled Python (no system Python needed)
- Better error diagnostics
- Comprehensive troubleshooting guide
**Process:**
1. Install KiCAD 9.0 from official installer
2. Install Node.js from official installer
3. Clone repository
@@ -82,6 +133,7 @@ Python: /usr/bin/python3
- Validates setup
**Typical paths:**
```powershell
KiCAD Python: C:\Program Files\KiCad\9.0\bin\python.exe
KiCAD Libraries: C:\Program Files\KiCad\9.0\lib\python3\dist-packages
@@ -89,6 +141,7 @@ Node.js: C:\Program Files\nodejs\node.exe
```
**Configuration example:**
```json
{
"mcpServers": {
@@ -108,19 +161,37 @@ Node.js: C:\Program Files\nodejs\node.exe
## Path Handling
### Linux Paths
- Use forward slashes: `/home/user/project`
- Case-sensitive filesystem
- No drive letters
- Symbolic links commonly used
**Example commands:**
```bash
cd /home/username/KiCAD-MCP-Server
export PYTHONPATH=/usr/lib/kicad/lib/python3/dist-packages
python3 -c "import pcbnew"
```
### macOS Paths
- Use forward slashes: `/Users/username/project`
- Case-insensitive but case-preserving filesystem (APFS default)
- No drive letters
- KiCAD paths are inside the `.app` bundle
**Example commands:**
```bash
cd ~/KiCAD-MCP-Server
export KICAD_PYTHON=/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/Current/bin/python3
"$KICAD_PYTHON" -c "import pcbnew"
```
### Windows Paths
- Use backslashes in native commands: `C:\Users\username`
- Use double backslashes in JSON: `C:\\Users\\username`
- OR use forward slashes in JSON: `C:/Users/username`
@@ -128,6 +199,7 @@ python3 -c "import pcbnew"
- Drive letters required (C:, D:, etc.)
**Example commands:**
```powershell
cd C:\Users\username\KiCAD-MCP-Server
$env:PYTHONPATH = "C:\Program Files\KiCad\9.0\lib\python3\dist-packages"
@@ -135,6 +207,7 @@ $env:PYTHONPATH = "C:\Program Files\KiCad\9.0\lib\python3\dist-packages"
```
**JSON configuration notes:**
```json
// Wrong - single backslash will cause errors
"args": ["C:\Users\name\project"]
@@ -153,11 +226,13 @@ $env:PYTHONPATH = "C:\Program Files\KiCad\9.0\lib\python3\dist-packages"
### Linux
**System Python:**
- Usually Python 3.10+ available system-wide
- KiCAD uses system Python with additional modules
- Virtual environments recommended for isolation
**Setup:**
```bash
# Check Python version
python3 --version
@@ -175,6 +250,7 @@ pip install -r requirements.txt
```
**PYTHONPATH:**
```bash
# Temporary (current session)
export PYTHONPATH=/usr/lib/kicad/lib/python3/dist-packages
@@ -183,14 +259,37 @@ export PYTHONPATH=/usr/lib/kicad/lib/python3/dist-packages
echo 'export PYTHONPATH=/usr/lib/kicad/lib/python3/dist-packages' >> ~/.bashrc
```
### macOS
**KiCAD Bundled Python:**
- KiCAD bundles Python inside the `.app` framework (versions 3.93.12)
- No system Python installation needed for pcbnew
- `setup-macos.sh` detects the correct path automatically
**Setup:**
```bash
# Check KiCAD Python
/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/Current/bin/python3 --version
# Verify pcbnew module
/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/Current/bin/python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())"
# Or use the setup script to verify everything at once
bash setup-macos.sh --verify
```
### Windows
**KiCAD Bundled Python:**
- KiCAD 9.0 includes Python 3.11
- No system Python installation needed
- Use KiCAD's Python for all MCP operations
**Setup:**
```powershell
# Check KiCAD Python
& "C:\Program Files\KiCad\9.0\bin\python.exe" --version
@@ -203,6 +302,7 @@ echo 'export PYTHONPATH=/usr/lib/kicad/lib/python3/dist-packages' >> ~/.bashrc
```
**PYTHONPATH:**
```powershell
# Temporary (current session)
$env:PYTHONPATH = "C:\Program Files\KiCad\9.0\lib\python3\dist-packages"
@@ -222,18 +322,21 @@ $env:PYTHONPATH = "C:\Program Files\KiCad\9.0\lib\python3\dist-packages"
### Linux
**Check KiCAD installation:**
```bash
which kicad
kicad --version
```
**Check Python module:**
```bash
python3 -c "import sys; print(sys.path)"
python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())"
```
**Run tests:**
```bash
cd /home/username/KiCAD-MCP-Server
npm test
@@ -241,41 +344,74 @@ pytest tests/
```
**View logs:**
```bash
tail -f ~/.kicad-mcp/logs/kicad_interface.log
```
**Start server manually:**
```bash
export PYTHONPATH=/usr/lib/kicad/lib/python3/dist-packages
node dist/index.js
```
### macOS
**Check KiCAD installation:**
```bash
ls /Applications/KiCad/KiCad.app
```
**Run automated diagnostics:**
```bash
bash setup-macos.sh --verify
```
**View logs:**
```bash
tail -f ~/.kicad-mcp/logs/kicad_interface.log
```
**Start server manually:**
```bash
node dist/index.js
```
### Windows
**Check KiCAD installation:**
```powershell
Test-Path "C:\Program Files\KiCad\9.0"
& "C:\Program Files\KiCad\9.0\bin\kicad.exe" --version
```
**Check Python module:**
```powershell
& "C:\Program Files\KiCad\9.0\bin\python.exe" -c "import sys; print(sys.path)"
& "C:\Program Files\KiCad\9.0\bin\python.exe" -c "import pcbnew; print(pcbnew.GetBuildVersion())"
```
**Run automated diagnostics:**
```powershell
.\setup-windows.ps1
```
**View logs:**
```powershell
Get-Content "$env:USERPROFILE\.kicad-mcp\logs\kicad_interface.log" -Tail 50 -Wait
```
**Start server manually:**
```powershell
$env:PYTHONPATH = "C:\Program Files\KiCad\9.0\lib\python3\dist-packages"
node dist\index.js
@@ -288,6 +424,7 @@ node dist\index.js
### Linux-Specific Issues
**1. Permission Errors**
```bash
# Fix file permissions
chmod +x python/kicad_interface.py
@@ -297,6 +434,7 @@ chmod -R 755 ~/KiCAD-MCP-Server
```
**2. PYTHONPATH Not Set**
```bash
# Check current PYTHONPATH
echo $PYTHONPATH
@@ -306,6 +444,7 @@ find /usr -name "pcbnew.py" 2>/dev/null
```
**3. KiCAD Not in PATH**
```bash
# Add to PATH temporarily
export PATH=$PATH:/usr/bin
@@ -315,6 +454,7 @@ export PATH=$PATH:/usr/bin
```
**4. Library Dependencies**
```bash
# Install missing system libraries
sudo apt-get install python3-wxgtk4.0 python3-cairo
@@ -323,14 +463,40 @@ sudo apt-get install python3-wxgtk4.0 python3-cairo
ldd /usr/lib/kicad/lib/python3/dist-packages/pcbnew.so
```
### macOS-Specific Issues
**1. KiCad Python Not Found**
```bash
# Verify the expected path exists
ls /Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/Current/bin/python3
# If installed elsewhere, set the override
export KICAD_PYTHON=/path/to/your/kicad/python3
bash setup-macos.sh --verify
```
**2. pcbnew Import Fails**
- Run `bash setup-macos.sh --verify` — the Prerequisites section will show a ✗ if pcbnew can't be imported
- Reinstall KiCAD if the bundled Python is corrupted
**3. Claude Config Not Picked Up**
- Default path is `~/Library/Application Support/Claude/claude_desktop_config.json`
- Use `--claude-config` flag to point to a different location
- Fully quit and reopen Claude Desktop after changes
### Windows-Specific Issues
**1. Server Exits Immediately**
- Most common issue
- Usually means pcbnew import failed
- Solution: Run `setup-windows.ps1` for diagnostics
**2. Path Issues in Configuration**
```powershell
# Test path accessibility
Test-Path "C:\Users\name\KiCAD-MCP-Server\dist\index.js"
@@ -340,6 +506,7 @@ cd C:\Users\[TAB]
```
**3. PowerShell Execution Policy**
```powershell
# Check current policy
Get-ExecutionPolicy
@@ -349,6 +516,7 @@ Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
```
**4. Antivirus Blocking**
```
Windows Defender may block Node.js or Python processes
Solution: Add exclusion for project directory in Windows Security
@@ -359,12 +527,14 @@ Solution: Add exclusion for project directory in Windows Security
## Performance Considerations
### Linux
- Generally faster file I/O operations
- Better process management
- Lower memory overhead
- Native Unix socket support (future IPC backend)
### Windows
- Slightly slower file operations
- More memory overhead
- Extra startup validation checks (for diagnostics)
@@ -379,6 +549,7 @@ Solution: Add exclusion for project directory in Windows Security
### Linux Development Environment
**Typical workflow:**
```bash
# Start development
cd ~/KiCAD-MCP-Server
@@ -395,6 +566,7 @@ python3 python/kicad_interface.py
```
**Recommended tools:**
- Terminal: GNOME Terminal, Konsole, or Alacritty
- Editor: VSCode with Python and TypeScript extensions
- Process monitoring: `htop` or `top`
@@ -403,6 +575,7 @@ python3 python/kicad_interface.py
### Windows Development Environment
**Typical workflow:**
```powershell
# Start development
cd C:\Users\username\KiCAD-MCP-Server
@@ -419,6 +592,7 @@ npm test
```
**Recommended tools:**
- Terminal: Windows Terminal or PowerShell 7
- Editor: VSCode with Python and TypeScript extensions
- Process monitoring: Task Manager or Process Explorer
@@ -436,6 +610,14 @@ npm test
4. **Check file permissions** if encountering access errors
5. **Monitor system logs** with `journalctl` if needed
### macOS
1. **Run `setup-macos.sh --verify` first** — confirms all prerequisites
2. **Use `--dry-run` before `--apply`** — review the merged config before writing
3. **Use KiCAD's bundled Python** — don't rely on system or Homebrew Python for pcbnew
4. **Override with `KICAD_PYTHON` env var** if KiCAD is in a non-standard location
5. **Check logs** in `~/.kicad-mcp/logs/` when debugging
### Windows
1. **Run setup-windows.ps1 first** - saves time troubleshooting
@@ -464,6 +646,13 @@ npm test
4. Update PYTHONPATH to Linux format
5. Set file permissions: `chmod +x python/kicad_interface.py`
### Moving to/from macOS
1. Clone repository on the target machine
2. Run `npm install && npm run build`
3. Run `bash setup-macos.sh --apply` (to macOS) or follow the target platform's setup
4. No project file changes needed
**KiCAD project files (.kicad_pro, .kicad_pcb) are identical across platforms.**
---
@@ -471,12 +660,21 @@ npm test
## Getting Help
### Linux Support
- Check: [README.md](../README.md) Linux installation section
- Read: [KNOWN_ISSUES.md](./KNOWN_ISSUES.md)
- Search: GitHub Issues filtered by `linux` label
- Community: Linux users in Discussions
### macOS Support
- Check: [README.md](../README.md) macOS installation section
- Run: `bash setup-macos.sh --verify` for automated diagnostics
- Search: GitHub Issues filtered by `macos` label
- Community: macOS users in Discussions
### Windows Support
- Check: [README.md](../README.md) Windows installation section
- Read: [WINDOWS_TROUBLESHOOTING.md](./WINDOWS_TROUBLESHOOTING.md)
- Run: `setup-windows.ps1` for automated diagnostics
@@ -488,25 +686,37 @@ npm test
## Summary
**Choose Linux if:**
- You're comfortable with command-line tools
- You want the most stable, tested environment
- You're developing or contributing to the project
- You need maximum performance
**Choose macOS if:**
- You're already using KiCAD on macOS
- You want automated setup with `setup-macos.sh`
- You prefer a Unix-based development environment
**Choose Windows if:**
- You want automated setup and diagnostics
- You're less comfortable with terminal commands
- You need detailed troubleshooting guidance
- You're a KiCAD user new to development tools
**Both platforms work well for PCB design with KiCAD MCP. Choose based on your comfort level and existing development environment.**
**All platforms work well for PCB design with KiCAD MCP. Choose based on your comfort level and existing development environment.**
---
**For platform-specific installation instructions, see:**
- Linux: [README.md - Linux Installation](../README.md#linux-ubuntudebian)
- macOS: [README.md - macOS Installation](../README.md#macos)
- Windows: [README.md - Windows Installation](../README.md#windows-1011)
**For troubleshooting:**
- Linux: [KNOWN_ISSUES.md](./KNOWN_ISSUES.md)
- macOS: Run `bash setup-macos.sh --verify`
- Windows: [WINDOWS_TROUBLESHOOTING.md](./WINDOWS_TROUBLESHOOTING.md)

View File

@@ -36,6 +36,7 @@ The MCP server uses KiCAD's Python API (`pcbnew` module) to read and write `.kic
**Use case:** Claude places components via MCP, human sees them in KiCAD UI
1. **Claude places components** via MCP tools:
```python
# MCP internally uses:
board = pcbnew.LoadBoard('project.kicad_pcb')
@@ -51,6 +52,7 @@ The MCP server uses KiCAD's Python API (`pcbnew` module) to read and write `.kic
- Components appear instantly ✅
**Example:**
```
User: "Place a 10k resistor at position 30, 30mm"
Claude: [uses place_component MCP tool]
@@ -74,6 +76,7 @@ User: [opens KiCAD UI]
- KiCAD writes changes to `.kicad_pcb` file
3. **Claude reads changes** via MCP tools:
```python
# MCP internally uses:
board = pcbnew.LoadBoard('project.kicad_pcb')
@@ -88,6 +91,7 @@ User: [opens KiCAD UI]
- New traces and nets
**Example:**
```
User: "I moved R1 to a new position, can you see it?"
Claude: [uses get_board_info MCP tool]
@@ -100,11 +104,13 @@ Claude: [uses get_board_info MCP tool]
### Test 1: MCP→UI (Verified ✅)
**Setup:**
- Created new board via MCP (100x80mm)
- Placed R1 (10k resistor) at (30, 30) mm
- Placed D1 (RED LED) at (50, 30) mm
**Result:**
- Opened KiCAD PCB editor
- Both components visible at correct positions ✅
- All properties (reference, value, rotation) correct ✅
@@ -112,10 +118,12 @@ Claude: [uses get_board_info MCP tool]
### Test 2: UI→MCP (Verified ✅)
**Setup:**
- User moved R1 from (30, 30) mm to (59.175, 49.0) mm in UI
- User saved file (Ctrl+S)
**Result:**
- MCP read board via `get_board_info`
- New position detected correctly ✅
- D1 position unchanged (as expected) ✅
@@ -136,11 +144,13 @@ Claude: [uses get_board_info MCP tool]
### MCP Tools for Collaboration
**Reading board state:**
- `get_board_info` - Get all components and their positions
- `get_project_info` - Get project metadata
- `list_components` - List all components (if implemented)
**Modifying board:**
- `place_component` - Add new components
- `add_trace` - Add copper traces
- `add_via` - Add vias
@@ -188,6 +198,7 @@ Claude: [uses get_board_info MCP tool]
### For AI-Human Collaboration
1. **Establish Turn-Taking:**
```
User: "I'm going to add some components, one sec"
[User edits in UI]
@@ -201,6 +212,7 @@ Claude: [uses get_board_info MCP tool]
- Claude: Always read fresh before making decisions
3. **Communicate Changes:**
```
Claude: "I'm placing R1-R4 now..."
[MCP places components]
@@ -215,6 +227,7 @@ Claude: [uses get_board_info MCP tool]
### Workflow Patterns
**Pattern 1: AI Does Layout, Human Reviews**
```
1. Claude places all components via MCP
2. Claude routes critical traces via MCP
@@ -225,6 +238,7 @@ Claude: [uses get_board_info MCP tool]
```
**Pattern 2: Human Sketches, AI Refines**
```
1. Human places major components in UI
2. Saves → Claude reads layout
@@ -235,6 +249,7 @@ Claude: [uses get_board_info MCP tool]
```
**Pattern 3: Pair Programming Style**
```
User: "Place a 10k pull-up resistor on pin 3"
Claude: [places R1 at calculated position]
@@ -276,6 +291,7 @@ Claude: [places D1]
### Long-Term Vision
**Fully Real-Time Collaboration:**
- Both AI and human see changes instantly
- No manual save/reload required
- Conflict detection and resolution
@@ -283,6 +299,7 @@ Claude: [places D1]
- Chat/comment system for design discussion
**Example Future Workflow:**
```
[Claude and human both have board open]
Claude: [starts placing R1]
@@ -299,6 +316,7 @@ Claude: "Good position for D1! I'll route them now"
### File Format
KiCAD uses S-expression format (`.kicad_pcb`):
```lisp
(kicad_pcb (version 20240108) (generator "pcbnew")
(footprint "Resistor_SMD:R_0603_1608Metric"
@@ -314,11 +332,13 @@ KiCAD uses S-expression format (`.kicad_pcb`):
### Sync Mechanism
**Current (File-based):**
1. MCP: `pcbnew.SaveBoard(path, board)` → writes file
2. UI: File → Revert → reads file
3. Latency: ~1-5 seconds (manual)
**Future (IPC-based):**
1. MCP: `kicad.AddFootprint(...)` → sends IPC command
2. KiCAD: Receives command → updates internal state
3. UI: Automatically refreshes display
@@ -355,6 +375,7 @@ pcbnew.SaveBoard('project.kicad_pcb', board)
**Cause:** UI hasn't reloaded the file
**Solution:**
1. File → Revert (or Ctrl+R if configured)
2. Or close PCB editor and reopen
3. Or restart KiCAD
@@ -364,6 +385,7 @@ pcbnew.SaveBoard('project.kicad_pcb', board)
**Cause:** File not saved
**Solution:**
1. Save file: Ctrl+S or File → Save
2. Verify save: Check file modification time
3. Ask Claude to read board again
@@ -373,6 +395,7 @@ pcbnew.SaveBoard('project.kicad_pcb', board)
**Cause:** File overwritten by other party
**Solution:**
1. Always save before asking MCP to make changes
2. Don't edit while MCP is working
3. Take turns to avoid conflicts
@@ -382,6 +405,7 @@ pcbnew.SaveBoard('project.kicad_pcb', board)
**Cause:** Unit conversion error or coordinate system mismatch
**Solution:**
1. Check KiCAD units (View → Switch Units)
2. MCP uses millimeters internally
3. Report issue if positions consistently wrong
@@ -410,6 +434,7 @@ The KiCAD MCP Server successfully enables paired circuit board design between AI
## Changelog
**2025-11-01 - v2.1.0-alpha**
- Tested MCPUI workflow (placing components via MCP, viewing in UI)
- Tested UIMCP workflow (editing in UI, reading via MCP)
- Documented best practices and limitations

View File

@@ -2,313 +2,123 @@
**Vision:** Enable anyone to design professional PCBs through natural conversation with AI
**Current Version:** 2.1.0-alpha
**Target:** 2.0.0 stable by end of Week 12
**Current Version:** 2.2.3
**Last Updated:** 2026-03-21
---
## Week 2: Component Integration & Routing
## Completed Milestones
**Goal:** Make the MCP server useful for real PCB design
**Status:** 80% Complete (2025-11-01)
### v1.0.0 - Core Foundation (October 2025)
### High Priority
- [x] MCP protocol implementation (JSON-RPC 2.0, MCP 2025-06-18)
- [x] Project management (create, open, save)
- [x] Board operations (size, outline, layers, mounting holes, text)
- [x] Component placement with 153+ footprint libraries
- [x] Basic routing (traces, vias, copper pours)
- [x] Design rule checking
- [x] Export (Gerber, PDF, SVG, 3D, BOM)
- [x] Cross-platform support (Linux, Windows, macOS)
- [x] UI auto-launch and detection
**1. Component Library Integration****COMPLETE**
- [x] Detect KiCAD footprint library paths
- [x] Add configuration for custom library paths
- [x] Create footprint search/autocomplete
- [x] Test component placement end-to-end
- [x] Document supported footprints
### v2.0.0-alpha - Router and IPC (November-December 2025)
**Deliverable:** ✅ Place components with actual footprints from libraries (153 libraries discovered!)
- [x] Tool router pattern -- 70% AI context reduction
- [x] IPC backend for real-time KiCAD UI synchronization (21 commands)
- [x] Hybrid SWIG/IPC backend with automatic fallback
- [x] Comprehensive Windows support with automated setup
**2. Routing Operations****COMPLETE**
- [x] Test `route_trace` with KiCAD 9.0
- [x] Test `add_via` with KiCAD 9.0
- [x] Test `add_copper_pour` with KiCAD 9.0
- [x] Fix any API compatibility issues
- [x] Add routing examples to docs
### v2.1.0-alpha - Schematics and JLCPCB (January 2026)
**Deliverable:** ✅ Successfully route a simple board (tested with nets, traces, vias, copper pours)
- [x] Complete schematic workflow fix (Issue #26)
- [x] Dynamic symbol loading -- access to all ~10,000 KiCad symbols
- [x] Intelligent wiring system with pin discovery and smart routing
- [x] Power symbol support (VCC, GND, +3V3, +5V)
- [x] Wire graph analysis for net connectivity
- [x] JLCPCB parts integration (2.5M+ parts, dual-mode architecture)
- [x] Local symbol library search (contributor: @l3wi)
**3. JLCPCB Parts Database** 📋 **PLANNED**
- [x] Research JLCPCB API and data format
- [x] Design integration architecture
- [ ] Download/parse JLCPCB parts database (~108k parts)
- [ ] Map parts to KiCAD footprints
- [ ] Create search by part number
- [ ] Add price/stock information
- [ ] Integrate with component placement
### v2.2.0 through v2.2.3 - Routing, Creators, Autorouting (February-March 2026)
**Deliverable:** "Add a 10k resistor (JLCPCB basic part)" - Ready to implement
### Medium Priority
**4. Fix get_board_info** 🟡 **DEFERRED**
- [ ] Update layer constants for KiCAD 9.0
- [ ] Add backward compatibility
- [ ] Test with real boards
**Status:** Low priority, workarounds available
**5. Example Projects** 🟢
- [ ] LED blinker (555 timer)
- [ ] Arduino Uno shield template
- [ ] Raspberry Pi HAT template
- [ ] Video tutorial of complete workflow
### Bonus Achievements ✨
**Real-time Collaboration****COMPLETE**
- [x] Test MCP→UI workflow (AI places, human sees)
- [x] Test UI→MCP workflow (human edits, AI reads)
- [x] Document best practices and limitations
- [x] Verify bidirectional sync works correctly
**Documentation****COMPLETE**
- [x] LIBRARY_INTEGRATION.md (comprehensive library guide)
- [x] REALTIME_WORKFLOW.md (collaboration workflows)
- [x] JLCPCB_INTEGRATION_PLAN.md (implementation plan)
- [x] 13 new routing/component tools (delete/query/modify traces, arrays, alignment)
- [x] route_pad_to_pad with auto-via insertion for cross-layer connections
- [x] copy_routing_pattern for trace replication
- [x] route_differential_pair for matched signals
- [x] Custom footprint creator (4 tools)
- [x] Custom symbol creator (4 tools)
- [x] Datasheet enrichment tools (LCSC integration)
- [x] 11 schematic inspection/editing tools (contributor: @Mehanik)
- [x] FFC/ribbon cable passthrough workflow (connect_passthrough, sync_schematic_to_board)
- [x] SVG logo import for PCB silkscreen
- [x] ERC validation
- [x] Project snapshot system
- [x] Freerouting autorouter integration with Docker/Podman (contributor: @jflaflamme)
- [x] Project-local library resolution
- [x] Developer mode (KICAD_MCP_DEV=1)
---
## Week 3: IPC Backend & Real-time Updates
## Current Focus: v2.3+
**Goal:** Eliminate manual reload - see changes instantly
**Status:** 🟢 **IMPLEMENTED** (2025-11-30)
### Documentation Overhaul (In Progress)
### High Priority
- [ ] Per-feature documentation for all 122 tools
- [ ] Architecture guide for contributors
- [ ] End-to-end PCB design workflow guide
- [ ] Documentation index
**1. IPC Connection****COMPLETE**
- [x] Establish socket connection to KiCAD
- [x] Handle connection errors gracefully
- [x] Auto-reconnect if KiCAD restarts
- [x] Fall back to SWIG if IPC unavailable
### Quality and Stability
**2. IPC Operations****COMPLETE**
- [x] Port project operations to IPC
- [x] Port board operations to IPC
- [x] Port component operations to IPC
- [x] Port routing operations to IPC
**3. Real-time UI Updates****COMPLETE**
- [x] Changes appear instantly in UI
- [x] No reload prompt
- [x] Visual feedback within 100ms
- [ ] Demo video showing real-time design
**Deliverable:** ✅ Design a board with live updates as Claude works
### Medium Priority
**4. Dual Backend Support****COMPLETE**
- [x] Auto-detect if IPC is available
- [x] Switch between SWIG/IPC seamlessly
- [x] Document when to use each
- [ ] Performance comparison
- [ ] Expand test coverage across all tool categories
- [ ] Performance profiling for large boards
- [ ] Update package.json version to match CHANGELOG
---
## Week 4-5: Smart BOM & Supplier Integration
## Planned Features
**Goal:** Optimize component selection for cost and availability
### Supplier Integration
**1. Digikey Integration**
- [ ] API authentication
- [ ] Part search by specs
- [ ] Price/stock checking
- [ ] Parametric search (e.g., "10k resistor, 0603, 1%")
- [ ] Digikey API integration
- [ ] Mouser API integration
- [ ] Smart BOM management with real-time pricing
- [ ] Cost optimization across suppliers
**2. Smart BOM Management**
- [ ] Auto-suggest component substitutions
- [ ] Calculate total board cost
- [ ] Check component availability
- [ ] Generate purchase links
### Design Patterns and Templates
**3. Cost Optimization**
- [ ] Suggest JLCPCB basic parts (free assembly)
- [ ] Warn about expensive/obsolete parts
- [ ] Batch component suggestions
- [ ] Circuit patterns library (voltage regulators, USB, microcontrollers)
- [ ] Board templates (Arduino shields, RPi HATs, Feather wings)
- [ ] Auto-suggest trace widths by current
- [ ] Impedance-controlled trace support
**Deliverable:** "Design a low-cost LED driver under $5 BOM"
### Advanced Capabilities
---
- [ ] Panelization support
- [ ] Multi-board project management
- [ ] High-speed design helpers (length matching, via stitching)
- [ ] SPICE simulation integration
## Week 6-7: Design Patterns & Templates
### Community and Education
**Goal:** Accelerate common design tasks
**1. Circuit Patterns Library**
- [ ] Voltage regulators (LDO, switching)
- [ ] USB interfaces (USB-C, micro-USB)
- [ ] Microcontroller circuits (ESP32, STM32, RP2040)
- [ ] Power protection (reverse polarity, ESD)
- [ ] Common interfaces (I2C, SPI, UART)
**2. Board Templates**
- [ ] Arduino form factors (Uno, Nano, Mega)
- [ ] Raspberry Pi HATs
- [ ] Feather wings
- [ ] Custom PCB shapes (badges, wearables)
**3. Auto-routing Helpers**
- [ ] Suggest trace widths by current
- [ ] Auto-create ground pours
- [ ] Match differential pair lengths
- [ ] Check impedance requirements
**Deliverable:** "Create an ESP32 dev board with USB-C"
---
## Week 8-9: Guided Workflows & Education
**Goal:** Make PCB design accessible to beginners
**1. Interactive Tutorials**
- [ ] First PCB (LED blinker)
- [ ] Understanding layers and vias
- [ ] Routing best practices
- [ ] Design rule checking
**2. Design Validation**
- [ ] Check for common mistakes
- [ ] Suggest improvements
- [ ] Explain DRC violations
- [ ] Manufacturing feasibility check
**3. Documentation Generation**
- [ ] Auto-generate assembly drawings
- [ ] Create BOM spreadsheets
- [ ] Export fabrication files
- [ ] Generate user manual
**Deliverable:** Complete beginner-to-fabrication tutorial
---
## Week 10-11: Advanced Features
**Goal:** Support complex professional designs
**1. Multi-board Projects**
- [ ] Panel designs for manufacturing
- [ ] Shared schematics across boards
- [ ] Version management
**2. High-speed Design**
- [ ] Impedance-controlled traces
- [ ] Length matching for DDR/PCIe
- [ ] Signal integrity analysis
- [ ] Via stitching for EMI
**3. Advanced Components**
- [ ] BGAs and fine-pitch packages
- [ ] Flex PCB support
- [ ] Rigid-flex designs
---
## Week 12: Polish & Release
**Goal:** Production-ready v2.0 release
**1. Performance**
- [ ] Optimize large board operations
- [ ] Cache library searches
- [ ] Parallel operations where possible
**2. Testing**
- [ ] Unit tests for all commands
- [ ] Integration tests for workflows
- [ ] Test on Windows/macOS/Linux
- [ ] Load testing with complex boards
**3. Documentation**
- [ ] Complete API reference
- [ ] Video tutorial series
- [ ] Blog post/announcement
- [ ] Example project gallery
**4. Community**
- [ ] Contribution guidelines
- [ ] Example project gallery with tutorials
- [ ] Video walkthrough series
- [ ] Interactive beginner tutorials
- [ ] Plugin system for custom tools
- [ ] Discord/forum for support
**Deliverable:** KiCAD MCP v2.0 stable release
---
## Future (Post-v2.0)
**Big Ideas for v3.0+**
**1. AI-Powered Design**
- Generate circuits from specifications
- Optimize layouts for size/cost/performance
- Suggest alternative designs
- Learn from user preferences
**2. Collaboration**
- Multi-user design sessions
- Design reviews and comments
- Version control integration (Git)
- Share design patterns
**3. Manufacturing Integration**
- Direct order to PCB fabs
- Assembly service integration
- Track order status
- Automated quoting
**4. Simulation**
- SPICE integration for circuit sim
- Thermal simulation
- Signal integrity
- Power integrity
**5. Extended Platform Support**
- Altium import/export
- Eagle compatibility
- EasyEDA integration
- Web-based viewer
---
## Success Metrics
**v2.0 Release Criteria:**
- [ ] 95%+ of commands working reliably
- [ ] Component placement with 10,000+ footprints
- [ ] IPC backend working on all platforms
- [ ] 10+ example projects
- [ ] 5+ video tutorials
- [ ] 100+ GitHub stars
- [ ] 10+ community contributors
**User Success Stories:**
- "Designed my first PCB with Claude Code in 30 minutes"
- "Cut PCB design time by 80% using MCP"
- "Got my board manufactured - it works!"
---
## How to Contribute
See the roadmap and want to help?
See the roadmap items above and want to help? High-value contributions:
**High-value contributions:**
1. Component library mappings (JLCPCB → KiCAD)
2. Design pattern library (circuits you use often)
3. Testing on Windows/macOS
4. Documentation and tutorials
5. Bug reports with reproductions
1. Testing on Windows/macOS with KiCAD 9
2. Example projects and workflow documentation
3. Bug reports with reproduction steps
4. New tool implementations (see [ARCHITECTURE.md](ARCHITECTURE.md))
5. Design pattern library contributions
Check [CONTRIBUTING.md](../CONTRIBUTING.md) for details.
---
**Last Updated:** 2025-11-30
**Maintained by:** KiCAD MCP Team
_Maintained by: KiCAD MCP Team and community contributors_

View File

@@ -2,7 +2,7 @@
## Overview
This document describes the router pattern implementation for the KiCAD MCP Server. The router reduces context window consumption from ~40K tokens (59 tools) to ~12K tokens (16 visible tools).
This document describes the router pattern implementation for the KiCAD MCP Server. The router reduces context window consumption by organizing 122+ tools into 8 discoverable categories, keeping only the most frequently used tools directly visible.
## Architecture Layers
@@ -29,14 +29,14 @@ This document describes the router pattern implementation for the KiCAD MCP Serv
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ ROUTED TOOLS (Hidden - 47) ││
│ │ ROUTED TOOLS (Hidden - 110+) ││
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ││
│ │ │ board │ │component │ │ export │ │ drc │ ││
│ │ │(9 tools) │ │(8 tools) │ │(8 tools) │ │(9 tools) │ ││
│ │ │ tools │ │ tools │ │ tools │ │ tools │ ││
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ ││
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ││
│ │ │schematic │ │ library │ │ routing │ ┌──────────┐ ││
│ │ │(9 tools) │ │(4 tools) │ │(3 tools) │ │ui (1 tool)│ ││
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ││
│ │ │schematic │ │ library │ │ routing │ │footprint │ ││
│ │ │ tools │ │ tools │ │ tools │ │ tools │ ││
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ ││
│ └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘
@@ -68,12 +68,14 @@ These cover the primary workflow (80%+ of use cases):
4. **UI Management** (1):
- `check_kicad_ui` - Check if KiCAD UI is running
### Routed Categories (7 categories, 47 tools)
### Routed Categories (8+ categories, 110+ tools)
#### 1. `board` - Board Configuration & Layout (9 tools)
Setup and configuration operations.
**Tools:**
- `add_layer` - Add PCB layer
- `set_active_layer` - Set active layer
- `get_layer_list` - List all layers
@@ -85,9 +87,11 @@ Setup and configuration operations.
- `launch_kicad_ui` - Launch KiCAD UI
#### 2. `component` - Advanced Component Operations (8 tools)
Beyond basic placement.
**Tools:**
- `rotate_component` - Rotate component
- `delete_component` - Delete component
- `edit_component` - Edit component properties
@@ -98,9 +102,11 @@ Beyond basic placement.
- `replace_component` - Replace component with another
#### 3. `export` - File Export & Manufacturing (8 tools)
Generate output files for fabrication and documentation.
**Tools:**
- `export_gerber` - Export Gerber files
- `export_pdf` - Export PDF
- `export_svg` - Export SVG
@@ -111,9 +117,11 @@ Generate output files for fabrication and documentation.
- `export_vrml` - Export VRML 3D model
#### 4. `drc` - Design Rules & Validation (9 tools)
Design rule checking and electrical validation.
**Tools:**
- `set_design_rules` - Configure design rules
- `get_design_rules` - Get current rules
- `run_drc` - Run design rule check
@@ -124,9 +132,11 @@ Design rule checking and electrical validation.
- `get_drc_violations` - Get DRC violations
#### 5. `schematic` - Schematic Operations (9 tools)
Schematic editor operations.
**Tools:**
- `create_schematic` - Create new schematic
- `add_schematic_component` - Add component to schematic
- `add_wire` - Add wire connection
@@ -137,18 +147,22 @@ Schematic editor operations.
- `generate_netlist` - Generate netlist
#### 6. `library` - Footprint Library Access (4 tools)
Search and browse footprint libraries.
**Tools:**
- `list_libraries` - List available libraries
- `search_footprints` - Search footprints
- `list_library_footprints` - List library footprints
- `get_footprint_info` - Get footprint details
#### 7. `routing` - Advanced Routing (3 tools)
Advanced routing operations beyond basic trace routing.
**Tools:**
- `add_via` - Add via
- `add_copper_pour` - Add copper pour
@@ -157,11 +171,13 @@ Advanced routing operations beyond basic trace routing.
## Router Tools
### 1. `list_tool_categories`
**Description:** List all available tool categories with descriptions and tool counts.
**Parameters:** None
**Returns:**
```json
{
"total_categories": 7,
@@ -171,19 +187,22 @@ Advanced routing operations beyond basic trace routing.
"name": "board",
"description": "Board configuration: layers, mounting holes, zones, visualization",
"tool_count": 9
},
}
// ... more categories
]
}
```
### 2. `get_category_tools`
**Description:** Get detailed information about all tools in a specific category.
**Parameters:**
- `category` (string) - Category name from `list_tool_categories`
**Returns:**
```json
{
"category": "export",
@@ -192,29 +211,36 @@ Advanced routing operations beyond basic trace routing.
{
"name": "export_gerber",
"description": "Export Gerber files for PCB fabrication",
"parameters": { /* zod schema */ }
},
"parameters": {
/* zod schema */
}
}
// ... more tools
]
}
```
### 3. `execute_tool`
**Description:** Execute a tool from any category.
**Parameters:**
- `tool_name` (string) - Tool name from `get_category_tools`
- `params` (object, optional) - Tool parameters
**Returns:** Tool execution result
### 4. `search_tools`
**Description:** Search for tools by keyword across all categories.
**Parameters:**
- `query` (string) - Search term (e.g., "gerber", "zone", "export")
**Returns:**
```json
{
"query": "export",
@@ -224,7 +250,7 @@ Advanced routing operations beyond basic trace routing.
"category": "export",
"tool": "export_gerber",
"description": "Export Gerber files for PCB fabrication"
},
}
// ... more matches
]
}
@@ -261,21 +287,25 @@ Advanced routing operations beyond basic trace routing.
## Migration Strategy
### Phase 1: Create Infrastructure
1. Create `registry.ts` with all tool definitions
2. Create `router.ts` with router tools
3. Create `direct.ts` with direct tool list
### Phase 2: Update Server
1. Modify server registration to use direct + router only
2. Keep all existing tool handlers intact
3. Route through `execute_tool`
### Phase 3: Testing
1. Test direct tools work as before
2. Test router tools (list/get/execute/search)
3. Test routed tools via `execute_tool`
### Phase 4: Optimization (Optional)
1. Add caching for tool lookups
2. Add tool usage analytics
3. Implement intelligent tool suggestions

View File

@@ -2,15 +2,17 @@
## What is the Router?
The KiCAD MCP Server now includes an intelligent tool router that organizes 59 tools into 7 discoverable categories. This reduces AI context usage by up to 70% while maintaining full access to all functionality.
The KiCAD MCP Server includes an intelligent tool router that organizes 122+ tools into 8 discoverable categories. This reduces AI context usage by up to 70% while maintaining full access to all functionality.
## How It Works
Instead of loading all 59 tool schemas into every conversation, Claude now sees:
- **12 direct tools** for high-frequency operations (always visible)
- **4 router tools** for discovering and executing the other 47 tools
When you ask Claude to do something (like "export gerber files"), it will:
1. Search for relevant tools using `search_tools`
2. Find the `export_gerber` tool in the "export" category
3. Execute it via `execute_tool` with your parameters
@@ -20,45 +22,59 @@ When you ask Claude to do something (like "export gerber files"), it will:
## Tool Categories
The 47 routed tools are organized into these categories:
The 110+ routed tools are organized into these categories:
### 1. board (9 tools)
Board configuration: layers, mounting holes, zones, visualization
- add_layer, set_active_layer, get_layer_list
- add_mounting_hole, add_board_text
- add_zone, get_board_extents, get_board_2d_view
- launch_kicad_ui
### 2. component (8 tools)
Advanced component operations: edit, delete, search, group, annotate
- rotate_component, delete_component, edit_component
- find_component, get_component_properties
- add_component_annotation, group_components, replace_component
### 3. export (8 tools)
File export for fabrication and documentation
- export_gerber, export_pdf, export_svg, export_3d
- export_bom, export_netlist, export_position_file, export_vrml
### 4. drc (8 tools)
Design rule checking and electrical validation
- set_design_rules, get_design_rules, run_drc
- add_net_class, assign_net_to_class, set_layer_constraints
- check_clearance, get_drc_violations
### 5. schematic (8 tools)
Schematic operations: create, add components, wire connections
- create_schematic, add_schematic_component, add_wire
- add_schematic_connection, add_schematic_net_label
- connect_to_net, get_net_connections, generate_netlist
### 6. library (4 tools)
Footprint library access and search
- list_libraries, search_footprints
- list_library_footprints, get_footprint_info
### 7. routing (2 tools)
Advanced routing operations
- add_via, add_copper_pour
## Direct Tools (Always Available)
@@ -66,47 +82,58 @@ Advanced routing operations
These 12 tools are always visible for common operations:
**Project Lifecycle:**
- create_project, open_project, save_project, get_project_info
**Core PCB Operations:**
- place_component, move_component
- add_net, route_trace
- get_board_info, set_board_size
- add_board_outline
**UI Management:**
- check_kicad_ui
## Router Tools
### list_tool_categories
Browse all available tool categories.
**Example:**
```
Claude, what tool categories are available?
```
### get_category_tools
View all tools in a specific category.
**Example:**
```
Show me all export tools available.
```
### search_tools
Find tools by keyword.
**Example:**
```
Search for tools related to "gerber" or "mounting holes"
```
### execute_tool
Execute any routed tool with parameters.
**Example:**
```
Execute the export_gerber tool with outputDir set to ./fabrication
```
@@ -114,6 +141,7 @@ Execute the export_gerber tool with outputDir set to ./fabrication
## Usage Examples
### Natural Interaction (Recommended)
Just ask Claude what you want - it handles discovery automatically:
```
@@ -124,6 +152,7 @@ Just ask Claude what you want - it handles discovery automatically:
```
### Manual Discovery (Optional)
You can also browse tools explicitly:
```
@@ -147,6 +176,7 @@ You can also browse tools explicitly:
- **Server Integration**: `src/server.ts` - Router tools registered at startup
For implementation details, see:
- [ROUTER_ARCHITECTURE.md](ROUTER_ARCHITECTURE.md) - Design specification
- [ROUTER_IMPLEMENTATION_STATUS.md](ROUTER_IMPLEMENTATION_STATUS.md) - Current status
- [TOOL_INVENTORY.md](TOOL_INVENTORY.md) - Complete tool catalog
@@ -154,15 +184,14 @@ For implementation details, see:
## Token Savings
**Before Router:**
- 59 tools × ~700 tokens each = ~42K tokens per conversation
**After Router (Current - Phase 1):**
- 122 tools × ~700 tokens each = ~85K tokens per conversation
**After Router (Current):**
- 12 direct tools + 4 router tools = 16 tools visible
- Still ~42K tokens (all tools still registered for backwards compatibility)
- Routed tools discovered on-demand
- ~12-15K tokens per conversation
- **~80% reduction** in context usage
**After Phase 2 (Optional Optimization):**
- Only 16 tools visible to Claude
- ~12K tokens per conversation
- **70% reduction** in context usage
Phase 1 is complete and functional. Phase 2 (hiding routed tools) is optional and can be implemented when desired.
The router pattern is complete and functional, providing efficient tool discovery while maintaining full access to all 122+ tools.

View File

@@ -0,0 +1,557 @@
# Routing Tools Reference
Added in: v1.0.0, major expansion in v2.2.0-v2.2.3 (PR #44, @Kletternaut)
This document provides comprehensive documentation for the 13 routing tools available in the KiCAD MCP Server. These tools cover basic trace routing, advanced operations like differential pairs, net management, trace operations, and copper zone management.
## Basic Routing (3 tools)
### add_net
Create a new net on the PCB.
**Parameters:**
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | -------------- |
| name | string | Yes | Net name |
| netClass | string | No | Net class name |
**Usage Notes:**
- Creates a new net that can be assigned to traces and pads
- If the net already exists, it will be reused
- Net class assignment is optional; defaults to "Default" if not specified
**Example:**
```json
{
"name": "VCC_3V3",
"netClass": "Power"
}
```
---
### route_trace
Route a trace segment between two XY points on a fixed layer.
**Parameters:**
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ------------------------------------------- |
| start | object | Yes | Start position with x, y, and optional unit |
| end | object | Yes | End position with x, y, and optional unit |
| layer | string | Yes | PCB layer |
| width | number | Yes | Trace width in mm |
| net | string | Yes | Net name |
**Usage Notes:**
- WARNING: Does NOT handle layer changes
- If start and end are on different copper layers, use `route_pad_to_pad` instead, which automatically inserts a via
- Coordinates use mm by default unless unit is specified
- This is a low-level tool; prefer `route_pad_to_pad` for component-to-component routing
**Example:**
```json
{
"start": { "x": 100.0, "y": 50.0, "unit": "mm" },
"end": { "x": 120.0, "y": 50.0, "unit": "mm" },
"layer": "F.Cu",
"width": 0.25,
"net": "GND"
}
```
---
### route_pad_to_pad
PREFERRED tool for pad-to-pad routing. Looks up pad positions automatically, detects the net from the pad, and automatically inserts a via if the two pads are on different copper layers.
**Parameters:**
| Parameter | Type | Required | Description |
| --------- | ------------- | -------- | ---------------------------------------------------- |
| fromRef | string | Yes | Reference of the source component (e.g. 'U2') |
| fromPad | string/number | Yes | Pad number on the source component (e.g. '6' or 6) |
| toRef | string | Yes | Reference of the target component (e.g. 'U1') |
| toPad | string/number | Yes | Pad number on the target component (e.g. '15' or 15) |
| layer | string | No | PCB layer (default: F.Cu) |
| width | number | No | Trace width in mm (default: board default) |
| net | string | No | Net name override (default: auto-detected from pad) |
**Usage Notes:**
- This is the PREFERRED tool for routing between component pads
- Automatically looks up pad positions - no need to query them separately
- Auto-detects the net from the source pad
- Critically: if pads are on different copper layers (e.g., one on F.Cu and one on B.Cu), automatically inserts a via at an appropriate position to complete the connection
- Always use this instead of `route_trace` when routing between named component pads
- Via is placed at the start pad's X coordinate to avoid stacking issues with back-to-back mirrored connectors
**Example:**
```json
{
"fromRef": "U2",
"fromPad": "6",
"toRef": "U1",
"toPad": "15",
"width": 0.25
}
```
---
## Vias (1 tool)
### add_via
Add a via to the PCB.
**Parameters:**
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ----------------------------------------- |
| position | object | Yes | Via position with x, y, and optional unit |
| net | string | Yes | Net name |
| viaType | string | No | Via type: "through", "blind", or "buried" |
**Usage Notes:**
- Through vias connect all layers (default)
- Blind vias connect an outer layer to one or more inner layers
- Buried vias connect two or more inner layers without reaching outer layers
- Position coordinates use mm by default
**Example:**
```json
{
"position": { "x": 110.0, "y": 50.0, "unit": "mm" },
"net": "GND",
"viaType": "through"
}
```
---
## Advanced Routing (2 tools)
### route_differential_pair
Route a differential pair between two sets of points.
**Parameters:**
| Parameter | Type | Required | Description |
| ----------- | ------ | -------- | ------------------------------------------ |
| positivePad | object | Yes | Positive pad with reference and pad number |
| negativePad | object | Yes | Negative pad with reference and pad number |
| layer | string | Yes | PCB layer |
| width | number | Yes | Trace width in mm |
| gap | number | Yes | Gap between traces in mm |
| positiveNet | string | Yes | Positive net name |
| negativeNet | string | Yes | Negative net name |
**Usage Notes:**
- Used for high-speed signals like USB, Ethernet, HDMI, etc.
- Maintains controlled impedance through consistent trace width and gap
- Both traces are routed in parallel with specified separation
- Pad object format: `{"reference": "U1", "pad": "1"}`
**Example:**
```json
{
"positivePad": { "reference": "J1", "pad": "2" },
"negativePad": { "reference": "J1", "pad": "3" },
"layer": "F.Cu",
"width": 0.2,
"gap": 0.2,
"positiveNet": "USB_DP",
"negativeNet": "USB_DN"
}
```
---
### copy_routing_pattern
Copy routing pattern (traces and vias) from a group of source components to a matching group of target components.
**Parameters:**
| Parameter | Type | Required | Description |
| ----------- | ------------- | -------- | ----------------------------------------------------------------------------------------- |
| sourceRefs | array[string] | Yes | References of the source components (e.g. ['U1', 'R1', 'C1']) |
| targetRefs | array[string] | Yes | References of the target components in same order as sourceRefs (e.g. ['U2', 'R2', 'C2']) |
| includeVias | boolean | No | Also copy vias (default: true) |
| traceWidth | number | No | Override trace width in mm (default: keep original width) |
**Usage Notes:**
- The offset is calculated automatically from the position difference between the first source and first target component
- Useful for replicating routing between identical circuit blocks
- Component arrays must be in matching order (sourceRefs[0] maps to targetRefs[0], etc.)
- Preserves relative routing topology from source to target
- Vias are copied by default unless includeVias is set to false
- Original trace widths are preserved unless traceWidth override is specified
**Example:**
```json
{
"sourceRefs": ["U1", "R1", "C1"],
"targetRefs": ["U2", "R2", "C2"],
"includeVias": true
}
```
---
## Net Management (2 tools)
### get_nets_list
Get a list of all nets in the PCB with optional statistics.
**Parameters:**
| Parameter | Type | Required | Description |
| ------------ | ------- | -------- | ---------------------------------------------------- |
| includeStats | boolean | No | Include statistics (track count, total length, etc.) |
| unit | string | No | Unit for length measurements: "mm" or "inch" |
**Usage Notes:**
- Returns all nets present in the board
- Statistics include track count, via count, and total trace length
- Useful for verifying net connectivity and routing completeness
- Length measurements default to mm
**Example:**
```json
{
"includeStats": true,
"unit": "mm"
}
```
---
### create_netclass
Create a new net class with custom design rules.
**Parameters:**
| Parameter | Type | Required | Description |
| ----------- | ------ | -------- | ------------------------- |
| name | string | Yes | Net class name |
| traceWidth | number | No | Default trace width in mm |
| clearance | number | No | Clearance in mm |
| viaDiameter | number | No | Via diameter in mm |
| viaDrill | number | No | Via drill size in mm |
**Usage Notes:**
- Net classes define design rules for groups of nets
- Common use cases: power nets (wider traces), high-speed signals (controlled impedance)
- Once created, assign nets to the class using the netClass parameter in `add_net`
- All measurements in mm
**Example:**
```json
{
"name": "Power",
"traceWidth": 0.5,
"clearance": 0.3,
"viaDiameter": 0.8,
"viaDrill": 0.4
}
```
---
## Trace Operations (3 tools)
### delete_trace
Delete traces from the PCB. Can delete by UUID, position, or bulk-delete all traces on a net.
**Parameters:**
| Parameter | Type | Required | Description |
| ----------- | ------- | -------- | ----------------------------------------------------------- |
| traceUuid | string | No | UUID of a specific trace to delete |
| position | object | No | Delete trace nearest to this position (x, y, optional unit) |
| net | string | No | Delete all traces on this net (bulk delete) |
| layer | string | No | Filter by layer when using net-based deletion |
| includeVias | boolean | No | Include vias in net-based deletion |
**Usage Notes:**
- Three deletion modes: by UUID (specific), by position (nearest), or by net (bulk)
- Position-based deletion finds the closest trace to the specified coordinates
- Net-based deletion can be filtered by layer
- Vias are excluded from net-based deletion by default unless includeVias is true
**Example (bulk delete):**
```json
{
"net": "GND",
"layer": "F.Cu",
"includeVias": false
}
```
---
### query_traces
Query traces on the board with optional filters by net, layer, or bounding box.
**Parameters:**
| Parameter | Type | Required | Description |
| ----------- | ------ | -------- | ------------------------------------------------------------- |
| net | string | No | Filter by net name |
| layer | string | No | Filter by layer name |
| boundingBox | object | No | Filter by bounding box region (x1, y1, x2, y2, optional unit) |
| unit | string | No | Unit for coordinates: "mm" or "inch" |
**Usage Notes:**
- Returns trace information including UUID, position, width, layer, and net
- Filters can be combined (e.g., specific net on specific layer)
- Bounding box uses rectangular region defined by opposite corners
- Useful for analyzing routing in specific board regions or on specific nets
**Example:**
```json
{
"net": "VCC_3V3",
"layer": "F.Cu"
}
```
---
### modify_trace
Modify an existing trace (change width, layer, or net).
**Parameters:**
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | --------------------------- |
| traceUuid | string | Yes | UUID of the trace to modify |
| width | number | No | New trace width in mm |
| layer | string | No | New layer name |
| net | string | No | New net name |
**Usage Notes:**
- Requires the trace UUID, which can be obtained from `query_traces`
- At least one modification parameter (width, layer, or net) must be provided
- Use with caution when changing nets - ensure electrical correctness
- Width changes are useful for adjusting impedance or current capacity
**Example:**
```json
{
"traceUuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"width": 0.5
}
```
---
## Copper Zones (2 tools)
### add_copper_pour
Add a copper pour (ground/power plane) to the PCB.
**Parameters:**
| Parameter | Type | Required | Description |
| --------- | ------------- | -------- | ----------------------------------------------------------------------------------------- |
| layer | string | Yes | PCB layer |
| net | string | Yes | Net name |
| clearance | number | No | Clearance in mm |
| outline | array[object] | No | Array of {x, y} points defining the pour boundary. If omitted, the board outline is used. |
**Usage Notes:**
- Copper pours are typically used for ground and power planes
- If no outline is specified, the pour fills the entire board area
- Custom outlines are defined as arrays of coordinate points
- Clearance defines the minimum distance from other copper features
- After adding a pour, use `refill_zones` to fill it
**Example:**
```json
{
"layer": "B.Cu",
"net": "GND",
"clearance": 0.2,
"outline": [
{ "x": 10.0, "y": 10.0 },
{ "x": 90.0, "y": 10.0 },
{ "x": 90.0, "y": 60.0 },
{ "x": 10.0, "y": 60.0 }
]
}
```
---
### refill_zones
Refill all copper zones on the board.
**Parameters:**
None
**Usage Notes:**
- WARNING: SWIG path has known segfault risk (see KNOWN_ISSUES.md)
- Prefer using IPC backend (KiCAD open) or triggering zone fill via KiCAD UI instead
- Required after adding or modifying copper pours to calculate the filled areas
- Recalculates all zone fills based on current board state
- May take several seconds on complex boards with many zones
**Example:**
```json
{}
```
---
## Example Workflows
### Point-to-Point Routing with route_pad_to_pad
The simplest and most robust approach for connecting component pads:
```json
// Connect pin 1 of U1 to pin 5 of R1
{
"tool": "route_pad_to_pad",
"params": {
"fromRef": "U1",
"fromPad": "1",
"toRef": "R1",
"toPad": "5",
"width": 0.25
}
}
```
This automatically:
- Looks up the exact pad positions
- Detects the net from the pads
- Creates the trace on the appropriate layer
- Inserts a via if the pads are on different copper layers
### Differential Pair Routing (USB, Ethernet)
For high-speed differential signals like USB D+ and D-:
```json
// 1. Create nets if needed
{
"tool": "add_net",
"params": {"name": "USB_DP"}
}
{
"tool": "add_net",
"params": {"name": "USB_DN"}
}
// 2. Route the differential pair
{
"tool": "route_differential_pair",
"params": {
"positivePad": {"reference": "U1", "pad": "14"},
"negativePad": {"reference": "U1", "pad": "15"},
"layer": "F.Cu",
"width": 0.2,
"gap": 0.2,
"positiveNet": "USB_DP",
"negativeNet": "USB_DN"
}
}
```
### Replicating Routing Patterns
For repeated circuit blocks (e.g., multiple identical LED drivers):
```json
// Route the first instance (U1, R1, C1) manually, then copy to others
{
"tool": "copy_routing_pattern",
"params": {
"sourceRefs": ["U1", "R1", "C1"],
"targetRefs": ["U2", "R2", "C2"],
"includeVias": true
}
}
// Copy the same pattern to a third instance
{
"tool": "copy_routing_pattern",
"params": {
"sourceRefs": ["U1", "R1", "C1"],
"targetRefs": ["U3", "R3", "C3"],
"includeVias": true
}
}
```
### Adding a Ground Plane
```json
// 1. Create the copper pour on bottom layer
{
"tool": "add_copper_pour",
"params": {
"layer": "B.Cu",
"net": "GND",
"clearance": 0.2
}
}
// 2. Fill the zones
{
"tool": "refill_zones",
"params": {}
}
```
Note: Use the IPC backend (keep KiCAD open) when using refill_zones to avoid potential segfaults with the SWIG backend.
---
## Source Files
- **TypeScript Tool Definitions**: `/home/chris/MCP/KiCAD-MCP-Server/src/tools/routing.ts`
- **Python Implementation**: `/home/chris/MCP/KiCAD-MCP-Server/python/commands/routing.py`

View File

@@ -0,0 +1,440 @@
# Schematic Tools Reference
Added in: v2.1.0, expanded in v2.2.0-v2.2.3
Contributors: @Mehanik (PRs #60, #66), @Kletternaut (PR #57)
This document provides a complete reference for the 27 schematic tools in the KiCAD MCP Server. These tools enable a complete schematic design workflow, from creating projects and adding components to wiring, validation, and synchronization with PCB boards. The dynamic symbol loading feature provides access to approximately 10,000 standard KiCad symbols.
## Component Operations (8 tools)
### add_schematic_component
Add a component to the schematic. Symbol format is 'Library:SymbolName' (e.g., 'Device:R', 'EDA-MCP:ESP32-C3').
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | ---------------------------------------------------------------- |
| schematicPath | string | Yes | Path to the schematic file |
| symbol | string | Yes | Symbol library:name reference (e.g., Device:R, EDA-MCP:ESP32-C3) |
| reference | string | Yes | Component reference (e.g., R1, U1) |
| value | string | No | Component value |
| footprint | string | No | KiCAD footprint (e.g. Resistor_SMD:R_0603_1608Metric) |
| position | object | No | Position on schematic with x and y coordinates |
**Usage Notes:** The dynamic symbol loader provides access to ~10,000 KiCad standard symbols. If a symbol is not in the static template map, it will be loaded dynamically from the specified library.
### delete_schematic_component
Remove a placed symbol from a KiCAD schematic (.kicad_sch). This removes the symbol instance (the placed component) from the schematic. It does NOT remove the symbol definition from lib_symbols. Note: This tool operates on schematic files (.kicad_sch). To remove a footprint from a PCB, use delete_component instead.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | ------------------------------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch file |
| reference | string | Yes | Reference designator of the component to remove (e.g. R1, U3) |
### edit_schematic_component
Update properties of a placed symbol in a KiCAD schematic (.kicad_sch) in-place. Use this tool to assign or update a footprint, change the value, or rename the reference of an already-placed component. This is more efficient than delete + re-add because it preserves the component's position and UUID. Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_component.
| Parameter | Type | Required | Description |
| -------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch file |
| reference | string | Yes | Current reference designator of the component (e.g. R1, U3) |
| footprint | string | No | New KiCAD footprint string (e.g. Resistor_SMD:R_0603_1608Metric) |
| value | string | No | New value string (e.g. 10k, 100nF) |
| newReference | string | No | Rename the reference designator (e.g. R1 → R10) |
| fieldPositions | object | No | Reposition field labels: map of field name to {x, y, angle} (e.g. {"Reference": {"x": 12.5, "y": 17.0}}) |
### get_schematic_component
Get full component info from a schematic: position, field values, and each field's label position (at x/y/angle). Use this to inspect or prepare repositioning of Reference/Value labels.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | -------------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch file |
| reference | string | Yes | Component reference designator (e.g. R1, U1) |
### list_schematic_components
List all components in a schematic with their references, values, positions, and pins. Essential for inspecting what's on the schematic before making edits.
| Parameter | Type | Required | Description |
| ---------------------- | ------ | -------- | --------------------------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch file |
| filter | object | No | Optional filters with libId and/or referencePrefix fields |
| filter.libId | string | No | Filter by library ID (e.g., 'Device:R') |
| filter.referencePrefix | string | No | Filter by reference prefix (e.g., 'R', 'C', 'U') |
### move_schematic_component
Move a placed symbol to a new position in the schematic.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | ------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch file |
| reference | string | Yes | Reference designator (e.g., R1, U1) |
| position | object | Yes | New position with x and y coordinates |
### rotate_schematic_component
Rotate a placed symbol in the schematic.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | ------------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch file |
| reference | string | Yes | Reference designator (e.g., R1, U1) |
| angle | number | Yes | Rotation angle in degrees (0, 90, 180, 270) |
| mirror | enum | No | Optional mirror axis ("x" or "y") |
### annotate_schematic
Assign reference designators to unannotated components (R? → R1, R2, ...). Must be called before tools that require known references.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | --------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch file |
## Wiring and Connections (8 tools)
### add_wire
Add a wire connection in the schematic.
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | --------------------------------------- |
| start | object | Yes | Start position with x and y coordinates |
| end | object | Yes | End position with x and y coordinates |
### add_schematic_connection
Connect two component pins with a wire. Use this for individual connections between components with different pin roles (e.g. U1.SDA → J3.2). WARNING: Do NOT use this in a loop to wire N passthrough pins — use connect_passthrough instead (single call, cleaner layout, far fewer tokens).
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | ---------------------------------------- |
| schematicPath | string | Yes | Path to the schematic file |
| sourceRef | string | Yes | Source component reference (e.g., R1) |
| sourcePin | string | Yes | Source pin name/number (e.g., 1, 2, GND) |
| targetRef | string | Yes | Target component reference (e.g., C1) |
| targetPin | string | Yes | Target pin name/number (e.g., 1, 2, VCC) |
### add_schematic_net_label
Add a net label to the schematic.
**Preferred usage — snap to pin:** supply `componentRef` + `pinNumber` and the label is placed at the exact pin endpoint resolved by `PinLocator`. This guarantees an electrical connection. A 0.01 mm offset is enough to break the connection in KiCad, so this mode eliminates all guesswork.
**Alternative — explicit position:** supply `position [x, y]`. The coordinates must match a pin or wire endpoint exactly; use `get_schematic_pin_locations` first to obtain them.
| Parameter | Type | Required | Description |
| ------------- | -------------- | -------- | ---------------------------------------------------------------------- |
| schematicPath | string | Yes | Path to the schematic file |
| netName | string | Yes | Name of the net (e.g., VCC, GND, SIGNAL_1) |
| position | array [x, y] | No\* | Explicit position. Required when `componentRef`/`pinNumber` not given. |
| componentRef | string | No\* | Component reference to snap to (e.g. U1). Use with `pinNumber`. |
| pinNumber | string\|number | No\* | Pin number or name (e.g. `"1"`, `"GND"`). Use with `componentRef`. |
| labelType | string | No | `label` (default), `global_label`, or `hierarchical_label` |
| orientation | number | No | Rotation angle: 0, 90, 180, 270 (default: 0) |
\* Either `position` **or** (`componentRef` + `pinNumber`) is required.
**Response fields:**
| Field | Description |
| --------------- | ------------------------------------------------------------ |
| success | `true` / `false` |
| actual_position | `[x, y]` coordinates where the label was actually placed |
| snapped_to_pin | `{component, pin}` — present only when pin-snapping was used |
| message | Human-readable status |
### connect_to_net
Connect a component pin to a named net by adding a wire stub from the pin endpoint and placing a net label at the stub's far end. The exact pin coordinates are resolved internally via `PinLocator`.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | ---------------------------------- |
| schematicPath | string | Yes | Path to the schematic file |
| componentRef | string | Yes | Component reference (e.g., U1, R1) |
| pinName | string | Yes | Pin name/number to connect |
| netName | string | Yes | Name of the net to connect to |
**Response fields:**
| Field | Description |
| -------------- | ------------------------------------------ |
| success | `true` / `false` |
| pin_location | `[x, y]` exact pin endpoint used |
| label_location | `[x, y]` where the net label was placed |
| wire_stub | `[[x1,y1],[x2,y2]]` the wire segment added |
| message | Human-readable status |
**Usage Notes:** Creates a wire stub from the pin and places a net label at the stub endpoint. The stub direction follows the pin's outward angle. Default stub length is 2.54 mm (0.1 inch, standard grid spacing). Check `pin_location` in the response to confirm the correct pin was found; no separate verification call is needed.
### connect_passthrough
Connects all pins of a source connector (e.g. J1) to matching pins of a target connector (e.g. J2) via shared net labels — pin N gets net '{netPrefix}\_{N}'. Use this for FFC/ribbon cable passthrough adapters instead of calling connect_to_net for every pin.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | --------------------------------------------------------- |
| schematicPath | string | Yes | Path to the schematic file |
| sourceRef | string | Yes | Source connector reference (e.g. J1) |
| targetRef | string | Yes | Target connector reference (e.g. J2) |
| netPrefix | string | No | Net name prefix, e.g. 'CSI' → CSI_1, CSI_2 (default: PIN) |
| pinOffset | number | No | Add to pin number when building net name (default: 0) |
**Usage Notes:** This is the most efficient way to wire passthrough adapters. For an N-pin connector, this replaces N individual connect_to_net calls with a single operation.
### get_schematic_pin_locations
Returns the exact x/y coordinates of every pin on a schematic component. Useful for inspection or when building custom placement logic. When the goal is to connect a pin to a net, prefer `add_schematic_net_label` with `componentRef`+`pinNumber` (which calls this internally) or `connect_to_net` — both snap to the exact pin endpoint automatically.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | ------------------------------------------------ |
| schematicPath | string | Yes | Path to the schematic file |
| reference | string | Yes | Component reference designator (e.g. U1, R1, J2) |
### delete_schematic_wire
Remove a wire from the schematic by start and end coordinates.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | -------------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch file |
| start | object | Yes | Wire start position with x and y coordinates |
| end | object | Yes | Wire end position with x and y coordinates |
### delete_schematic_net_label
Remove a net label from the schematic.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | -------------------------------------------------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch file |
| netName | string | Yes | Name of the net label to remove |
| position | object | No | Position to disambiguate if multiple labels with same name (x and y coordinates) |
## Net Analysis (5 tools)
### get_net_connections
Get all connections for a named net.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | -------------------------- |
| schematicPath | string | Yes | Path to the schematic file |
| netName | string | Yes | Name of the net to query |
**Usage Notes:** Uses wire graph analysis to find all component pins connected to the specified net. Returns a list of {component, pin} pairs.
### list_schematic_nets
List all nets in the schematic with their connections.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | --------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch file |
### list_schematic_wires
List all wires in the schematic with start/end coordinates.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | --------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch file |
### list_schematic_labels
List all net labels, global labels, and power flags in the schematic.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | --------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch file |
### get_net_at_point
Return the net name at a given (x, y) coordinate, or `null` if no net label or wire endpoint is present there.
Checks net label / power symbol positions first (exact IU match), then wire endpoints. Faster than `get_wire_connections` when you only need the net name and not full pin traversal.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | ------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch schematic file |
| x | number | Yes | X coordinate in mm |
| y | number | Yes | Y coordinate in mm |
**Response fields:**
| Field | Description |
| -------- | ----------------------------------------------------------------------- |
| net_name | Net label string, or `null` if no net found at this point |
| position | `{"x": float, "y": float}` — echoes the query coordinates |
| source | `"net_label"` \| `"wire_endpoint"` \| `null` — how the net was resolved |
## Schematic Creation and Export (6 tools)
### create_schematic
Create a new schematic.
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | -------------- |
| name | string | Yes | Schematic name |
| path | string | No | Optional path |
### export_schematic_svg
Export schematic to SVG format using kicad-cli.
| Parameter | Type | Required | Description |
| ------------- | ------- | -------- | --------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch file |
| outputPath | string | Yes | Output SVG file path |
| blackAndWhite | boolean | No | Export in black and white |
### export_schematic_pdf
Export schematic to PDF format using kicad-cli.
| Parameter | Type | Required | Description |
| ------------- | ------- | -------- | --------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch file |
| outputPath | string | Yes | Output PDF file path |
| blackAndWhite | boolean | No | Export in black and white |
### get_schematic_view
Return a rasterized image of the schematic (PNG by default, or SVG). Uses kicad-cli to export SVG, then converts to PNG via cairosvg. Use this for visual feedback after placing or wiring components.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | -------------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch file |
| format | enum | No | Output format ("png" or "svg", default: png) |
| width | number | No | Image width in pixels (default: 1200) |
| height | number | No | Image height in pixels (default: 900) |
### generate_netlist
Return a structured JSON netlist from the schematic for programmatic use. Uses `kicad-cli` internally — the schematic file must be saved to disk first.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | ---------------------------------------------- |
| schematicPath | string | Yes | Absolute path to the .kicad_sch schematic file |
**Returns:** `{ components: [{reference, value, footprint}], nets: [{name, connections: [{component, pin}]}] }`
**Usage Notes:** Use this when you need net membership data in the conversation (e.g., to verify connectivity). For writing a netlist to a file or exporting SPICE/Cadstar/OrcadPCB2 format, use `export_netlist` instead.
### export_netlist
Export a netlist to a file in a standard EDA format using `kicad-cli`. Supports SPICE (for simulation), KiCad XML (for archiving/import), Cadstar, and OrcadPCB2.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | ------------------------------------------------------------ |
| schematicPath | string | Yes | Absolute path to the .kicad_sch schematic file |
| outputPath | string | Yes | Absolute path for the output file (e.g. `/tmp/design.spice`) |
| format | enum | No | `KiCad` (default), `Spice`, `Cadstar`, `OrcadPCB2` |
**Usage Notes:** The schematic file must be saved before calling this tool. Use `Spice` format to produce a SPICE netlist for simulation or diff against a reference. The output file is created or overwritten at `outputPath`.
## Validation and Synchronization (6 tools)
### list_floating_labels
Return all net labels that are not connected to any component pin.
A label is "floating" when no component pin's coordinate falls on the wire-network reachable from the label's anchor position. Floating labels indicate misplaced or off-grid labels that will cause ERC errors. Does not require the KiCAD UI to be running.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | ------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch schematic file |
**Response fields:** list of `{"name": str, "x": float, "y": float, "type": "label" | "global_label"}`.
### find_orphaned_wires
Find wire segments with at least one dangling endpoint — not connected to a component pin, net label, or another wire. Orphaned wires cause ERC "wire end unconnected" errors. Does not require the KiCAD UI to be running.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | ------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch schematic file |
**Response fields:**
| Field | Description |
| -------------- | ---------------------------------------------------------------------------- |
| orphaned_wires | List of `{"start": {x,y}, "end": {x,y}, "dangling_ends": [{x,y}, ...]}` (mm) |
| count | Total number of orphaned wire segments |
### snap_to_grid
Snap schematic element coordinates to the nearest grid point. KiCAD uses exact integer matching (10 000 IU/mm) internally, so even a sub-pixel offset makes wires appear connected visually while failing ERC. Run this before `run_erc` to eliminate that class of error. Modifies the `.kicad_sch` file in place. Does not require the KiCAD UI to be running.
| Parameter | Type | Required | Description |
| ------------- | --------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch schematic file |
| gridSize | number | No | Grid spacing in mm (default: 2.54 — standard KiCAD schematic grid; use 1.27 for high-density) |
| elements | array\<string\> | No | Types to snap: `"wires"`, `"junctions"`, `"labels"`, `"components"`. Default: `["wires", "junctions", "labels"]`. `"components"` is opt-in — moving a component without re-routing its wires creates new mismatches. |
**Response fields:**
| Field | Description |
| --------------- | --------------------------------------------------------- |
| snapped | Number of elements that had at least one coordinate moved |
| already_on_grid | Number of elements already on the grid |
| grid_size | Grid spacing used (mm) |
### run_erc
Runs the KiCAD Electrical Rules Check (ERC) on a schematic and returns all violations. Use after wiring to verify the schematic before generating a netlist.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | ------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch schematic file |
**Usage Notes:** Returns violations categorized by severity (error, warning, info) with location coordinates. Essential for catching design errors before PCB layout.
### sync_schematic_to_board
Import the schematic netlist into the PCB board — equivalent to pressing F8 in KiCAD (Tools → Update PCB from Schematic). MUST be called after the schematic is complete and before placing or routing components on the PCB. Without this step, the board has no footprints and no net assignments — place_component and route_pad_to_pad will produce an empty, unroutable board.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | ---------------------------------------------- |
| schematicPath | string | Yes | Absolute path to the .kicad_sch schematic file |
| boardPath | string | Yes | Absolute path to the .kicad_pcb board file |
**Usage Notes:** This is the F8 equivalent. It synchronizes the schematic design to the PCB, creating footprints on the board and assigning nets. This step is critical in the workflow: design in schematic → sync_schematic_to_board → place and route on PCB.
## Example Workflows
### Basic Circuit Design
1. **Create project:** Use `create_schematic` to initialize a new schematic file
2. **Add components:** Use `add_schematic_component` to place resistors, capacitors, ICs, etc.
- Example: Add a resistor with `symbol: "Device:R"`, `reference: "R1"`, `value: "10k"`
3. **Wire components:** Use `add_schematic_connection` to connect component pins
- Or use `connect_to_net` to connect pins to named nets (VCC, GND, etc.)
4. **Add net labels:** Use `add_schematic_net_label` to label important signals
5. **Validate:** Run `run_erc` to check for electrical rule violations
6. **Review:** Use `list_schematic_components` and `get_schematic_view` to verify the design
7. **Sync to PCB:** Use `sync_schematic_to_board` to transfer the design to the PCB layout
### FFC Passthrough Adapter
1. **Add connectors:** Place two FFC connectors using `add_schematic_component`
- Example: J1 and J2, both 20-pin FFC connectors
2. **Connect passthrough:** Use `connect_passthrough` with `sourceRef: "J1"`, `targetRef: "J2"`, `netPrefix: "CSI"`
- This single call connects all 20 pins (J1.1 ↔ J2.1 via CSI_1, J1.2 ↔ J2.2 via CSI_2, etc.)
3. **Sync to board:** Use `sync_schematic_to_board` to create the PCB layout
4. **Verify:** Use `list_schematic_nets` to confirm all connections are correct
## Source Files
The schematic tools are implemented across the following source files:
- **TypeScript (Tool Definitions):**
- `/home/chris/MCP/KiCAD-MCP-Server/src/tools/schematic.ts` - All 27 schematic tool definitions with parameter schemas and handlers
- **Python (Backend Implementation):**
- `/home/chris/MCP/KiCAD-MCP-Server/python/commands/component_schematic.py` - ComponentManager class (add, delete, edit, list components with dynamic symbol loading)
- `/home/chris/MCP/KiCAD-MCP-Server/python/commands/connection_schematic.py` - ConnectionManager class (wiring, net labels, passthrough, netlist generation)
- `/home/chris/MCP/KiCAD-MCP-Server/python/commands/wire_manager.py` - WireManager class (low-level wire manipulation)
- `/home/chris/MCP/KiCAD-MCP-Server/python/commands/pin_locator.py` - PinLocator class (pin location lookup and angle calculation)
- `/home/chris/MCP/KiCAD-MCP-Server/python/commands/dynamic_symbol_loader.py` - DynamicSymbolLoader class (runtime symbol loading from KiCad libraries)

View File

@@ -1,287 +1,175 @@
# KiCAD MCP - Current Status Summary
**Date:** 2025-12-02
**Version:** 2.1.0-alpha
**Phase:** IPC Backend Implementation and Testing
**Date:** 2026-03-21
**Version:** 2.2.3 (package.json shows 2.1.0-alpha -- CHANGELOG is authoritative)
**Phase:** Active development with community contributions
---
## Quick Stats
| Metric | Value | Status |
|--------|-------|--------|
| Core Features Working | 18/20 | 90% |
| KiCAD 9.0 Compatible | Yes | Verified |
| UI Auto-launch | Working | Verified |
| Component Placement | Working | Verified |
| Component Libraries | 153 libraries | Verified |
| Routing Operations | Working | Verified |
| IPC Backend | Under Testing | Experimental |
| Tests Passing | 18/20 | 90% |
---
## What's Working (Verified 2025-12-02)
### Project Management
- `create_project` - Create new KiCAD projects
- `open_project` - Load existing PCB files
- `save_project` - Save changes to disk
- `get_project_info` - Retrieve project metadata
### Board Design
- `set_board_size` - Set dimensions (KiCAD 9.0 fixed)
- `add_board_outline` - Rectangle, circle, polygon outlines
- `add_mounting_hole` - Mounting holes with pads
- `add_board_text` - Text annotations (KiCAD 9.0 fixed)
- `add_layer` - Custom layer creation
- `set_active_layer` - Layer switching
- `get_layer_list` - List all layers
### Component Operations
- `place_component` - Place components with library footprints (KiCAD 9.0 fixed)
- `move_component` - Move components
- `rotate_component` - Rotate components (EDA_ANGLE fixed)
- `delete_component` - Remove components
- `list_components` - Get all components on board
**Footprint Library Integration:**
- Auto-discovered 153 KiCAD footprint libraries
- Search footprints by pattern (`search_footprints`)
- List library contents (`list_library_footprints`)
- Get footprint info (`get_footprint_info`)
- Support for both `Library:Footprint` and `Footprint` formats
**KiCAD 9.0 API Fixes:**
- `SetOrientation()` uses `EDA_ANGLE(degrees, DEGREES_T)`
- `GetOrientation()` returns `EDA_ANGLE`, call `.AsDegrees()`
- `GetFootprintName()` now `GetFPIDAsString()`
### Routing Operations
- `add_net` - Create electrical nets
- `route_trace` - Add copper traces (KiCAD 9.0 fixed)
- `add_via` - Add vias between layers (KiCAD 9.0 fixed)
- `add_copper_pour` - Add copper zones/pours (KiCAD 9.0 fixed)
- `route_differential_pair` - Differential pair routing
**KiCAD 9.0 API Fixes:**
- `netinfo.FindNet()` now `netinfo.NetsByName()[name]`
- `zone.SetPriority()` now `zone.SetAssignedPriority()`
- `ZONE_FILL_MODE_POLYGON` now `ZONE_FILL_MODE_POLYGONS`
- Zone outline requires `outline.NewOutline()` first
### UI Management
- `check_kicad_ui` - Detect running KiCAD
- `launch_kicad_ui` - Auto-launch with project
### Export
- `export_gerber` - Manufacturing files
- `export_pdf` - Documentation
- `export_svg` - Vector graphics
- `export_3d` - STEP/VRML models
- `export_bom` - Bill of materials
### Design Rules
- `set_design_rules` - DRC configuration
- `get_design_rules` - Rule inspection
- `run_drc` - Design rule check
---
## IPC Backend (Under Development)
We are currently implementing and testing the KiCAD 9.0 IPC API for real-time UI synchronization. This is experimental and may not work perfectly in all scenarios.
### IPC-Capable Commands (21 total)
The following commands have IPC handlers implemented:
| Command | IPC Handler | Notes |
|---------|-------------|-------|
| `route_trace` | `_ipc_route_trace` | Implemented |
| `add_via` | `_ipc_add_via` | Implemented |
| `add_net` | `_ipc_add_net` | Implemented |
| `delete_trace` | `_ipc_delete_trace` | Falls back to SWIG |
| `get_nets_list` | `_ipc_get_nets_list` | Implemented |
| `add_copper_pour` | `_ipc_add_copper_pour` | Implemented |
| `refill_zones` | `_ipc_refill_zones` | Implemented |
| `add_text` | `_ipc_add_text` | Implemented |
| `add_board_text` | `_ipc_add_text` | Implemented |
| `set_board_size` | `_ipc_set_board_size` | Implemented |
| `get_board_info` | `_ipc_get_board_info` | Implemented |
| `add_board_outline` | `_ipc_add_board_outline` | Implemented |
| `add_mounting_hole` | `_ipc_add_mounting_hole` | Implemented |
| `get_layer_list` | `_ipc_get_layer_list` | Implemented |
| `place_component` | `_ipc_place_component` | Hybrid (SWIG+IPC) |
| `move_component` | `_ipc_move_component` | Implemented |
| `rotate_component` | `_ipc_rotate_component` | Implemented |
| `delete_component` | `_ipc_delete_component` | Implemented |
| `get_component_list` | `_ipc_get_component_list` | Implemented |
| `get_component_properties` | `_ipc_get_component_properties` | Implemented |
| `save_project` | `_ipc_save_project` | Implemented |
### How IPC Works
When KiCAD is running with IPC enabled:
1. Commands check if IPC is connected
2. If connected, use IPC handler for real-time UI updates
3. If not connected, fall back to SWIG API
**To enable IPC:**
1. KiCAD 9.0+ must be running
2. Enable IPC API: `Preferences > Plugins > Enable IPC API Server`
3. Have a board open in the PCB editor
### Known Limitations
- KiCAD must be running for IPC to work
- Some commands may not work as expected (still testing)
- Footprint loading uses hybrid approach (SWIG for library, IPC for placement)
- Delete trace falls back to SWIG (IPC API limitation)
---
## What Needs Work
### Minor Issues (NON-BLOCKING)
**1. get_board_info layer constants**
- Error: `AttributeError: 'BOARD' object has no attribute 'LT_USER'`
- Impact: Low (informational command only)
- Workaround: Use `get_project_info` or read components directly
**2. Zone filling via SWIG**
- Copper pours created but not filled automatically via SWIG
- Cause: SWIG API segfault when calling `ZONE_FILLER`
- Workaround: Use IPC backend or zones are filled when opened in KiCAD UI
**3. UI manual reload (SWIG mode)**
- User must manually reload to see MCP changes when using SWIG
- Impact: Workflow friction
- Workaround: Use IPC backend for automatic updates
---
## Architecture Status
### SWIG Backend (File-based)
- **Status:** Stable and functional
- **Pros:** No KiCAD process required, works offline, reliable
- **Cons:** Requires manual file reload for UI updates, no zone filling
- **Use Case:** Offline work, automated pipelines, batch operations
### IPC Backend (Real-time)
- **Status:** Under active development and testing
- **Pros:** Real-time UI updates, no file I/O for many operations, zone filling works
- **Cons:** Requires KiCAD running, experimental
- **Use Case:** Interactive design sessions, paired programming with AI
### Hybrid Approach
The server automatically selects the best backend:
- IPC when KiCAD is running with IPC enabled
- SWIG fallback when IPC is unavailable
| Metric | Value |
| -------------------- | --------------------------- |
| Total MCP Tools | 122 |
| Tool Categories | 16 |
| KiCAD 9.0 Compatible | Yes (verified) |
| Platforms | Linux, Windows, macOS |
| JLCPCB Parts Catalog | 2.5M+ components |
| Symbol Access | ~10,000 via dynamic loading |
| Footprint Libraries | 153+ auto-discovered |
| Contributors | 10+ |
| MCP Protocol Version | 2025-06-18 |
---
## Feature Completion Matrix
| Feature Category | Status | Details |
|-----------------|--------|---------|
| Project Management | 100% | Create, open, save, info |
| Board Setup | 100% | Size, outline, mounting holes |
| Component Placement | 100% | Place, move, rotate, delete + 153 libraries |
| Routing | 90% | Traces, vias, copper (zone filling via IPC) |
| Design Rules | 100% | Set, get, run DRC |
| Export | 100% | Gerber, PDF, SVG, 3D, BOM |
| UI Integration | 85% | Launch, check, IPC auto-updates |
| IPC Backend | 60% | Under testing, 21 commands implemented |
| JLCPCB Integration | 0% | Planned |
| Feature Category | Status | Tool Count | Details |
| ------------------- | -------- | ---------- | ----------------------------------------------------------------------- |
| Project Management | Complete | 5 | Create, open, save, info, snapshot |
| Board Setup | Complete | 12 | Size, outline, layers, mounting holes, zones, text, 2D view, SVG import |
| Component Placement | Complete | 16 | Place, move, rotate, delete, edit, find, pads, arrays, align, duplicate |
| Routing | Complete | 13 | Traces, vias, pad-to-pad, differential pairs, netclasses, copy pattern |
| Design Rules / DRC | Complete | 8 | Set/get rules, DRC, net classes, clearance checks |
| Export | Complete | 8 | Gerber, PDF, SVG, 3D, BOM, netlist, position file, VRML |
| Schematic | Complete | 27 | Components, wiring, net labels, connections, ERC, export, sync to board |
| Footprint Libraries | Complete | 4 | List, search, browse, info |
| Symbol Libraries | Complete | 4 | List, search, browse, info |
| Footprint Creator | Complete | 4 | Create custom footprints, edit pads, register libraries |
| Symbol Creator | Complete | 4 | Create custom symbols, register libraries |
| Datasheet Tools | Complete | 2 | LCSC datasheet enrichment |
| JLCPCB Integration | Complete | 5 | Local DB, search, part details, stats, alternatives |
| Freerouting | Complete | 4 | Autoroute, DSN export, SES import, availability check |
| UI Management | Complete | 2 | Check/launch KiCAD |
| Router Tools | Complete | 4 | Category browsing, tool search, execute |
---
## Developer Setup Status
## Architecture
### SWIG Backend (File-based) -- Default
- **Status:** Stable
- Direct pcbnew API access via KiCAD's Python bindings
- Requires manual KiCAD UI reload to see changes
- Works without KiCAD running
- Auto-saves after every board-modifying command
### IPC Backend (Real-time) -- Experimental
- **Status:** Functional, 21 commands implemented
- Real-time UI synchronization with KiCAD 9+
- Requires KiCAD running with IPC API enabled
- Automatic fallback to SWIG when unavailable
### Hybrid Approach
The server automatically selects the best backend:
- IPC when KiCAD is running with IPC enabled
- SWIG fallback when IPC is unavailable
- Some operations use both (e.g., footprint placement)
---
## Platform Support
### Linux -- Primary Platform
### Linux - Primary Platform
- KiCAD 9.0 detection: Working
- Process management: Working
- venv support: Working
- Library discovery: Working (153 libraries)
- Testing: Working
- IPC backend: Under testing
- Library discovery: Working (153+ libraries)
- IPC backend: Working
### Windows - Supported
- Automated setup script (`setup-windows.ps1`)
- Process detection implemented
### Windows -- Fully Supported
- Automated setup script (setup-windows.ps1)
- Process detection via Toolhelp32 API
- Library paths auto-detected
- Comprehensive error diagnostics
- Startup validation with helpful errors
- Troubleshooting guide (WINDOWS_TROUBLESHOOTING.md)
- Troubleshooting guide available (WINDOWS_TROUBLESHOOTING.md)
### macOS - Untested
- Configuration provided
### macOS -- Community Supported
- Automated setup script (setup-macos.sh)
- Auto-detects KiCad Python and pcbnew
- Generates Claude Desktop configuration
- Process detection implemented
- Library paths configured
- Library paths auto-configured
- Needs community testing
---
## Documentation Status
## Recent Development Highlights
### Complete
- [x] README.md
- [x] ROADMAP.md
- [x] IPC_BACKEND_STATUS.md
- [x] IPC_API_MIGRATION_PLAN.md
- [x] REALTIME_WORKFLOW.md
- [x] LIBRARY_INTEGRATION.md
- [x] KNOWN_ISSUES.md
- [x] UI_AUTO_LAUNCH.md
- [x] VISUAL_FEEDBACK.md
- [x] CLIENT_CONFIGURATION.md
- [x] BUILD_AND_TEST_SESSION.md
- [x] STATUS_SUMMARY.md (this document)
- [x] WINDOWS_SETUP.md
- [x] WINDOWS_TROUBLESHOOTING.md
### v2.2.3 (2026-03-11)
### Needed
- [ ] EXAMPLE_PROJECTS.md
- [ ] CONTRIBUTING.md
- [ ] API_REFERENCE.md
- FFC/ribbon cable passthrough workflow (connect_passthrough, sync_schematic_to_board)
- Project snapshot system
- SVG logo import
- ERC validation
- Developer mode (KICAD_MCP_DEV=1)
- Critical B.Cu routing fixes
### v2.2.2-alpha (2026-03-01)
- route_pad_to_pad with auto-via insertion
- copy_routing_pattern for trace replication
- Project-local library resolution
### v2.2.1-alpha (2026-02-28)
- edit_schematic_component with field position support
- Footprint and symbol creator tools
### v2.2.0-alpha (2026-02-27)
- 13 new routing/component tools
- Datasheet enrichment tools
- SWIG/UUID bug fixes
### v2.1.0-alpha (2026-01-10)
- Complete schematic wiring system
- Dynamic symbol loading (~10,000 symbols)
- JLCPCB parts integration
- Router pattern (70% context reduction)
---
## What's Next?
## Community Contributors
### Immediate Priorities
1. **Complete IPC Testing** - Verify all 21 IPC handlers work correctly
2. **Fix Edge Cases** - Address any issues found during testing
3. **Improve Error Handling** - Better fallback behavior
### Planned Features
- JLCPCB parts integration
- Digikey API integration
- Advanced routing algorithms
- Smart BOM management
- Design pattern library (Arduino shields, RPi HATs)
| Contributor | Key Contributions |
| ------------- | ------------------------------------------------------------------------------ |
| Kletternaut | Routing tools, footprint/symbol creators, passthrough workflow, template fixes |
| Mehanik | Schematic inspection/editing tools, component field positions |
| jflaflamme | Freerouting autorouter integration with Docker/Podman |
| l3wi | Local symbol library search, JLCPCB third-party library support |
| gwall-ceres | MCP protocol compliance, Windows compatibility |
| fariouche | Bug fixes |
| shuofengzhang | XDG relative path handling |
| sid115 | Windows setup script improvements |
| pasrom | MCP server bug fixes |
---
## Getting Help
**For Users:**
1. Check [README.md](../README.md) for installation
2. Review [KNOWN_ISSUES.md](KNOWN_ISSUES.md) for common problems
3. Check logs: `~/.kicad-mcp/logs/kicad_interface.log`
**For Developers:**
1. Read [BUILD_AND_TEST_SESSION.md](BUILD_AND_TEST_SESSION.md)
2. Check [ROADMAP.md](ROADMAP.md) for priorities
3. Review [IPC_BACKEND_STATUS.md](IPC_BACKEND_STATUS.md) for IPC details
**For Contributors:**
1. Read [CONTRIBUTING.md](../CONTRIBUTING.md) for development setup
2. Check [ARCHITECTURE.md](ARCHITECTURE.md) for system design
3. Review the [Documentation Index](INDEX.md) for all available docs
**Issues:**
- Open an issue on GitHub with OS, KiCAD version, and error details
---
*Last Updated: 2025-12-02*
*Maintained by: KiCAD MCP Team*
_Last Updated: 2026-04-11_

119
docs/SVG_IMPORT_GUIDE.md Normal file
View File

@@ -0,0 +1,119 @@
# SVG Logo Import Guide
**Added in:** v2.2.3
The `import_svg_logo` tool converts SVG vector graphics into filled polygons on a KiCAD PCB layer. This is useful for placing company logos, project branding, or custom artwork on your board's silkscreen or copper layers.
---
## Tool Reference
### `import_svg_logo`
Imports an SVG file as filled graphic polygons onto a PCB layer. Curves are linearized automatically.
**Parameters:**
| Parameter | Type | Required | Default | Description |
| ------------- | ------- | -------- | ------- | ----------------------------------------------------------------------- |
| `pcbPath` | string | Yes | -- | Path to the .kicad_pcb file |
| `svgPath` | string | Yes | -- | Path to the SVG logo file |
| `x` | number | Yes | -- | X position of the logo top-left corner in mm |
| `y` | number | Yes | -- | Y position of the logo top-left corner in mm |
| `width` | number | Yes | -- | Target width of the logo in mm (height scales to preserve aspect ratio) |
| `layer` | string | No | F.SilkS | PCB layer name (e.g., F.SilkS, B.SilkS, F.Cu, B.Cu) |
| `strokeWidth` | number | No | 0 | Outline stroke width in mm (0 = no outline) |
| `filled` | boolean | No | true | Fill polygons with solid color |
**Returns:**
- Polygon count
- Final dimensions (width x height in mm)
- Layer used
---
## SVG Requirements
### Supported Features
- Path elements with M, L, H, V, C, S, Q, T, A, Z commands
- Filled shapes (polygons, rectangles, circles, ellipses)
- Nested groups and transforms
- Cubic and quadratic Bezier curves (linearized automatically)
### Recommendations
- Use simple, solid shapes -- avoid complex gradients or filters
- Convert text to paths/outlines before importing
- Ensure shapes are filled (not just stroked) for best results
- Keep the SVG clean -- remove unnecessary metadata and layers
### What Will Not Work
- Raster images embedded in SVG
- CSS-based styling (inline style attributes are preferred)
- Complex SVG filters or effects
- Transparency (PCB layers are binary -- copper or no copper)
---
## Workflow
### 1. Prepare Your SVG
If starting from a raster image (PNG, JPG):
- Use a vector graphics editor (Inkscape, Illustrator, Figma) to trace the image
- In Inkscape: Path > Trace Bitmap to convert
- Export as plain SVG
If starting from a vector logo:
- Open in a vector editor
- Convert all text to paths (Object to Path / Create Outlines)
- Remove unnecessary layers and hidden elements
- Save as plain SVG
### 2. Import the Logo
```
Import my company logo from ~/logos/logo.svg onto the board at position x=25 y=40 with width 15mm on the front silkscreen.
```
### 3. Verify Placement
Use `get_board_2d_view` to preview the board with the logo, or open in KiCAD to check placement.
### 4. Adjust if Needed
Re-run `import_svg_logo` with different position, width, or layer parameters.
---
## Layer Options
| Layer | Use Case |
| --------- | ----------------------------------------------------- |
| `F.SilkS` | Front silkscreen (most common for logos) |
| `B.SilkS` | Back silkscreen |
| `F.Cu` | Front copper (logo as exposed copper) |
| `B.Cu` | Back copper |
| `F.Mask` | Front solder mask opening (exposes copper underneath) |
| `B.Mask` | Back solder mask opening |
---
## Manufacturing Considerations
- **Silkscreen logos** are the safest choice -- no impact on electrical design
- **Copper logos** will be part of the copper layer and may affect DRC. Ensure adequate clearance from traces and pads
- **Minimum feature size** depends on your PCB fabricator. Most support 0.15mm (6mil) minimum line width for silkscreen
- **Logo size** should account for manufacturing tolerances -- very small details may not reproduce well
---
## Source Files
- TypeScript tool definition: `src/tools/board.ts` (import_svg_logo)
- Python implementation: `python/commands/svg_import.py`

View File

@@ -1,115 +1,343 @@
# KiCAD MCP Server - Tool Inventory
# KiCAD MCP Server - Complete Tool Inventory
**Total Tools: 59**
**Token Impact: ~40K+ tokens before any user interaction**
**Version:** 2.2.3
**Total Tools:** 122 (18 direct + 65 routed + 4 router + 35 additional)
**Last Updated:** 2026-03-21
## Current Tool Categories
## How Tools Are Organized
### Project Management (4 tools)
- `create_project` - Create a new KiCAD project
- `open_project` - Open an existing KiCAD project
- `save_project` - Save the current KiCAD project
- `get_project_info` - Get information about the current project
The server uses a **router pattern** to reduce AI context usage. Tools fall into three groups:
### Board Management (12 tools)
- `set_board_size` - Set the board dimensions
- `add_layer` - Add a new layer to the board
- `set_active_layer` - Set the active working layer
- `get_board_info` - Get board information
- `get_layer_list` - Get list of all layers
- `add_board_outline` - Add board outline shape (rectangle/circle/polygon)
- `add_mounting_hole` - Add mounting hole to the board
- `add_board_text` - Add text to the board
- `add_zone` - Add copper zone/pour
- `get_board_extents` - Get board bounding box
- `get_board_2d_view` - Get 2D visualization of board
- **Direct tools** - Always visible to the AI. High-frequency operations used in most sessions.
- **Routed tools** - Organized into categories. Discovered via the router tools (`list_tool_categories`, `get_category_tools`, `search_tools`) and invoked via `execute_tool`.
- **Additional tools** - Registered directly (always visible) but not part of the router categories.
### Component Management (10 tools)
- `place_component` - Place a component on the board
- `move_component` - Move a component to new position
- `rotate_component` - Rotate a component
- `delete_component` - Delete a component
- `edit_component` - Edit component properties
- `find_component` - Find component by reference or value
- `get_component_properties` - Get component properties
- `add_component_annotation` - Add annotation to component
- `group_components` - Group multiple components
- `replace_component` - Replace component with another
---
### Routing (4 tools)
- `add_net` - Create a new net
- `route_trace` - Route a trace between two points
- `add_via` - Add a via
- `add_copper_pour` - Add copper pour (ground/power plane)
## Project Management (5 tools)
### Design Rules & DRC (9 tools)
- `set_design_rules` - Configure design rules
- `get_design_rules` - Get current design rules
- `run_drc` - Run design rule check
- `add_net_class` - Add a net class with specific rules
- `assign_net_to_class` - Assign net to a net class
- `set_layer_constraints` - Set layer-specific constraints
- `check_clearance` - Check clearance between items
- `get_drc_violations` - Get DRC violation list
_Source: `src/tools/project.ts`_
### Export (8 tools)
- `export_gerber` - Export Gerber files for fabrication
- `export_pdf` - Export PDF documentation
- `export_svg` - Export SVG graphics
- `export_3d` - Export 3D model (STEP/STL/VRML/OBJ)
- `export_bom` - Export bill of materials
- `export_netlist` - Export netlist
- `export_position_file` - Export component position file
- `export_vrml` - Export VRML 3D model
| Tool | Description | Access |
| ------------------ | ---------------------------------------------------------------- | ------ |
| `create_project` | Create a new KiCAD project (.kicad_pro, .kicad_pcb, .kicad_sch) | Direct |
| `open_project` | Open an existing KiCAD project | Direct |
| `save_project` | Save the current project | Direct |
| `get_project_info` | Get project metadata and information | Direct |
| `snapshot_project` | Save a named checkpoint snapshot (renders PDF, saves step label) | Direct |
### Library (4 tools)
- `list_libraries` - List available footprint libraries
- `search_footprints` - Search for footprints across libraries
- `list_library_footprints` - List footprints in specific library
- `get_footprint_info` - Get detailed footprint information
---
### Schematic (9 tools)
- `create_schematic` - Create a new schematic
- `add_schematic_component` - Add component to schematic
- `add_wire` - Add wire connection in schematic
- `add_schematic_connection` - Connect component pins
- `add_schematic_net_label` - Add net label
- `connect_to_net` - Connect pin to named net
- `get_net_connections` - Get all connections for a net
- `generate_netlist` - Generate netlist from schematic
## Board Management (12 tools)
### UI Management (2 tools)
- `check_kicad_ui` - Check if KiCAD UI is running
- `launch_kicad_ui` - Launch KiCAD UI
_Source: `src/tools/board.ts`_
## Router Implementation Plan
| Tool | Description | Access |
| ------------------- | ----------------------------------------------------------------- | -------------- |
| `set_board_size` | Set PCB dimensions (width, height, unit) | Direct |
| `add_board_outline` | Add board outline (rectangle, circle, polygon, rounded_rectangle) | Direct |
| `get_board_info` | Get board metadata and properties | Direct |
| `add_layer` | Add copper/technical/signal layer | Routed (board) |
| `set_active_layer` | Change the active working layer | Routed (board) |
| `get_layer_list` | List all layers on the board | Routed (board) |
| `add_mounting_hole` | Add mounting hole with optional pad | Routed (board) |
| `add_board_text` | Add text annotation to board | Routed (board) |
| `add_zone` | Add copper zone/pour with clearance settings | Routed (board) |
| `get_board_extents` | Get bounding box of board | Routed (board) |
| `get_board_2d_view` | Render 2D board view (PNG/JPG/SVG) | Routed (board) |
| `import_svg_logo` | Import SVG file as polygons on silkscreen layer | Additional |
### Direct Tools (Always Visible) - 12 tools
High-frequency operations used in 80%+ of sessions:
- `create_project`
- `open_project`
- `save_project`
- `get_project_info`
- `place_component`
- `move_component`
- `add_net`
- `route_trace`
- `get_board_info`
- `set_board_size`
- `add_board_outline`
- `check_kicad_ui`
---
### Router Tools - 4 tools
Discovery and execution:
- `list_tool_categories`
- `get_category_tools`
- `execute_tool`
- `search_tools`
## Component Management (16 tools)
### Routed Tools (Hidden) - 47 tools
Organized into categories for discovery.
_Source: `src/tools/component.ts`_
## Expected Impact
**Before Router**: 59 tools = ~40K+ tokens
**After Router**: 16 tools (12 direct + 4 router) = ~12K tokens
**Savings**: ~28K tokens (70% reduction)
| Tool | Description | Access |
| -------------------------- | ------------------------------------------------------------- | ------------------ |
| `place_component` | Place footprint on PCB (position, rotation, reference, value) | Direct |
| `move_component` | Move component to new position | Direct |
| `rotate_component` | Rotate component (absolute angle) | Routed (component) |
| `delete_component` | Remove component from board | Routed (component) |
| `edit_component` | Edit component properties (reference, value, footprint) | Routed (component) |
| `find_component` | Search components by reference or value | Routed (component) |
| `get_component_properties` | Get all properties of a component | Routed (component) |
| `add_component_annotation` | Add annotation/comment to component | Routed (component) |
| `group_components` | Group multiple components together | Routed (component) |
| `replace_component` | Replace component with different footprint | Routed (component) |
| `get_component_pads` | Get all pad information for a component | Additional |
| `get_component_list` | List all components with optional filters | Additional |
| `get_pad_position` | Get precise position of a specific pad | Additional |
| `place_component_array` | Place array of components (rows x columns) | Additional |
| `align_components` | Align components (horizontal, vertical, grid) | Additional |
| `duplicate_component` | Duplicate component with offset | Additional |
---
## Routing (13 tools)
_Source: `src/tools/routing.ts`_
| Tool | Description | Access |
| ------------------------- | ---------------------------------------------------- | ---------------- |
| `add_net` | Create a new net on the PCB | Direct |
| `route_trace` | Route trace segment between XY points (single layer) | Direct |
| `add_via` | Add via (through/blind/buried) | Routed (routing) |
| `add_copper_pour` | Add copper pour / ground plane | Routed (routing) |
| `delete_trace` | Delete traces by UUID, position, or bulk by net | Additional |
| `query_traces` | Query/filter traces by net, layer, or bounding box | Additional |
| `get_nets_list` | List all nets with statistics | Additional |
| `modify_trace` | Modify existing trace (width, layer, net) | Additional |
| `create_netclass` | Create net class with design rules | Additional |
| `route_differential_pair` | Route differential pair traces | Additional |
| `refill_zones` | Refill all copper zones | Additional |
| `route_pad_to_pad` | Route trace between two pads with auto-via insertion | Additional |
| `copy_routing_pattern` | Copy routing from source to target component groups | Additional |
---
## Design Rules and DRC (8 tools)
_Source: `src/tools/design-rules.ts`_
| Tool | Description | Access |
| ----------------------- | ----------------------------------------------------------- | ------------ |
| `set_design_rules` | Set global design rules (clearance, track width, via sizes) | Routed (drc) |
| `get_design_rules` | Get current design rules | Routed (drc) |
| `run_drc` | Run design rule check | Routed (drc) |
| `add_net_class` | Add net class with custom rules | Routed (drc) |
| `assign_net_to_class` | Assign net to a net class | Routed (drc) |
| `set_layer_constraints` | Set layer-specific constraints | Routed (drc) |
| `check_clearance` | Check clearance between two items | Routed (drc) |
| `get_drc_violations` | Get DRC violation list (filter by severity) | Routed (drc) |
---
## Export (8 tools)
_Source: `src/tools/export.ts`_
| Tool | Description | Access |
| ---------------------- | ------------------------------------------------- | --------------- |
| `export_gerber` | Export Gerber files for fabrication | Routed (export) |
| `export_pdf` | Export PDF with layer selection and page size | Routed (export) |
| `export_svg` | Export SVG vector graphics | Routed (export) |
| `export_3d` | Export 3D model (STEP, STL, VRML, OBJ) | Routed (export) |
| `export_bom` | Export Bill of Materials (CSV, XML, HTML, JSON) | Routed (export) |
| `export_netlist` | Export netlist (KiCad, Spice, Cadstar, OrcadPCB2) | Routed (export) |
| `export_position_file` | Export component position file for pick and place | Routed (export) |
| `export_vrml` | Export VRML 3D model | Routed (export) |
---
## Schematic (27 tools)
_Source: `src/tools/schematic.ts`_
### Component Operations
| Tool | Description | Access |
| ---------------------------- | ------------------------------------------------------- | ------------------ |
| `add_schematic_component` | Add component to schematic (symbol from library) | Direct |
| `delete_schematic_component` | Remove component from schematic | Additional |
| `edit_schematic_component` | Edit component properties (footprint, value, reference) | Additional |
| `get_schematic_component` | Get component info with field positions | Additional |
| `list_schematic_components` | List all components in schematic | Direct |
| `move_schematic_component` | Move component to new position | Routed (schematic) |
| `rotate_schematic_component` | Rotate component | Routed (schematic) |
| `annotate_schematic` | Auto-annotate reference designators | Direct |
### Wiring and Connections
| Tool | Description | Access |
| ----------------------------- | ------------------------------------------------ | ------------------ |
| `add_wire` | Add wire connection between two points | Routed (schematic) |
| `delete_schematic_wire` | Delete wire segment | Routed (schematic) |
| `add_schematic_connection` | Connect two component pins with wire | Routed (schematic) |
| `add_schematic_net_label` | Add net label to schematic | Direct |
| `delete_schematic_net_label` | Delete net label | Routed (schematic) |
| `connect_to_net` | Connect component pin to named net | Direct |
| `connect_passthrough` | Connect all matching pins between two connectors | Direct |
| `get_schematic_pin_locations` | Get pin locations for a component | Additional |
### Net Analysis
| Tool | Description | Access |
| ----------------------- | ----------------------------- | ------------------ |
| `get_net_connections` | Get all connections for a net | Routed (schematic) |
| `list_schematic_nets` | List all nets in schematic | Routed (schematic) |
| `list_schematic_wires` | List all wires in schematic | Routed (schematic) |
| `list_schematic_labels` | List all net labels | Routed (schematic) |
### Schematic Creation and Export
| Tool | Description | Access |
| ---------------------- | -------------------------------- | ------------------ |
| `create_schematic` | Create a new schematic file | Routed (schematic) |
| `get_schematic_view` | Get schematic as image (PNG/SVG) | Routed (schematic) |
| `export_schematic_svg` | Export schematic to SVG | Routed (schematic) |
| `export_schematic_pdf` | Export schematic to PDF | Routed (schematic) |
### Validation and Synchronization
| Tool | Description | Access |
| ------------------------- | ----------------------------------------------------- | ------------------ |
| `run_erc` | Run electrical rule check | Additional |
| `generate_netlist` | Generate netlist from schematic | Routed (schematic) |
| `sync_schematic_to_board` | Sync schematic components/nets to PCB (F8 equivalent) | Direct |
---
## Footprint Libraries (4 tools)
_Source: `src/tools/library.ts`_
| Tool | Description | Access |
| ------------------------- | ------------------------------------- | ---------------- |
| `list_libraries` | List all footprint libraries | Routed (library) |
| `search_footprints` | Search footprints across libraries | Routed (library) |
| `list_library_footprints` | List footprints in a specific library | Routed (library) |
| `get_footprint_info` | Get detailed footprint information | Routed (library) |
---
## Symbol Libraries (4 tools)
_Source: `src/tools/library-symbol.ts`_
| Tool | Description | Access |
| ----------------------- | ----------------------------------------------- | ---------- |
| `list_symbol_libraries` | List all symbol libraries from sym-lib-table | Additional |
| `search_symbols` | Search symbols by name, LCSC ID, or description | Additional |
| `list_library_symbols` | List symbols in a specific library | Additional |
| `get_symbol_info` | Get detailed symbol information | Additional |
---
## Footprint Creator (4 tools)
_Source: `src/tools/footprint.ts`_
| Tool | Description | Access |
| ---------------------------- | ------------------------------------------------------------------------ | ---------- |
| `create_footprint` | Create custom .kicad_mod footprint (SMD/THT pads, courtyard, silkscreen) | Additional |
| `edit_footprint_pad` | Edit pad in existing footprint (size, position, drill, shape) | Additional |
| `register_footprint_library` | Register .pretty library in fp-lib-table | Additional |
| `list_footprint_libraries` | List available .pretty libraries | Additional |
---
## Symbol Creator (4 tools)
_Source: `src/tools/symbol-creator.ts`_
| Tool | Description | Access |
| ------------------------- | ------------------------------------------------------------- | ---------- |
| `create_symbol` | Create custom .kicad_sym symbol (pins, rectangles, polylines) | Additional |
| `delete_symbol` | Remove symbol from library | Additional |
| `list_symbols_in_library` | List all symbols in a .kicad_sym file | Additional |
| `register_symbol_library` | Register library in sym-lib-table | Additional |
---
## Datasheet Tools (2 tools)
_Source: `src/tools/datasheet.ts`_
| Tool | Description | Access |
| ------------------- | --------------------------------------------------- | ---------- |
| `enrich_datasheets` | Fill missing datasheet URLs using LCSC part numbers | Additional |
| `get_datasheet_url` | Get LCSC datasheet URL for a component | Additional |
---
## JLCPCB Integration (5 tools)
_Source: `src/tools/jlcpcb-api.ts`_
| Tool | Description | Access |
| ----------------------------- | ------------------------------------------------------- | ---------- |
| `download_jlcpcb_database` | Download 2.5M+ parts catalog to local SQLite database | Additional |
| `search_jlcpcb_parts` | Search parts by specs (category, package, library type) | Additional |
| `get_jlcpcb_part` | Get detailed part info with pricing | Additional |
| `get_jlcpcb_database_stats` | Get database statistics | Additional |
| `suggest_jlcpcb_alternatives` | Find cheaper or in-stock alternatives | Additional |
---
## Freerouting Autorouter (4 tools)
_Source: `src/tools/freerouting.ts`_
| Tool | Description | Access |
| ------------------- | ---------------------------------------------------------- | ------------------ |
| `autoroute` | Run Freerouting autorouter (export DSN, route, import SES) | Routed (autoroute) |
| `export_dsn` | Export Specctra DSN file for manual routing | Routed (autoroute) |
| `import_ses` | Import routed SES file back into PCB | Routed (autoroute) |
| `check_freerouting` | Check Java and Freerouting JAR availability | Routed (autoroute) |
---
## UI Management (2 tools)
_Source: `src/tools/ui.ts`_
| Tool | Description | Access |
| ----------------- | ----------------------------------------- | -------------- |
| `check_kicad_ui` | Check if KiCAD UI is running | Direct |
| `launch_kicad_ui` | Launch KiCAD UI (optionally with project) | Routed (board) |
---
## Router Tools (4 tools)
_Source: `src/tools/router.ts`_
These meta-tools provide discovery and execution of routed tools:
| Tool | Description |
| ---------------------- | ------------------------------------ |
| `list_tool_categories` | Browse all available tool categories |
| `get_category_tools` | View tools in a specific category |
| `search_tools` | Find tools by keyword |
| `execute_tool` | Run any routed tool with parameters |
---
## Summary by Access Type
| Access Type | Count | Description |
| ----------- | ------- | --------------------------------------------------- |
| Direct | 18 | Always visible, no router needed |
| Routed | 65 | Discovered via router, invoked via `execute_tool` |
| Router | 4 | Meta-tools for discovering and running routed tools |
| Additional | 35 | Always visible, registered directly |
| **Total** | **122** | |
## Summary by Category
| Category | Tool Count |
| -------------------- | ---------- |
| Project Management | 5 |
| Board Management | 12 |
| Component Management | 16 |
| Routing | 13 |
| Design Rules / DRC | 8 |
| Export | 8 |
| Schematic | 27 |
| Footprint Libraries | 4 |
| Symbol Libraries | 4 |
| Footprint Creator | 4 |
| Symbol Creator | 4 |
| Datasheet | 2 |
| JLCPCB Integration | 5 |
| Freerouting | 4 |
| UI Management | 2 |
| Router | 4 |
| **Total** | **122** |
## Token Impact
**Before Router Pattern:** All 122 tools in context = ~80K+ tokens
**With Router Pattern:** 18 direct + 35 additional + 4 router = 57 always-visible tools
**On-Demand:** 65 routed tools loaded only when their category is requested

View File

@@ -7,6 +7,7 @@ Automatically detect and launch KiCAD UI when needed, providing seamless visual
## 🎯 Overview
The KiCAD MCP server can now:
- ✅ Detect if KiCAD UI is running
- ✅ Launch KiCAD automatically when needed
- ✅ Open projects directly in the UI
@@ -49,6 +50,7 @@ Check if KiCAD is currently running.
**Parameters:** None
**Example:**
```typescript
{
"command": "check_kicad_ui",
@@ -57,6 +59,7 @@ Check if KiCAD is currently running.
```
**Response:**
```json
{
"success": true,
@@ -77,10 +80,12 @@ Check if KiCAD is currently running.
Launch KiCAD UI, optionally with a project file.
**Parameters:**
- `projectPath` (optional): Path to `.kicad_pcb` file to open
- `autoLaunch` (optional): Whether to launch if not running (default: true)
**Example:**
```typescript
{
"command": "launch_kicad_ui",
@@ -91,6 +96,7 @@ Launch KiCAD UI, optionally with a project file.
```
**Response:**
```json
{
"success": true,
@@ -158,7 +164,7 @@ Claude:
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| ------------------- | ----------- | ------------------------------ |
| `KICAD_AUTO_LAUNCH` | `false` | Auto-launch KiCAD when needed |
| `KICAD_EXECUTABLE` | auto-detect | Override KiCAD executable path |
@@ -182,16 +188,19 @@ If KiCAD is installed in a non-standard location:
### Process Detection
**Linux:**
```bash
pgrep -f "pcbnew|kicad"
```
**macOS:**
```bash
pgrep -f "KiCad|pcbnew"
```
**Windows:**
```powershell
tasklist /FI "IMAGENAME eq pcbnew.exe"
```
@@ -201,15 +210,18 @@ tasklist /FI "IMAGENAME eq pcbnew.exe"
The system searches for KiCAD in:
**Linux:**
- `/usr/bin/pcbnew`
- `/usr/local/bin/pcbnew`
- `/usr/bin/kicad`
**macOS:**
- `/Applications/KiCad/KiCad.app/Contents/MacOS/kicad`
- `/Applications/KiCad/pcbnew.app/Contents/MacOS/pcbnew`
**Windows:**
- `C:/Program Files/KiCad/9.0/bin/pcbnew.exe`
- `C:/Program Files/KiCad/8.0/bin/pcbnew.exe`
@@ -229,6 +241,7 @@ The system searches for KiCAD in:
### 1. Beginner-Friendly Workflow
User doesn't need to know how to launch KiCAD manually:
```
User: "Help me design a simple LED board"
Claude: [Auto-launches KiCAD, creates project, designs board]
@@ -237,6 +250,7 @@ Claude: [Auto-launches KiCAD, creates project, designs board]
### 2. Streamlined Iteration
For rapid prototyping with visual feedback:
```
1. Claude creates board → KiCAD opens
2. User sees board, requests changes
@@ -247,6 +261,7 @@ For rapid prototyping with visual feedback:
### 3. Batch Processing
Process multiple designs without manual intervention:
```python
for design in designs:
create_project(design)
@@ -263,6 +278,7 @@ for design in designs:
### KiCAD Doesn't Launch
**Check executable path:**
```bash
# Linux/macOS
which pcbnew
@@ -272,6 +288,7 @@ where pcbnew.exe
```
**Override if needed:**
```json
{
"env": {
@@ -283,6 +300,7 @@ where pcbnew.exe
### Process Detection Fails
**Manual check:**
```bash
# Linux/macOS
ps aux | grep kicad
@@ -292,6 +310,7 @@ tasklist | findstr kicad
```
**Verify permissions:**
- Ensure user can execute `pgrep` (Linux/macOS)
- Ensure user can execute `tasklist` (Windows)
@@ -309,17 +328,20 @@ tasklist | findstr kicad
### Files Modified/Created
**New Files:**
- `python/utils/kicad_process.py` - Process management utilities
- `src/tools/ui.ts` - MCP tool definitions
- `docs/UI_AUTO_LAUNCH.md` - This documentation
**Modified Files:**
- `python/kicad_interface.py` - Added UI command handlers
- `src/server.ts` - Registered UI tools
### API Reference
**Python:**
```python
from utils.kicad_process import KiCADProcessManager, check_and_launch_kicad
@@ -341,6 +363,7 @@ result = check_and_launch_kicad(
```
**MCP Tools:**
```typescript
// Check status
await callKicadScript("check_kicad_ui", {});
@@ -348,7 +371,7 @@ await callKicadScript("check_kicad_ui", {});
// Launch
await callKicadScript("launch_kicad_ui", {
projectPath: "/path/to/project.kicad_pcb",
autoLaunch: true
autoLaunch: true,
});
```
@@ -367,6 +390,7 @@ await callKicadScript("launch_kicad_ui", {
### IPC Mode (Coming Weeks 2-3)
When IPC backend is fully implemented:
```
KiCAD runs in background → MCP connects via IPC → Real-time updates
No file reloading needed! Changes appear instantly.
@@ -377,6 +401,7 @@ No file reloading needed! Changes appear instantly.
## 📝 Summary
**Before this feature:**
```
User manually launches KiCAD
User manually opens project
@@ -385,6 +410,7 @@ User manually reloads
```
**After this feature:**
```
User: "Design a board"
→ KiCAD auto-launches with project

View File

@@ -15,6 +15,7 @@ This document explains how to see changes made by the MCP server in the KiCAD UI
### Setup
1. **Open your project in KiCAD PCB Editor**
```bash
pcbnew /tmp/kicad_test_project/New_Project.kicad_pcb
```
@@ -77,12 +78,14 @@ Claude MCP → IPC Socket → Running KiCAD → Instant UI Update
- Restart KiCAD
2. **Install kicad-python** (Already installed ✓)
```bash
pip install kicad-python
```
3. **Configure MCP Server**
Add to your MCP config:
```json
{
"env": {
@@ -98,7 +101,7 @@ Claude MCP → IPC Socket → Running KiCAD → Instant UI Update
### Current IPC Status
| Feature | Status |
|---------|--------|
| -------------------- | ----------- |
| Connection to KiCAD | ✅ Working |
| Version checking | ✅ Working |
| Project operations | ⏳ Week 2-3 |
@@ -124,6 +127,7 @@ This will print a message each time the MCP server saves changes.
## 💡 Tips for Best Experience
### 1. Side-by-Side Windows
```
┌──────────────────┬──────────────────┐
│ Claude Code │ KiCAD PCB │
@@ -134,16 +138,20 @@ This will print a message each time the MCP server saves changes.
```
### 2. Quick Reload Workflow
- Keep KiCAD focused in one window
- Make changes via Claude in another
- Press Alt+Tab → Click "Reload" → See changes
- Repeat
### 3. Save Frequently
The MCP server auto-saves after each operation, so changes are immediately available for reload.
### 4. Verify Before Complex Operations
For complex changes (multiple components, routing, etc.):
1. Make the change
2. Reload in KiCAD
3. Verify it looks correct
@@ -167,6 +175,7 @@ For complex changes (multiple components, routing, etc.):
**Cause:** KiCAD has the file open exclusively
**Solution:**
- KiCAD should allow external modifications
- If not, close the file in KiCAD, let MCP make changes, then reopen

View File

@@ -12,6 +12,7 @@ This guide helps diagnose and fix common issues when setting up KiCAD MCP Server
```
This script will:
- Detect your KiCAD installation
- Verify all prerequisites
- Install dependencies
@@ -34,12 +35,15 @@ If the automated setup fails, continue with the manual troubleshooting below.
**Solution:**
1. **Check the log file** (this has the actual error):
```
%USERPROFILE%\.kicad-mcp\logs\kicad_interface.log
```
Open in Notepad and look at the last 50-100 lines.
2. **Test pcbnew import manually:**
```powershell
& "C:\Program Files\KiCad\9.0\bin\python.exe" -c "import pcbnew; print(pcbnew.GetBuildVersion())"
```
@@ -73,6 +77,7 @@ If the automated setup fails, continue with the manual troubleshooting below.
**Solution:**
1. **Check if KiCAD is installed:**
```powershell
Test-Path "C:\Program Files\KiCad\9.0"
```
@@ -99,6 +104,7 @@ If the automated setup fails, continue with the manual troubleshooting below.
**Solution:**
1. **Check if Node.js is installed:**
```powershell
node --version
npm --version
@@ -124,6 +130,7 @@ If the automated setup fails, continue with the manual troubleshooting below.
**Solution:**
1. **Clean and reinstall dependencies:**
```powershell
Remove-Item node_modules -Recurse -Force
Remove-Item package-lock.json -Force
@@ -132,6 +139,7 @@ If the automated setup fails, continue with the manual troubleshooting below.
```
2. **Check Node.js version:**
```powershell
node --version # Should be v18.0.0 or higher
```
@@ -152,11 +160,13 @@ If the automated setup fails, continue with the manual troubleshooting below.
**Solution:**
1. **Install with KiCAD's Python:**
```powershell
& "C:\Program Files\KiCad\9.0\bin\python.exe" -m pip install -r requirements.txt
```
2. **If pip is not available:**
```powershell
# Download get-pip.py
Invoke-WebRequest -Uri https://bootstrap.pypa.io/get-pip.py -OutFile get-pip.py
@@ -197,6 +207,7 @@ If the automated setup fails, continue with the manual troubleshooting below.
**Symptom:** Config file paths not working
**Common mistakes:**
```json
// ❌ Wrong - single backslashes
"args": ["C:\Users\Name\KiCAD-MCP-Server\dist\index.js"]
@@ -224,6 +235,7 @@ If the automated setup fails, continue with the manual troubleshooting below.
KiCAD MCP requires Python 3.10+. KiCAD 9.0 includes Python 3.11, which is perfect.
**Always use KiCAD's bundled Python:**
```json
{
"mcpServers": {
@@ -315,6 +327,7 @@ print('SUCCESS!')
```
Expected output:
```
Python version: 3.11.x ...
pcbnew version: 9.0.0
@@ -364,6 +377,7 @@ Should show successful initialization with no errors.
### Enable Verbose Logging
Add to your MCP config:
```json
{
"env": {
@@ -412,9 +426,11 @@ if ($process.HasExited) {
If none of the above solutions work:
1. **Run the diagnostic script:**
```powershell
.\setup-windows.ps1
```
Copy the entire output.
2. **Collect log files:**
@@ -437,12 +453,14 @@ If none of the above solutions work:
1. **File paths are case-insensitive** but should match actual casing for best results
2. **Long path support** may be needed for deeply nested projects:
```powershell
# Enable long paths (requires admin)
New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force
```
3. **Windows Defender** may slow down file operations. Add exclusion:
```
Settings → Windows Security → Virus & threat protection → Exclusions
Add: C:\Users\YourName\KiCAD-MCP-Server

View File

@@ -1,4 +1,5 @@
# Build and Test Session Summary
**Date:** October 25, 2025 (Evening)
**Status:** ✅ **SUCCESS**
@@ -17,6 +18,7 @@ Complete the MCP server build and test it with various MCP clients (Claude Deskt
**Problem:** Missing TypeScript source files preventing build
**Files Created:**
- `src/tools/project.ts` (80 lines)
- Registers MCP tools: `create_project`, `open_project`, `save_project`, `get_project_info`
@@ -40,6 +42,7 @@ Complete the MCP server build and test it with various MCP clients (Claude Deskt
**Problem:** Both `component.ts` and `library.ts` registered a resource named "component_details"
**Fix Applied:**
- Renamed library resource to `library_component_details`
- Updated URI template from `kicad://component/{componentId}` to `kicad://library/component/{componentId}`
@@ -52,11 +55,13 @@ Complete the MCP server build and test it with various MCP clients (Claude Deskt
### 3. **Successful Server Startup Test** 🚀
**Test Command:**
```bash
timeout --signal=TERM 3 node dist/index.js
```
**Server Output (All Green):**
```
[INFO] Using STDIO transport for local communication
[INFO] Registering KiCAD tools, resources, and prompts...
@@ -101,6 +106,7 @@ timeout --signal=TERM 3 node dist/index.js
**File Created:** `docs/CLIENT_CONFIGURATION.md` (500+ lines)
**Contents:**
- Platform-specific configurations:
- Linux (Ubuntu/Debian, Arch)
- macOS (with KiCAD.app paths)
@@ -153,6 +159,7 @@ timeout --signal=TERM 3 node dist/index.js
**Addition:** New "Configuration for Other Clients" section after Quick Start
**Changes:**
- Added links to CLIENT_CONFIGURATION.md guide
- Listed all supported MCP clients (Claude Desktop, Cline, Claude Code)
- Highlighted that KiCAD MCP works with ANY MCP-compatible client
@@ -167,6 +174,7 @@ timeout --signal=TERM 3 node dist/index.js
### Files Created/Modified (This Session)
**New Files (5):**
```
src/tools/project.ts # 80 lines
src/tools/routing.ts # 100 lines
@@ -177,6 +185,7 @@ docs/BUILD_AND_TEST_SESSION.md # This file
```
**Modified Files (5):**
```
src/resources/library.ts # Fixed duplicate registration
config/linux-config.example.json # Updated format
@@ -194,11 +203,13 @@ README.md # Added config guide section
### Generated Files
**TypeScript Compilation:**
- 72 JavaScript files in `dist/`
- 24 declaration files (`.d.ts`)
- 24 source maps (`.js.map`)
**Directory Structure:**
```
dist/
├── index.js (entry point)
@@ -215,12 +226,14 @@ dist/
## Verification Tests
### ✅ Test 1: TypeScript Compilation
```bash
npm run build
# Result: SUCCESS (no errors)
```
### ✅ Test 2: Server Startup
```bash
timeout --signal=TERM 3 node dist/index.js
# Result: SUCCESS (exit code 0)
@@ -233,6 +246,7 @@ timeout --signal=TERM 3 node dist/index.js
```
### ✅ Test 3: Python Integration
- Python process successfully spawned: `/home/chris/MCP/KiCAD-MCP-Server/python/kicad_interface.py`
- Using system Python: `python` (resolved to Python 3.12)
- No Python import errors during startup
@@ -244,6 +258,7 @@ timeout --signal=TERM 3 node dist/index.js
### MCP Server Capabilities
**Registered Tools (20+):**
- Project: create_project, open_project, save_project, get_project_info
- Board: set_board_size, add_board_outline, get_board_properties
- Component: add_component, move_component, rotate_component, get_component_list
@@ -253,6 +268,7 @@ timeout --signal=TERM 3 node dist/index.js
- Export: export_gerber, export_pdf, export_svg, export_3d_model
**Registered Resources (15+):**
- Project info and metadata
- Board info, layers, extents
- Board 2D/3D views (PNG, SVG)
@@ -260,6 +276,7 @@ timeout --signal=TERM 3 node dist/index.js
- Statistics and analytics
**Registered Prompts (10+):**
- Component selection guidance
- Routing strategy suggestions
- Design best practices
@@ -271,6 +288,7 @@ timeout --signal=TERM 3 node dist/index.js
### Immediate Testing (Ready Now)
1. **Test with Claude Code CLI:**
```bash
# Create config
mkdir -p ~/.config/claude-code
@@ -295,6 +313,7 @@ timeout --signal=TERM 3 node dist/index.js
### Integration Testing
**Test basic workflow:**
```
1. Create new project
2. Set board size
@@ -304,6 +323,7 @@ timeout --signal=TERM 3 node dist/index.js
```
**Test resources:**
```
1. Request board info
2. View 2D board rendering
@@ -318,6 +338,7 @@ timeout --signal=TERM 3 node dist/index.js
### 1. **Modular Tool Registration**
Each tool module follows consistent pattern:
```typescript
export function registerXxxTools(server: McpServer, callKicadScript: Function) {
server.tool("tool_name", "Description", schema, async (args) => {
@@ -328,6 +349,7 @@ export function registerXxxTools(server: McpServer, callKicadScript: Function) {
```
**Benefits:**
- Easy to add new tools
- Consistent error handling
- Clean separation of concerns
@@ -335,13 +357,15 @@ export function registerXxxTools(server: McpServer, callKicadScript: Function) {
### 2. **Resource Helper Utilities**
Abstracted common response patterns:
```typescript
createJsonResponse(data, uri) // For JSON data
createBinaryResponse(data, mime) // For images/binary
createErrorResponse(error, msg) // For errors
createJsonResponse(data, uri); // For JSON data
createBinaryResponse(data, mime); // For images/binary
createErrorResponse(error, msg); // For errors
```
**Benefits:**
- DRY principle (Don't Repeat Yourself)
- Consistent response format
- Easy to modify response structure
@@ -349,6 +373,7 @@ createErrorResponse(error, msg) // For errors
### 3. **STDIO Transport**
Using standard STDIO (stdin/stdout) for MCP protocol:
- No network ports required
- Maximum security (process isolation)
- Works with all MCP clients
@@ -357,6 +382,7 @@ Using standard STDIO (stdin/stdout) for MCP protocol:
### 4. **Python Subprocess Integration**
Server spawns Python process for KiCAD operations:
- Persistent Python process (faster than per-call spawn)
- JSON-RPC communication over stdin/stdout
- Proper error propagation
@@ -367,12 +393,14 @@ Server spawns Python process for KiCAD operations:
## Achievements
### Development Infrastructure ✅
- ✅ TypeScript build pipeline working
- ✅ All source files complete
- ✅ No compilation errors
- ✅ Source maps generated for debugging
### Server Functionality ✅
- ✅ MCP protocol implementation working
- ✅ STDIO transport connected
- ✅ Python subprocess integration
@@ -380,12 +408,14 @@ Server spawns Python process for KiCAD operations:
- ✅ Graceful startup and shutdown
### Documentation ✅
- ✅ Comprehensive client configuration guide
- ✅ Platform-specific examples
- ✅ Troubleshooting section
- ✅ Advanced configuration options
### Configuration ✅
- ✅ Linux config example
- ✅ Windows config example
- ✅ macOS config example
@@ -398,7 +428,7 @@ Server spawns Python process for KiCAD operations:
**Week 1 Progress:** 100% ✅
| Category | Status |
|----------|--------|
| ---------------------- | ------------- |
| TypeScript compilation | ✅ Complete |
| Server startup | ✅ Working |
| STDIO transport | ✅ Connected |
@@ -445,6 +475,7 @@ Server spawns Python process for KiCAD operations:
## Code Quality
**Metrics:**
- TypeScript strict mode: ✅ Enabled
- ESLint compliance: ✅ Clean
- Type coverage: ✅ 100% (all exports typed)
@@ -457,12 +488,14 @@ Server spawns Python process for KiCAD operations:
## Session Impact
### Before This Session:
- TypeScript wouldn't compile (missing files)
- Server had duplicate resource registration bug
- No client configuration documentation
- Unclear how to use with different MCP clients
### After This Session:
- Complete TypeScript build working
- Server starts cleanly with all features registered
- Comprehensive 500+ line configuration guide

View File

@@ -5,12 +5,14 @@
Replace the template-based schematic workflow with dynamic symbol loading from KiCad's installed symbol libraries. This would eliminate the 13-component limitation and provide access to ALL KiCad symbols (~10,000+ symbols from standard libraries).
**Current Status (Option 1):**
- ✅ Template-based approach working
- ✅ 13 component types supported
- ❌ Limited symbol variety
- ❌ Requires manual template updates for new types
**Proposed (Option 2):**
- 🎯 Dynamic loading from `.kicad_sym` library files
- 🎯 Access to ~10,000+ KiCad symbols
- 🎯 No template maintenance required
@@ -23,6 +25,7 @@ Replace the template-based schematic workflow with dynamic symbol loading from K
### kicad-skip Library Limitation
**Core Issue:** kicad-skip **cannot create symbols from scratch**. It can only:
1. Clone existing symbols from a loaded schematic
2. Modify properties of cloned symbols
@@ -62,20 +65,24 @@ KiCad symbol libraries are S-expression files containing symbol definitions:
### Standard KiCad Library Locations
**Linux:**
- System libraries: `/usr/share/kicad/symbols/`
- User libraries: `~/.local/share/kicad/8.0/symbols/` or `~/.config/kicad/8.0/symbols/`
**Windows:**
- System libraries: `C:\Program Files\KiCad\9.0\share\kicad\symbols\`
- User libraries: `%APPDATA%\kicad\8.0\symbols\`
**macOS:**
- System libraries: `/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/`
- User libraries: `~/Library/Preferences/kicad/8.0/symbols/`
### Standard Library Files
Common libraries (each containing 50-500 symbols):
- `Device.kicad_sym` - Passives (R, C, L, D, LED, Crystal, etc.)
- `Connector.kicad_sym` - Connectors (headers, USB, etc.)
- `Connector_Generic.kicad_sym` - Generic connectors
@@ -96,6 +103,7 @@ Common libraries (each containing 50-500 symbols):
**Goal:** Build an index of all available symbols and their locations
**Implementation:**
```python
class SymbolLibraryManager:
def __init__(self):
@@ -288,16 +296,19 @@ def add_schematic_component_dynamic(params):
## Advantages Over Template Approach
### ✅ Unlimited Symbol Access
- Access to ~10,000+ standard KiCad symbols
- Support for custom user libraries
- Support for 3rd-party libraries (JLCPCB, Espressif, etc.)
### ✅ No Maintenance Required
- Template doesn't need updates for new component types
- Automatically supports new KiCad library additions
- Works with custom symbol libraries
### ✅ Better User Experience
```
User: "Add an STM32F103C8T6 microcontroller at position 100,100"
AI: *Searches symbol index*
@@ -309,6 +320,7 @@ AI: *Searches symbol index*
```
### ✅ Flexible Symbol Search
```python
# Find all resistors
symbols = lib_manager.search_symbols(query="resistor")
@@ -327,6 +339,7 @@ symbols = lib_manager.search_symbols(query="STM32", library="MCU_ST_STM32F1")
**Problem:** Directly manipulating S-expression data is error-prone
**Mitigation:**
- Use `sexpdata` library (already a dependency)
- Create helper functions for common operations
- Add comprehensive validation and error handling
@@ -337,6 +350,7 @@ symbols = lib_manager.search_symbols(query="STM32", library="MCU_ST_STM32F1")
**Problem:** Loading/reloading schematics after injection could be slow
**Mitigation:**
- **Cache loaded symbols**: Once injected, symbol stays in schematic
- **Batch injection**: Inject multiple symbols at once
- **Lazy loading**: Only inject symbols when first used
@@ -346,6 +360,7 @@ symbols = lib_manager.search_symbols(query="STM32", library="MCU_ST_STM32F1")
**Problem:** Some symbols may have complex pin configurations or multiple units
**Mitigation:**
- Start with simple 2-pin passives (R, C, L)
- Gradually add support for multi-pin ICs
- Handle multi-unit symbols (gates, OpAmp sections) explicitly
@@ -356,6 +371,7 @@ symbols = lib_manager.search_symbols(query="STM32", library="MCU_ST_STM32F1")
**Problem:** KiCad symbol format may change between versions
**Mitigation:**
- Parse KiCad version from library files
- Version-specific handling if needed
- Fallback to template approach for unsupported formats
@@ -365,6 +381,7 @@ symbols = lib_manager.search_symbols(query="STM32", library="MCU_ST_STM32F1")
## Implementation Phases
### Phase A: Proof of Concept (1-2 weeks)
- [ ] Create `SymbolLibraryManager` class
- [ ] Implement library discovery (Linux paths only)
- [ ] Implement symbol indexing
@@ -373,6 +390,7 @@ symbols = lib_manager.search_symbols(query="STM32", library="MCU_ST_STM32F1")
- [ ] Test end-to-end with simple components
### Phase B: Core Functionality (2-3 weeks)
- [ ] Cross-platform library discovery (Windows, macOS)
- [ ] Symbol search functionality
- [ ] Template instance creation automation
@@ -381,6 +399,7 @@ symbols = lib_manager.search_symbols(query="STM32", library="MCU_ST_STM32F1")
- [ ] Unit tests for all operations
### Phase C: MCP Integration (1 week)
- [ ] Create `add_schematic_component_dynamic` tool
- [ ] Update `search_symbols` to use library index
- [ ] Add `list_available_symbols` tool
@@ -388,6 +407,7 @@ symbols = lib_manager.search_symbols(query="STM32", library="MCU_ST_STM32F1")
- [ ] Documentation and examples
### Phase D: Advanced Features (2-3 weeks)
- [ ] Multi-unit symbol support (e.g., quad OpAmps)
- [ ] Custom library registration
- [ ] Symbol caching and optimization
@@ -428,18 +448,21 @@ def add_schematic_component(params):
## Success Criteria
### Must Have
- [ ] Load symbols from Device.kicad_sym (passives)
- [ ] Support R, C, L, D, LED (5 core types)
- [ ] Cross-platform library discovery
- [ ] Proper error handling
### Should Have
- [ ] Support for all Device.kicad_sym symbols (~50 symbols)
- [ ] Support for Connector.kicad_sym symbols
- [ ] Symbol search by name/keyword
- [ ] Performance: < 1 second per symbol injection
### Nice to Have
- [ ] Support for all standard libraries (~10,000 symbols)
- [ ] Multi-unit symbol support
- [ ] Custom library registration
@@ -450,7 +473,7 @@ def add_schematic_component(params):
## Risk Assessment
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| ------------------------------- | ----------- | ------ | ------------------------------------------------ |
| S-expression parsing complexity | High | High | Use proven `sexpdata` library, extensive testing |
| Performance degradation | Medium | Medium | Implement caching, lazy loading |
| KiCad version incompatibility | Low | High | Version detection, format validation |
@@ -469,6 +492,7 @@ Dynamic library loading is **feasible and highly beneficial** for the schematic
4. **Enable true "natural language PCB design"**
**Recommendation:**
- ✅ **Keep Option 1 (expanded template) for immediate use**
- ✅ **Implement Option 2 (dynamic loading) over 6-8 weeks**
- ✅ **Maintain template fallback for compatibility**

View File

@@ -18,30 +18,35 @@ The dynamic symbol loading is now **FULLY OPERATIONAL** and accessible through t
## What's Working (Core Functionality)
### ✅ Symbol Extraction
- Parse `.kicad_sym` library files using S-expression parser
- Extract specific symbol definitions by name
- Cache parsed libraries for performance
- Tested with Device.kicad_sym (533 symbols)
### ✅ S-Expression Manipulation
- Load schematic files as S-expression trees
- Inject symbol definitions into `lib_symbols` section
- Preserve schematic structure and formatting
- Write modified schematics back to disk
### ✅ Template Instance Creation
- Create offscreen template instances at negative Y coordinates
- Generate unique UUIDs for each template
- Set proper properties (Reference, Value, Footprint, Datasheet)
- Templates marked as: `in_bom: no`, `on_board: no`, `dnp: yes`
### ✅ Component Cloning
- kicad-skip successfully clones from dynamic templates
- Components inherit symbol structure from injected definitions
- Properties can be modified after cloning
- Full integration with existing ComponentManager
### ✅ Cross-Platform Library Discovery
- Linux: `/usr/share/kicad/symbols`, `~/.local/share/kicad/*/symbols`
- Windows: `C:/Program Files/KiCad/*/share/kicad/symbols`
- macOS: `/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols`
@@ -91,6 +96,7 @@ Results:
**Class:** `DynamicSymbolLoader`
**Key Methods:**
```python
# Library Discovery
find_kicad_symbol_libraries() -> List[Path]
@@ -109,6 +115,7 @@ load_symbol_dynamically(schematic_path: Path, library: str, symbol: str) -> str
```
**Caching:**
- `library_cache`: Parsed library files (path → S-expression data)
- `symbol_cache`: Extracted symbols (lib:symbol → symbol definition)
@@ -117,16 +124,19 @@ load_symbol_dynamically(schematic_path: Path, library: str, symbol: str) -> str
## What's NOT Yet Done (Integration Layer)
### ⏳ MCP Tool Integration
- Need to create `add_schematic_component_dynamic` MCP tool
- Wire dynamic loader through MCP interface (has schematic path)
- Update existing `add_schematic_component` to auto-detect and use dynamic loading
### ⏳ Smart Symbol Discovery
- Automatic library detection from component type
- Search across all libraries for symbol names
- Fuzzy matching for symbol names
### ⏳ Advanced Features
- Multi-unit symbol support (e.g., quad op-amps)
- Pin configuration handling
- Custom library registration
@@ -137,21 +147,25 @@ load_symbol_dynamically(schematic_path: Path, library: str, symbol: str) -> str
## Technical Challenges Solved
### Challenge 1: S-Expression Parsing
**Problem:** KiCad files use Lisp-style S-expressions, complex to parse
**Solution:** Used `sexpdata` library (already a dependency of kicad-skip)
**Result:** ✅ Robust parsing with proper handling of nested structures
### Challenge 2: Symbol Structure Complexity
**Problem:** Symbols have complex nested structure with multiple sub-symbols
**Solution:** Extract entire symbol tree as-is, inject without modification
**Result:** ✅ Preserves all symbol details (graphics, pins, properties)
### Challenge 3: kicad-skip Integration
**Problem:** kicad-skip can only clone existing symbols, can't create from scratch
**Solution:** Inject symbol into lib_symbols, create template instance, then clone
**Result:** ✅ Seamless integration, kicad-skip unaware of dynamic loading
### Challenge 4: Schematic File Path Access
**Problem:** kicad-skip Schematic object doesn't expose file path
**Solution:** Pass schematic path explicitly at MCP interface layer
**Result:** ⏳ Workaround identified, integration pending
@@ -192,7 +206,7 @@ await mcpServer.callTool("add_schematic_component_dynamic", {
reference: "U1",
x: 100,
y: 100,
footprint: "Package_QFP:LQFP-48_7x7mm_P0.5mm"
footprint: "Package_QFP:LQFP-48_7x7mm_P0.5mm",
});
// The tool will:
@@ -210,7 +224,7 @@ await mcpServer.callTool("add_schematic_component_dynamic", {
## Comparison: Before vs After
| Feature | Static Templates (Current) | Dynamic Loading (New) |
|---------|---------------------------|----------------------|
| --------------------- | -------------------------- | ------------------------------ |
| **Available Symbols** | 13 types | ~10,000+ types |
| **Maintenance** | Manual template updates | Zero maintenance |
| **Custom Symbols** | Not supported | Fully supported |
@@ -224,6 +238,7 @@ await mcpServer.callTool("add_schematic_component_dynamic", {
## Phase Progress
### ✅ Phase A: Proof of Concept (COMPLETE)
- [x] Create `DynamicSymbolLoader` class
- [x] Implement library discovery (Linux paths)
- [x] Implement symbol indexing
@@ -235,6 +250,7 @@ await mcpServer.callTool("add_schematic_component_dynamic", {
**Actual Time:** 4 hours! 🎉
### ⏳ Phase B: Core Functionality (IN PROGRESS)
- [ ] Cross-platform library discovery (Windows, macOS)
- [ ] Symbol search functionality
- [ ] Template instance creation automation
@@ -246,6 +262,7 @@ await mcpServer.callTool("add_schematic_component_dynamic", {
**Progress:** 25% (cross-platform discovery done)
### ✅ Phase C: MCP Integration (COMPLETE!)
- [x] Integrate dynamic loading into `add_schematic_component` MCP handler
- [x] Implement save → inject → reload → clone orchestration
- [x] Add schematic_path parameter throughout component chain
@@ -258,6 +275,7 @@ await mcpServer.callTool("add_schematic_component_dynamic", {
**Status:** PRODUCTION READY!
**What Works Now:**
- ✅ Users can add ANY symbol from KiCad libraries via MCP interface
- ✅ Automatic detection and dynamic loading
- ✅ Seamless fallback to static templates
@@ -265,6 +283,7 @@ await mcpServer.callTool("add_schematic_component_dynamic", {
- ✅ Compatible with all existing MCP clients
### ⏸️ Phase D: Advanced Features (PENDING)
- [ ] Multi-unit symbol support (e.g., quad OpAmps)
- [ ] Custom library registration
- [ ] Symbol caching and optimization
@@ -299,18 +318,21 @@ await mcpServer.callTool("add_schematic_component_dynamic", {
## Success Metrics
### Phase A Metrics (All Achieved ✅)
- [x] Load symbols from Device.kicad_sym (passives)
- [x] Support R, C, L, D, LED (5 core types)
- [x] Cross-platform library discovery
- [x] Proper error handling
### Phase B Metrics (Target)
- [ ] Support for all Device.kicad_sym symbols (~500 symbols)
- [ ] Support for Connector.kicad_sym symbols
- [ ] Symbol search by name/keyword
- [ ] Performance: < 1 second per symbol injection
### Overall Success Criteria
- [ ] Access to all standard libraries (~10,000 symbols)
- [ ] Works on Linux, Windows, macOS
- [ ] <100ms latency for cached symbols
@@ -322,7 +344,7 @@ await mcpServer.callTool("add_schematic_component_dynamic", {
## Risks & Mitigations
| Risk | Status | Mitigation |
|------|--------|------------|
| --------------------------- | -------------- | --------------------------------------- |
| S-expression complexity | ✅ RESOLVED | Used proven sexpdata library |
| Performance degradation | ✅ RESOLVED | Caching works great (<30ms cached) |
| KiCad version compatibility | ⚠️ TESTING | Version detection, format validation |
@@ -354,7 +376,7 @@ await mcpServer.callTool("add_schematic_component_dynamic", {
### Test Components
| Component | Type | Library | Dynamic? | Result |
|-----------|------|---------|----------|--------|
| --------- | ----------------- | ------- | -------- | --------------------------- |
| R1 | Resistor | Device | Yes | ✅ Added successfully |
| C1 | Capacitor | Device | Yes | ✅ Added successfully |
| BT1 | Battery | Device | **Yes** | ✅ **Dynamic load + clone** |
@@ -375,6 +397,7 @@ await mcpServer.callTool("add_schematic_component_dynamic", {
✅ Users can now add **ANY symbol from ~10,000 KiCad symbols** through the MCP interface!
✅ The system automatically:
1. Detects if symbol needs dynamic loading
2. Saves current schematic
3. Injects symbol definition from library

View File

@@ -13,7 +13,7 @@ The current KiCAD MCP Server uses SWIG-based Python bindings (`import pcbnew`) w
### Why Migrate?
| SWIG API (Current) | IPC API (Future) |
|-------------------|------------------|
| -------------------------------- | ------------------------------------ |
| ❌ Deprecated | ✅ Official & Supported |
| ❌ Will be removed in KiCAD 10.0 | ✅ Long-term stability |
| ❌ Python-only | ✅ Multi-language (Python, JS, etc.) |
@@ -72,11 +72,13 @@ The current KiCAD MCP Server uses SWIG-based Python bindings (`import pcbnew`) w
### Phase 1: Research & Preparation (Days 1-2)
**Goals:**
- Understand kicad-python library
- Test IPC connection
- Document API differences
**Tasks:**
```bash
# Install kicad-python
pip install kicad-python>=0.5.0
@@ -93,6 +95,7 @@ EOF
```
**Deliverables:**
- [ ] kicad-python installed and tested
- [ ] Connection test script
- [ ] API comparison document (SWIG vs IPC)
@@ -104,6 +107,7 @@ EOF
**Goal:** Create an abstraction layer to support both APIs during transition
**File Structure:**
```
python/kicad_api/
├── __init__.py
@@ -114,6 +118,7 @@ python/kicad_api/
```
**Abstract Interface:**
```python
# python/kicad_api/base.py
from abc import ABC, abstractmethod
@@ -157,6 +162,7 @@ class KiCADBackend(ABC):
```
**IPC Implementation:**
```python
# python/kicad_api/ipc_backend.py
from kicad import KiCad
@@ -187,6 +193,7 @@ class IPCBackend(KiCADBackend):
```
**Backend Factory:**
```python
# python/kicad_api/factory.py
from typing import Optional
@@ -222,6 +229,7 @@ def create_backend(backend_type: Optional[str] = None) -> KiCADBackend:
```
**Deliverables:**
- [ ] Abstract base class defined
- [ ] IPC backend implemented
- [ ] SWIG backend (wrapper around existing code)
@@ -262,6 +270,7 @@ def create_backend(backend_type: Optional[str] = None) -> KiCADBackend:
**Total Estimated Time: 30 hours (~4 days)**
**Migration Template:**
```python
# OLD (SWIG)
import pcbnew
@@ -277,6 +286,7 @@ board_api.set_size(width, height)
```
**Deliverables:**
- [ ] project.py migrated
- [ ] board.py migrated
- [ ] component.py migrated
@@ -291,6 +301,7 @@ board_api.set_size(width, height)
**Testing Strategy:**
1. **Unit Tests**
```python
@pytest.mark.parametrize("backend_type", ["ipc", "swig"])
def test_create_project(backend_type):
@@ -311,6 +322,7 @@ board_api.set_size(width, height)
```
**Deliverables:**
- [ ] 50+ unit tests passing for IPC backend
- [ ] Side-by-side comparison tests
- [ ] Performance benchmarks documented
@@ -322,7 +334,7 @@ board_api.set_size(width, height)
### Project Operations
| Operation | SWIG | IPC |
|-----------|------|-----|
| -------------- | -------------------- | ------------------------ |
| Create project | Custom file creation | `kicad.create_project()` |
| Open project | `pcbnew.LoadBoard()` | `kicad.open_project()` |
| Save project | `board.Save()` | `board.save()` |
@@ -330,7 +342,7 @@ board_api.set_size(width, height)
### Board Operations
| Operation | SWIG | IPC |
|-----------|------|-----|
| --------- | ----------------------- | -------------------- |
| Get board | `pcbnew.LoadBoard()` | `kicad.get_board()` |
| Set size | `board.SetBoardSize()` | `board.set_size()` |
| Add layer | `board.GetLayerCount()` | `board.layers.add()` |
@@ -338,7 +350,7 @@ board_api.set_size(width, height)
### Component Operations
| Operation | SWIG | IPC |
|-----------|------|-----|
| ---------------- | --------------------- | -------------------------- |
| Place component | `pcbnew.FOOTPRINT()` | `board.add_footprint()` |
| Move component | `fp.SetPosition()` | `footprint.set_position()` |
| Rotate component | `fp.SetOrientation()` | `footprint.set_rotation()` |
@@ -346,7 +358,7 @@ board_api.set_size(width, height)
### Routing Operations
| Operation | SWIG | IPC |
|-----------|------|-----|
| ----------- | --------------------- | ------------------- |
| Add net | `board.GetNetCount()` | `board.nets.add()` |
| Route trace | `pcbnew.PCB_TRACK()` | `board.add_track()` |
| Add via | `pcbnew.PCB_VIA()` | `board.add_via()` |
@@ -378,6 +390,7 @@ export KICAD_BACKEND=ipc # or 'swig' or 'auto'
### User Migration Guide
Create `docs/MIGRATING_TO_IPC.md`:
- How to enable IPC in KiCAD
- What changes for users
- Troubleshooting IPC connection issues
@@ -409,7 +422,7 @@ If IPC migration fails:
## Timeline
| Week | Days | Tasks |
|------|------|-------|
| ---------- | ------- | ----------------------------------------------- |
| **Week 2** | Mon-Tue | Research, install kicad-python, test connection |
| | Wed-Thu | Build abstraction layer |
| | Fri | Port project.py and board.py |
@@ -449,11 +462,13 @@ If IPC migration fails:
## Next Steps (This Week)
1. **Install kicad-python**
```bash
pip install kicad-python
```
2. **Test IPC connection**
```bash
# Launch KiCAD
# Enable IPC in preferences
@@ -461,6 +476,7 @@ If IPC migration fails:
```
3. **Create abstraction layer structure**
```bash
mkdir -p python/kicad_api
touch python/kicad_api/{__init__,base,ipc_backend,swig_backend,factory}.py

View File

@@ -11,6 +11,7 @@
## Overview
Integrate JLCPCB's SMT assembly parts library (~100k+ parts) into the KiCAD MCP server, enabling:
- Component search by specifications (e.g., "10k resistor 0603 1%")
- Automatic part selection optimized for cost (prefer Basic parts)
- Real stock and pricing information
@@ -83,12 +84,14 @@ docs/
**File:** `python/commands/jlcpcb.py`
**Features:**
- Authenticate with JLCPCB API (requires user-provided key/secret)
- Download parts database (paginated, ~100k parts)
- Handle rate limiting and retries
- Save to SQLite database
**API Endpoints:**
```python
# Get auth token
POST https://jlcpcb.com/external/genToken
@@ -104,6 +107,7 @@ Body: { "lastKey": "PAGINATION_KEY" } # Optional, for next page
```
**Database Schema:**
```sql
CREATE TABLE components (
lcsc TEXT PRIMARY KEY, -- "C12345"
@@ -128,6 +132,7 @@ CREATE INDEX idx_library_type ON components(library_type);
```
**Environment Variables:**
```bash
# ~/.bashrc or .env
export JLCPCB_API_KEY="your_key_here"
@@ -135,6 +140,7 @@ export JLCPCB_API_SECRET="your_secret_here"
```
**Python Implementation Outline:**
```python
class JLCPCBClient:
def __init__(self, api_key: str, api_secret: str):
@@ -162,6 +168,7 @@ class JLCPCBClient:
**File:** `python/commands/jlcpcb_parts.py`
**Features:**
- Initialize/load SQLite database
- Parametric search (resistance, capacitance, voltage, etc.)
- Filter by library type (Basic/Extended)
@@ -169,6 +176,7 @@ class JLCPCBClient:
- Map package names to KiCAD footprints
**Python Implementation Outline:**
```python
class JLCPCBPartsManager:
def __init__(self, db_path: str = "data/jlcpcb_parts.db"):
@@ -203,6 +211,7 @@ class JLCPCBPartsManager:
```
**Package to Footprint Mapping:**
```json
{
"0402": [
@@ -215,17 +224,9 @@ class JLCPCBPartsManager:
"Capacitor_SMD:C_0603_1608Metric",
"LED_SMD:LED_0603_1608Metric"
],
"0805": [
"Resistor_SMD:R_0805_2012Metric",
"Capacitor_SMD:C_0805_2012Metric"
],
"SOT-23": [
"Package_TO_SOT_SMD:SOT-23",
"Package_TO_SOT_SMD:SOT-23-3"
],
"SOIC-8": [
"Package_SO:SOIC-8_3.9x4.9mm_P1.27mm"
]
"0805": ["Resistor_SMD:R_0805_2012Metric", "Capacitor_SMD:C_0805_2012Metric"],
"SOT-23": ["Package_TO_SOT_SMD:SOT-23", "Package_TO_SOT_SMD:SOT-23-3"],
"SOIC-8": ["Package_SO:SOIC-8_3.9x4.9mm_P1.27mm"]
}
```
@@ -238,6 +239,7 @@ class JLCPCBPartsManager:
**New MCP Tools:**
#### 1. `search_jlcpcb_parts`
Search JLCPCB parts library by specifications.
```typescript
@@ -280,6 +282,7 @@ Search JLCPCB parts library by specifications.
```
**Example Usage:**
```
User: "Find me a 10k resistor, 0603 package, JLCPCB basic part"
Claude: [uses search_jlcpcb_parts]
@@ -291,6 +294,7 @@ Recommended: C58972 (cheapest Basic part with high stock)
```
#### 2. `get_jlcpcb_part`
Get detailed information about a specific JLCPCB part.
```typescript
@@ -311,6 +315,7 @@ Get detailed information about a specific JLCPCB part.
```
**Returns:**
```json
{
"lcsc": "C25804",
@@ -327,13 +332,12 @@ Get detailed information about a specific JLCPCB part.
{ "qty": 100, "price": "$0.0015" }
],
"datasheet": "https://datasheet.lcsc.com/...",
"kicad_footprints": [
"Resistor_SMD:R_0603_1608Metric"
]
"kicad_footprints": ["Resistor_SMD:R_0603_1608Metric"]
}
```
#### 3. Enhanced `place_component`
Add JLCPCB integration to existing component placement.
```typescript
@@ -347,6 +351,7 @@ Add JLCPCB integration to existing component placement.
```
**Example:**
```
User: "Place a 10k resistor at 50, 40mm using JLCPCB part C25804"
Claude: [uses place_component with jlcpcb_part="C25804"]
@@ -364,6 +369,7 @@ Claude: [uses place_component with jlcpcb_part="C25804"]
### Phase 4: Testing & Documentation (Day 4)
**Testing:**
1. Download JLCPCB database (verify ~100k parts loaded)
2. Test parametric search (resistors, capacitors, ICs)
3. Test package mapping (0603 → correct footprints)
@@ -371,6 +377,7 @@ Claude: [uses place_component with jlcpcb_part="C25804"]
5. Verify BOM export includes LCSC part numbers
**Documentation:**
- User guide: How to get JLCPCB API key
- Usage examples for each MCP tool
- Best practices (prefer Basic parts, check stock)
@@ -381,6 +388,7 @@ Claude: [uses place_component with jlcpcb_part="C25804"]
## User Workflow Examples
### Example 1: Find and Place a Resistor
```
User: "I need a 10k pull-up resistor, 0603 size, cheapest JLCPCB basic part"
@@ -409,6 +417,7 @@ Claude: [uses place_component]
```
### Example 2: Design a Cost-Optimized LED Circuit
```
User: "Design an LED circuit with current-limiting resistor, use only JLCPCB basic parts"
@@ -428,6 +437,7 @@ Claude: [searches for LED]
```
### Example 3: Check Stock Before Ordering
```
User: "I need 100 of part C25804, is there enough stock?"
@@ -453,12 +463,14 @@ Claude: [uses get_jlcpcb_part lcsc_number="C25804"]
**Configure in MCP:**
Option A: Environment variables (recommended)
```bash
export JLCPCB_API_KEY="your_app_key"
export JLCPCB_API_SECRET="your_app_secret"
```
Option B: Config file
```json
{
"jlcpcb": {
@@ -470,6 +482,7 @@ Option B: Config file
```
**Initial Setup:**
```
User: "Download the JLCPCB parts database"
@@ -543,11 +556,13 @@ This BOM can be directly uploaded to JLCPCB for assembly!
**Initial Download:** ~5-10 minutes (108k parts)
**Incremental Updates:**
- Run daily via cron/scheduled task
- Only fetch parts modified since last update
- Much faster (~30 seconds)
**Update Command:**
```python
# In Python
jlcpcb_client.update_database(db_path)
@@ -562,6 +577,7 @@ update_jlcpcb_database(force=True) # Full re-download
## Success Metrics
**Implementation Complete When:**
- ✅ Can download/cache full JLCPCB parts database
- ✅ Parametric search works (resistors, capacitors, ICs)
- ✅ Package → footprint mapping covers 90%+ of common parts
@@ -570,6 +586,7 @@ update_jlcpcb_database(force=True) # Full re-download
- ✅ Documentation complete with examples
**User Experience Goal:**
```
User: "Design a board with an ESP32, USB-C connector, and LED,
use only JLCPCB basic parts under $10 BOM"
@@ -588,6 +605,7 @@ Claude: [searches JLCPCB database]
## Future Enhancements
**Post-MVP (v2.1+):**
- LCSC API integration for extended parametric data
- Digikey/Mouser fallback for non-JLCPCB parts
- Part substitution suggestions (out of stock → alternatives)

18
docs/archive/README.md Normal file
View File

@@ -0,0 +1,18 @@
# Archived Documentation
This directory contains historical planning and session documents from the KiCAD MCP Server development. These documents record the design decisions and implementation progress for features that are now complete.
They are preserved for historical reference but are no longer maintained. For current documentation, see the [Documentation Index](../INDEX.md).
## Contents
- **SCHEMATIC_WIRING_PLAN.md** - Original plan for the intelligent wiring system (completed v2.1.0)
- **SCHEMATIC_WORKFLOW_FIX.md** - Fix for broken schematic workflow, Issue #26 (completed v2.1.0)
- **DYNAMIC_LIBRARY_LOADING_PLAN.md** - Plan for dynamic symbol loading (completed v2.1.0)
- **DYNAMIC_LOADING_STATUS.md** - Status tracking for dynamic symbol loader (completed v2.1.0)
- **JLCPCB_INTEGRATION_PLAN.md** - Original JLCPCB integration plan (completed v2.1.0)
- **ROUTER_IMPLEMENTATION_STATUS.md** - Router pattern implementation progress (completed v2.0.0)
- **IPC_API_MIGRATION_PLAN.md** - IPC backend migration plan (completed v2.0.0)
- **BUILD_AND_TEST_SESSION.md** - Early build and test session notes
- **WEEK1_SESSION1_SUMMARY.md** - Week 1 development session 1 notes
- **WEEK1_SESSION2_SUMMARY.md** - Week 1 development session 2 notes

View File

@@ -7,6 +7,7 @@
### What Was Implemented
#### 1. Tool Registry (`src/tools/registry.ts`)
- ✅ Complete tool categorization (59 tools → 7 categories)
- ✅ Direct tools list (12 high-frequency tools)
- ✅ Category lookup maps for O(1) access
@@ -14,17 +15,20 @@
- ✅ Registry statistics and metadata
#### 2. Router Tools (`src/tools/router.ts`)
- ✅ `list_tool_categories` - Browse all categories
- ✅ `get_category_tools` - View tools in a category
- ✅ `execute_tool` - Execute any routed tool
- ✅ `search_tools` - Search tools by keyword
#### 3. Server Integration (`src/server.ts`)
- ✅ Router tools registered at server startup
- ✅ All tools remain functional (backwards compatible)
- ✅ Logging added for router pattern status
#### 4. Documentation
- ✅ `TOOL_INVENTORY.md` - Complete tool catalog
- ✅ `ROUTER_ARCHITECTURE.md` - Design specification
- ✅ `ROUTER_IMPLEMENTATION_STATUS.md` - This file
@@ -36,6 +40,7 @@
**Build:** ✅ Compiles successfully (`npm run build`)
**Tool Count:**
- Total Tools: 59 (ALL still registered and visible)
- Direct Tools: 12
- Routed Tools: 47
@@ -43,6 +48,7 @@
- **Currently Visible to Claude:** 63 tools (59 + 4 router)
**Token Impact:**
- **Current:** ~42K tokens (still showing all tools)
- **Target:** ~12K tokens (Phase 2 optimization)
- **Potential Savings:** ~30K tokens (71% reduction)
@@ -50,46 +56,55 @@
## 🔄 Phase 2: Token Optimization (Next Step)
### Objective
Hide routed tools from Claude's context while keeping them accessible via `execute_tool`.
### Two Approaches
#### Option A: Registration Filtering (Recommended)
Modify tool registration to conditionally register tools based on whether they're in the direct list.
**Changes needed:**
1. Update each `register*Tools` function to check `isDirectTool()`
2. Only call `server.tool()` for direct tools
3. Routed tools remain accessible via `execute_tool` calling `callKicadScript`
**Pros:**
- Clean separation
- True token savings
- No behavior changes
**Cons:**
- Requires modifying 9 tool files
#### Option B: MCP Filter (If Supported)
If MCP SDK supports tool filtering/hiding, use that instead.
**Pros:**
- No tool file changes
- Centralized control
**Cons:**
- May not be supported by SDK
- Needs investigation
### Implementation Plan for Phase 2
1. **Create Helper Function** (`src/tools/conditional-register.ts`)
```typescript
export function registerToolConditionally(
server: McpServer,
toolName: string,
definition: ToolDefinition,
handler: Function
handler: Function,
) {
if (isDirectTool(toolName)) {
// Register with MCP (visible to Claude)
@@ -115,7 +130,7 @@ If MCP SDK supports tool filtering/hiding, use that instead.
## 📊 Categories & Distribution
| Category | Tools | Description |
|----------|-------|-------------|
| ------------- | ------ | ------------------------------------------------- |
| **board** | 9 | Board configuration, layers, zones, visualization |
| **component** | 8 | Advanced component operations |
| **export** | 8 | Manufacturing file generation |
@@ -130,6 +145,7 @@ If MCP SDK supports tool filtering/hiding, use that instead.
## 🧪 Testing the Router
### Test 1: List Categories
```
User: "What tool categories are available?"
@@ -138,6 +154,7 @@ Result: Returns 7 categories with descriptions
```
### Test 2: Browse Category
```
User: "What export tools are available?"
@@ -146,6 +163,7 @@ Result: Returns 8 export tools
```
### Test 3: Search Tools
```
User: "How do I export gerber files?"
@@ -154,6 +172,7 @@ Result: Finds export_gerber in export category
```
### Test 4: Execute Tool
```
User: "Export gerbers to ./output"

View File

@@ -19,10 +19,12 @@ This plan outlines the implementation of complete schematic wiring functionality
### What Exists ✅
**Files:**
- `python/commands/connection_schematic.py` - ConnectionManager class with wire/label methods
- MCP handlers in `kicad_interface.py` for 6 connection-related tools
**MCP Tools (Registered):**
1. `add_schematic_wire` - Add wire between two points
2. `add_schematic_connection` - Connect two component pins
3. `add_schematic_net_label` - Add net label
@@ -31,6 +33,7 @@ This plan outlines the implementation of complete schematic wiring functionality
6. `generate_netlist` - Generate netlist from schematic
**ConnectionManager Methods:**
- `add_wire(schematic, start_point, end_point)` - Add wire between coordinates
- `add_connection(schematic, source_ref, source_pin, target_ref, target_pin)` - Connect pins
- `add_net_label(schematic, net_name, position)` - Add label
@@ -42,28 +45,33 @@ This plan outlines the implementation of complete schematic wiring functionality
### What's Broken/Missing ❌
**Problem 1: kicad-skip API Uncertainty**
- Code assumes `schematic.wire.append()` exists
- Code assumes `schematic.label.append()` exists
- Code assumes pins have `.name` and `.location` attributes
- **Need to verify what kicad-skip actually supports**
**Problem 2: Pin Location Calculation**
- Current implementation tries to calculate absolute pin positions
- May not account for symbol rotation
- May not work with multi-unit symbols
- Pin numbering vs pin naming confusion
**Problem 3: No Visual Feedback**
- No way to verify wires were created correctly
- No validation of wire endpoints
- No checks for overlapping wires or junctions
**Problem 4: Limited Testing**
- No integration tests for wiring functionality
- No validation with real KiCad schematics
- User reported `add_schematic_wire` fails
**Problem 5: Missing Features**
- No junction (wire intersection) support
- No bus support (multi-bit signals)
- No no-connect flags
@@ -77,11 +85,13 @@ This plan outlines the implementation of complete schematic wiring functionality
### Challenge 1: kicad-skip Wire API
**Issue:** The kicad-skip library documentation is sparse. We need to determine:
- Does `schematic.wire` exist?
- What's the correct API to add wires?
- How are wires stored in .kicad_sch files?
**S-Expression Format (KiCad 8/9):**
```lisp
(wire (pts (xy 100 100) (xy 200 100))
(stroke (width 0) (type default))
@@ -90,6 +100,7 @@ This plan outlines the implementation of complete schematic wiring functionality
```
**Approach:**
1. Examine kicad-skip source code
2. Test wire creation manually with kicad-skip
3. Fall back to S-expression manipulation if necessary (similar to dynamic symbol loading)
@@ -102,12 +113,14 @@ This plan outlines the implementation of complete schematic wiring functionality
Pins are defined within symbol definitions in lib_symbols, with coordinates relative to symbol origin. When symbol is placed, pins move with it.
**Required Information:**
- Symbol position (x, y)
- Symbol rotation angle
- Pin offset from symbol origin
- Pin number/name mapping
**Solution:**
1. Parse symbol definition to find pin definitions
2. Apply transformation matrix (position + rotation) to pin coordinates
3. Return absolute pin position in schematic space
@@ -117,6 +130,7 @@ Pins are defined within symbol definitions in lib_symbols, with coordinates rela
**Issue:** Users don't want to manually specify every wire segment.
**Desired Behavior:**
```
User: "Connect R1 pin 1 to C1 pin 1"
System:
@@ -128,6 +142,7 @@ System:
```
**Auto-Routing Options:**
1. **Direct** - Single wire segment (diagonal if needed)
2. **Orthogonal** - Only horizontal/vertical segments (2 segments)
3. **Manhattan** - Complex path avoiding components (3+ segments)
@@ -139,11 +154,13 @@ System:
**Issue:** Labels need to attach to wires, not float in space.
**KiCad Behavior:**
- Labels must touch a wire or pin
- Labels create electrical connections at their attachment point
- Multiple labels with same name = connected net
**Implementation:**
- When adding label, find nearest wire endpoint
- Attach label to that coordinate
- Or create short wire stub for label attachment
@@ -190,6 +207,7 @@ System:
- Verify electrical connectivity
**Deliverables:**
- Working `add_schematic_wire` tool
- Working `add_schematic_connection` tool
- Pin location discovery working
@@ -197,6 +215,7 @@ System:
- Documentation updated
**Success Criteria:**
- User can connect two resistor pins with MCP command
- Wire appears in KiCad schematic viewer
- Netlist shows electrical connection
@@ -240,6 +259,7 @@ System:
- Generate and validate netlist
**Deliverables:**
- Working `add_schematic_net_label` tool
- Working `connect_to_net` tool
- Working `get_net_connections` tool
@@ -247,6 +267,7 @@ System:
- Netlist generation working
**Success Criteria:**
- User can label nets VCC, GND
- Multiple components connect to same net
- Netlist correctly shows net membership
@@ -284,12 +305,14 @@ System:
- Sheet connections
**Deliverables:**
- Junction creation
- No-connect support
- Smart orthogonal routing
- Bus and hierarchical label support
**Success Criteria:**
- Wires route cleanly around components
- Junctions appear at wire intersections
- Unused pins marked with no-connect
@@ -332,12 +355,14 @@ System:
- Add to CHANGELOG
**Deliverables:**
- ERC validation
- Comprehensive test suite
- Error handling
- Complete documentation
**Success Criteria:**
- 95%+ test pass rate
- Users can create functional circuits
- Clear error messages on failures
@@ -365,11 +390,13 @@ label = schematic.label.new(
```
**Pros:**
- Clean, maintainable code
- Follows library patterns
- Less likely to break
**Cons:**
- Depends on kicad-skip having these features
- May be limited in functionality
@@ -407,11 +434,13 @@ with open(schematic_path, 'w') as f:
```
**Pros:**
- Complete control
- Can implement any feature
- Works around library limitations
**Cons:**
- More complex
- Requires deep KiCad format knowledge
- More maintenance
@@ -447,6 +476,7 @@ Symbols are stored in `lib_symbols` section:
### Step 2: Extract Pin Information
For each pin:
- Number (e.g., "1", "2")
- Name (e.g., "GND", "VCC", "~" for unnamed)
- Position relative to symbol origin: `(at x y angle)`
@@ -462,6 +492,7 @@ From symbol instance in schematic:
```
Extract:
- Position: `(at 100 100 0)` = x=100, y=100, rotation=0°
- Reference: "R1"
@@ -518,6 +549,7 @@ R1 pin 2 C1 pin 1
```
**Algorithm:**
1. Calculate midpoint
2. Route horizontal to midpoint
3. Route vertical to target
@@ -540,6 +572,7 @@ Complex multi-segment paths avoiding components.
### Unit Tests
Test individual functions:
- `test_add_wire()` - Wire creation
- `test_get_pin_location()` - Pin discovery
- `test_add_net_label()` - Label creation
@@ -548,6 +581,7 @@ Test individual functions:
### Integration Tests
Test complete workflows:
- `test_connect_two_resistors()` - Wire R1 to R2
- `test_connect_to_vcc_net()` - Multiple components to VCC
- `test_generate_netlist()` - Netlist accuracy
@@ -565,6 +599,7 @@ Test complete workflows:
## Success Metrics
### Phase 1 Success:
- [ ] `add_schematic_wire` works (coordinates)
- [ ] `add_schematic_connection` works (pin to pin)
- [ ] Wires appear in KiCad schematic
@@ -572,6 +607,7 @@ Test complete workflows:
- [ ] 3+ integration tests passing
### Phase 2 Success:
- [ ] Net labels work (VCC, GND, etc.)
- [ ] Multiple components on same net
- [ ] `get_net_connections` returns correct results
@@ -579,12 +615,14 @@ Test complete workflows:
- [ ] 5+ integration tests passing
### Phase 3 Success:
- [ ] Junctions at wire intersections
- [ ] Orthogonal routing preferred
- [ ] No-connect flags on unused pins
- [ ] 10+ integration tests passing
### Phase 4 Success:
- [ ] ERC detects errors
- [ ] 95%+ test coverage
- [ ] Complete documentation
@@ -595,7 +633,7 @@ Test complete workflows:
## Risk Assessment
| Risk | Probability | Impact | Mitigation |
|------|------------|--------|------------|
| ------------------------- | ----------- | ------ | -------------------------------- |
| kicad-skip lacks wire API | High | High | Use S-expression fallback |
| Pin discovery complex | Medium | Medium | Test with multiple symbol types |
| Rotation math errors | Medium | High | Extensive testing, validation |
@@ -607,11 +645,13 @@ Test complete workflows:
## Dependencies
**Required:**
- kicad-skip >= 0.1.0 (or compatible)
- sexpdata (already dependency for dynamic loading)
- Python 3.8+
**Optional:**
- KiCad CLI for validation (`kicad-cli sch export netlist`)
---
@@ -658,6 +698,7 @@ Test complete workflows:
**For Issue #26:**
Update users that:
- ✅ Component placement is DONE (with 10,000+ symbols)
- ⏳ Wire/connection tools are IN PROGRESS
- 📅 Estimated completion: 2-3 weeks for core functionality

View File

@@ -14,6 +14,7 @@ The schematic workflow was completely broken due to incorrect usage of the kicad
The kicad-skip library **does not support creating symbols from scratch**. The only way to add symbols is by **cloning existing symbol instances**.
From kicad-skip documentation:
> "symbols: these don't have a new()" because they require complex mappings to library elements, pins, and properties.
## Solution
@@ -21,6 +22,7 @@ From kicad-skip documentation:
### 1. Template-Based Approach
Created a template schematic (`python/templates/template_with_symbols.kicad_sch`) with:
- Complete `lib_symbols` section defining R, C, LED symbols
- Three template symbol instances placed off-screen at (-100, -110, -120)
- Template symbols marked as `dnp yes`, `in_bom no`, `on_board no` so they don't interfere
@@ -28,15 +30,18 @@ Created a template schematic (`python/templates/template_with_symbols.kicad_sch`
### 2. Updated Files
**python/commands/project.py:**
- Now creates both `.kicad_pcb` AND `.kicad_sch` files
- Project file includes schematic reference in `sheets` array
- Copies template schematic with cloneable symbols
**python/commands/schematic.py:**
- Uses template file instead of creating from scratch
- Proper minimal schematic structure when template unavailable
**python/commands/component_schematic.py:**
- Completely rewritten to use `clone()` API
- Maps component types to template symbols
- Proper UUID generation for each cloned symbol
@@ -78,17 +83,20 @@ SchematicManager.save_schematic(sch, result['project']['schematicPath'])
## Supported Component Types
Currently supported template symbols:
- `R` - Resistor (maps to `_TEMPLATE_R`)
- `C` - Capacitor (maps to `_TEMPLATE_C`)
- `D` or `LED` - LED (maps to `_TEMPLATE_D`)
To add more component types, update:
1. `python/templates/template_with_symbols.kicad_sch` - Add lib_symbol definition and template instance
2. `python/commands/component_schematic.py` - Add mapping in `TEMPLATE_MAP`
## Testing
Comprehensive test created at `/tmp/test_schematic_workflow.py`:
- Creates project with schematic
- Loads schematic
- Adds R, C, LED components

View File

@@ -1,4 +1,5 @@
# Week 1 - Session 1 Summary
**Date:** October 25, 2025
**Status:** ✅ **EXCELLENT PROGRESS**
@@ -9,6 +10,7 @@
Resurrect the KiCAD MCP Server and transform it from a Windows-only "KiCAD automation wrapper" into a **true AI-assisted PCB design companion** for hobbyist users (novice to intermediate).
**Strategic Focus:**
- Linux-first platform support
- JLCPCB & Digikey integration
- End-to-end PCB design workflow
@@ -21,12 +23,14 @@ Resurrect the KiCAD MCP Server and transform it from a Windows-only "KiCAD autom
### 1. **Complete Project Analysis** 📊
Created comprehensive documentation:
- ✅ Full codebase exploration (6 tool categories, 9 Python command modules)
- ✅ Identified critical issues (deprecated SWIG API, Windows-only paths)
- ✅ Researched KiCAD IPC API, JLCPCB API, Digikey API
- ✅ Researched MCP best practices
**Key Findings:**
- SWIG Python bindings are DEPRECATED (will be removed in KiCAD 10.0)
- Current architecture works but is Windows-centric
- Missing core AI-assisted features (part selection, BOM management)
@@ -38,22 +42,26 @@ Created comprehensive documentation:
Designed comprehensive roadmap in 4 phases:
#### **Phase 1: Foundation & Migration (Weeks 1-4)**
- Linux compatibility
- KiCAD IPC API migration
- Performance improvements (caching, async)
#### **Phase 2: Core AI Features (Weeks 5-8)**
- JLCPCB integration (parts library + pricing)
- Digikey integration (parametric search)
- Smart BOM management
- Design pattern library
#### **Phase 3: Novice-Friendly Workflows (Weeks 9-11)**
- Guided step-by-step workflows
- Visual feedback system
- Intelligent error recovery
#### **Phase 4: Polish & Launch (Week 12)**
- Testing, documentation, community building
---
@@ -63,6 +71,7 @@ Designed comprehensive roadmap in 4 phases:
Created complete cross-platform support:
**Files Created:**
- ✅ `docs/LINUX_COMPATIBILITY_AUDIT.md` - Comprehensive audit report
- ✅ `python/utils/platform_helper.py` - Cross-platform path detection
- ✅ `config/linux-config.example.json` - Linux configuration template
@@ -70,6 +79,7 @@ Created complete cross-platform support:
- ✅ `config/macos-config.example.json` - macOS configuration template
**Platform Helper Features:**
```python
PlatformHelper.get_config_dir() # ~/.config/kicad-mcp on Linux
PlatformHelper.get_log_dir() # ~/.config/kicad-mcp/logs
@@ -86,6 +96,7 @@ Created GitHub Actions workflow:
**File:** `.github/workflows/ci.yml`
**Testing Matrix:**
- TypeScript build on Ubuntu 24.04, 22.04, Windows, macOS
- Python tests on Python 3.10, 3.11, 3.12
- Integration tests with KiCAD 9.0 installation
@@ -102,12 +113,14 @@ Created GitHub Actions workflow:
Set up comprehensive testing infrastructure:
**Files Created:**
- ✅ `pytest.ini` - Pytest configuration
- ✅ `requirements.txt` - Production dependencies
- ✅ `requirements-dev.txt` - Development dependencies
- ✅ `tests/test_platform_helper.py` - 20+ unit tests
**Test Categories:**
```python
@pytest.mark.unit # Fast, no external dependencies
@pytest.mark.integration # Requires KiCAD
@@ -116,6 +129,7 @@ Set up comprehensive testing infrastructure:
```
**Test Results:**
```
✅ Platform detection works correctly
✅ Config directories follow XDG spec on Linux
@@ -132,6 +146,7 @@ Created contributor guide:
**File:** `CONTRIBUTING.md`
**Includes:**
- Platform-specific setup instructions (Linux/Windows/macOS)
- Project structure overview
- Development workflow
@@ -144,6 +159,7 @@ Created contributor guide:
### 7. **Dependencies Management** 📦
**Production Dependencies (requirements.txt):**
```
kicad-skip>=0.1.0 # Schematic manipulation
Pillow>=9.0.0 # Image processing
@@ -154,6 +170,7 @@ python-dotenv>=1.0.0 # Config management
```
**Development Dependencies:**
```
pytest>=7.4.0 # Testing
black>=23.7.0 # Code formatting
@@ -323,6 +340,7 @@ Node: 18.x, 20.x, 22.x
## 🚀 Impact Assessment
### Before Today
- ❌ Windows-only
- ❌ No CI/CD
- ❌ No tests
@@ -330,6 +348,7 @@ Node: 18.x, 20.x, 22.x
- ❌ No developer documentation
### After Today
- ✅ Cross-platform (Linux/Windows/macOS)
- ✅ GitHub Actions CI/CD
- ✅ 20+ unit tests with pytest
@@ -346,24 +365,28 @@ Node: 18.x, 20.x, 22.x
## 💡 Key Decisions Made
### 1. **IPC API Migration: Proceed Immediately**
- SWIG is deprecated, will be removed in KiCAD 10.0
- IPC API is stable, officially supported
- Better performance and cross-language support
- Decision: Migrate in Week 2-3
### 2. **Linux-First Approach**
- Hobbyists often use Linux
- Better for open-source development
- Easier CI/CD with GitHub Actions
- Decision: Linux is primary development platform
### 3. **JLCPCB Integration Priority**
- Hobbyists love JLCPCB for cheap assembly
- "Basic parts" filter is critical
- Better stock than Digikey for hobbyists
- Decision: JLCPCB before Digikey
### 4. **Pytest over unittest**
- More Pythonic
- Better fixtures and parametrization
- Industry standard
@@ -392,15 +415,19 @@ Node: 18.x, 20.x, 22.x
## 🎉 Highlights
### Biggest Win
✨ **Complete cross-platform infrastructure in one session**
### Most Valuable Addition
🔧 **PlatformHelper utility** - Solves path issues elegantly
### Best Decision
🎯 **Creating comprehensive plan first** - Clear roadmap for 12 weeks
### Unexpected Discovery
⚠️ **SWIG deprecation** - Would have been a nasty surprise later!
---
@@ -408,11 +435,13 @@ Node: 18.x, 20.x, 22.x
## 🤝 Collaboration Notes
### What Went Well
- Clear requirements from user
- Good research phase before coding
- Incremental progress with testing
### What to Improve
- Need actual Ubuntu 24.04 testing
- Should run pytest suite
- Need to test KiCAD 9.0 integration
@@ -422,6 +451,7 @@ Node: 18.x, 20.x, 22.x
## 📅 Schedule Status
### Week 1 Goals
- [x] Linux compatibility audit (**100% complete**)
- [x] CI/CD setup (**100% complete**)
- [x] Development infrastructure (**100% complete**)
@@ -448,16 +478,19 @@ Node: 18.x, 20.x, 22.x
## 📝 Notes for Future
### Architecture Decisions to Make
- [ ] Redis vs in-memory cache?
- [ ] Session storage approach?
- [ ] WebSocket vs STDIO for future scaling?
### Dependencies to Research
- [ ] JLCPCB API client library (exists?)
- [ ] Digikey API v3 (issus/DigiKeyApi looks good)
- [ ] kicad-python 0.5.0 compatibility
### Questions to Answer
- [ ] How to handle KiCAD running vs not running (IPC requirement)?
- [ ] Should we support both SWIG and IPC during migration?
- [ ] BOM format standardization?
@@ -467,7 +500,7 @@ Node: 18.x, 20.x, 22.x
## 🏆 Success Metrics Achieved Today
| Metric | Target | Achieved | Status |
|--------|--------|----------|--------|
| ---------------- | --------------- | -------------- | ------ |
| Platform support | Linux primary | ✅ Linux ready | ✅ |
| CI/CD pipeline | GitHub Actions | ✅ Complete | ✅ |
| Test coverage | Setup pytest | ✅ 20+ tests | ✅ |

View File

@@ -1,4 +1,5 @@
# Week 1 - Session 2 Summary
**Date:** October 25, 2025 (Afternoon)
**Status:** 🚀 **OUTSTANDING PROGRESS**
@@ -7,6 +8,7 @@
## 🎯 Session Goals
Continue Week 1 implementation while user installs KiCAD:
1. Update README with comprehensive Linux guide
2. Create installation scripts
3. Begin IPC API preparation
@@ -21,6 +23,7 @@ Continue Week 1 implementation while user installs KiCAD:
**File:** `README.md`
**Changes:**
- ✅ Updated project status to reflect v2.0 rebuild
- ✅ Added collapsible platform-specific installation sections:
- 🐧 **Linux (Ubuntu/Debian)** - Primary, detailed
@@ -39,6 +42,7 @@ Continue Week 1 implementation while user installs KiCAD:
**File:** `scripts/install-linux.sh`
**Features:**
- ✅ Fully automated Ubuntu/Debian installation
- ✅ Color-coded output (info/success/warning/error)
- ✅ Safety checks (platform detection, command validation)
@@ -51,6 +55,7 @@ Continue Week 1 implementation while user installs KiCAD:
- ✅ Helpful next-steps guidance
**Usage:**
```bash
cd kicad-mcp-server
./scripts/install-linux.sh
@@ -65,6 +70,7 @@ cd kicad-mcp-server
**File:** `.pre-commit-config.yaml`
**Hooks Added:**
- ✅ **Python:**
- Black (code formatting)
- isort (import sorting)
@@ -84,6 +90,7 @@ cd kicad-mcp-server
- Markdownlint (formatting)
**Setup:**
```bash
pip install pre-commit
pre-commit install
@@ -98,6 +105,7 @@ pre-commit install
**File:** `docs/IPC_API_MIGRATION_PLAN.md`
**Comprehensive 30-page migration guide:**
- ✅ Why migrate (SWIG deprecation analysis)
- ✅ IPC API architecture overview
- ✅ 4-phase migration strategy (10 days)
@@ -108,6 +116,7 @@ pre-commit install
- ✅ Timeline with day-by-day tasks
**Key Insights:**
- SWIG will be removed in KiCAD 10.0
- IPC is faster for some operations
- Protocol Buffers ensure API stability
@@ -156,6 +165,7 @@ pre-commit install
**Total Lines of Code:** ~800 lines
**Architecture:**
```python
from kicad_api import create_backend
@@ -173,6 +183,7 @@ if backend.connect():
```
**Key Features:**
- ✅ Abstraction allows painless migration
- ✅ Both backends can coexist during transition
- ✅ Easy testing (compare SWIG vs IPC outputs)
@@ -187,6 +198,7 @@ if backend.connect():
**File:** `package.json`
**Improvements:**
- ✅ Version bumped to `2.0.0-alpha.1`
- ✅ Better description
- ✅ Enhanced npm scripts:
@@ -211,6 +223,7 @@ if backend.connect():
### Files Created/Modified (Session 2)
**New Files (10):**
```
docs/IPC_API_MIGRATION_PLAN.md # 500+ lines
docs/WEEK1_SESSION2_SUMMARY.md # This file
@@ -224,6 +237,7 @@ python/kicad_api/swig_backend.py # 220 lines
```
**Modified Files (2):**
```
README.md # Major rewrite
package.json # Enhanced scripts
@@ -247,7 +261,7 @@ package.json # Enhanced scripts
### Progress: **95% Complete** ████████████░
| Task | Status |
|------|--------|
| --------------------- | -------------------------------- |
| Linux compatibility | ✅ Complete |
| CI/CD pipeline | ✅ Complete |
| Cross-platform paths | ✅ Complete |
@@ -270,6 +284,7 @@ package.json # Enhanced scripts
### IPC API Migration Prep ✅
Everything is in place to begin migration:
- ✅ Abstraction layer architecture defined
- ✅ Base classes and interfaces ready
- ✅ Factory pattern for backend selection
@@ -278,6 +293,7 @@ Everything is in place to begin migration:
- ✅ Comprehensive migration plan documented
**Week 2 kickoff tasks:**
1. Install `kicad-python` package
2. Test IPC connection to running KiCAD
3. Begin porting `project.py` module
@@ -288,15 +304,19 @@ Everything is in place to begin migration:
## 💡 Key Insights from Session 2
### 1. **Installation Automation**
The bash script reduces setup time from 30+ minutes to < 10 minutes with zero manual intervention.
### 2. **Pre-Commit Hooks**
Automatic code quality checks prevent bugs before they're committed. This will save hours in code review.
### 3. **Abstraction Pattern**
The backend abstraction is elegant - allows gradual migration without breaking existing functionality. Users won't notice the transition.
### 4. **Documentation Quality**
The IPC migration plan is thorough enough that another developer could execute it independently.
---
@@ -308,26 +328,31 @@ The IPC migration plan is thorough enough that another developer could execute i
You can immediately test:
**1. Platform Helper:**
```bash
python3 python/utils/platform_helper.py
```
**2. Backend Detection:**
```bash
python3 python/kicad_api/factory.py
```
**3. Installation Script:**
```bash
./scripts/install-linux.sh
```
**4. Pytest Suite:**
```bash
pytest tests/ -v
```
**5. Pre-commit Hooks:**
```bash
pre-commit run --all-files
```
@@ -337,18 +362,22 @@ pre-commit run --all-files
## 📈 Impact Assessment
### Developer Onboarding
- **Before:** 2-3 hours setup, Windows-only, manual steps
- **After:** 10 minutes automated, cross-platform, one script
### Code Quality
- **Before:** No automated checks, inconsistent style
- **After:** Pre-commit hooks, 100% type hints, Black formatting
### Future-Proofing
- **Before:** Deprecated SWIG API, no migration path
- **After:** IPC API ready, abstraction layer in place
### Documentation
- **Before:** README only, Windows-focused
- **After:** 8 comprehensive docs, Linux-primary, migration guides
@@ -357,12 +386,14 @@ pre-commit run --all-files
## 🎯 Next Actions
### Immediate (Tonight/Tomorrow)
1. Install KiCAD 9.0 on your system
2. Run `./scripts/install-linux.sh`
3. Test backend detection
4. Verify pytest suite passes
### Week 2 Start (Monday)
1. Install `kicad-python` package
2. Test IPC connection
3. Begin project.py migration
@@ -373,17 +404,20 @@ pre-commit run --all-files
## 🏆 Session 2 Achievements
### Infrastructure
- ✅ Automated Linux installation
- ✅ Pre-commit hooks for code quality
- ✅ Enhanced npm scripts
- ✅ IPC API abstraction layer (800+ lines)
### Documentation
- ✅ Updated README (Linux-primary)
- ✅ 30-page IPC migration plan
- ✅ Session summaries
### Architecture
- ✅ Backend abstraction pattern
- ✅ Factory with auto-detection
- ✅ SWIG backward compatibility
@@ -394,6 +428,7 @@ pre-commit run --all-files
## 🎉 Overall Day Summary
**Sessions 1+2 Combined:**
- ⏱️ **Time:** ~4-5 hours total
- 📝 **Files:** 27 created
- 💻 **Code:** ~3,000+ lines

View File

@@ -108,25 +108,25 @@ export const toolCategories: ToolCategory[] = [
properties: {
output_dir: {
type: "string",
description: "Output directory path"
description: "Output directory path",
},
layers: {
type: "array",
items: { type: "string" },
description: "Layers to export (default: all copper + silkscreen + mask)"
description: "Layers to export (default: all copper + silkscreen + mask)",
},
format: {
type: "string",
enum: ["rs274x", "x2"],
description: "Gerber format version"
}
description: "Gerber format version",
},
required: ["output_dir"]
},
required: ["output_dir"],
},
handler: async (params) => {
// Your implementation
return { success: true, files: ["..."] };
}
},
},
{
name: "export_drill",
@@ -135,24 +135,31 @@ export const toolCategories: ToolCategory[] = [
type: "object",
properties: {
output_dir: { type: "string" },
format: { type: "string", enum: ["excellon", "excellon2"] }
format: { type: "string", enum: ["excellon", "excellon2"] },
},
required: ["output_dir"]
required: ["output_dir"],
},
handler: async (params) => {
/* ... */
},
handler: async (params) => { /* ... */ }
},
{
name: "export_bom",
description: "Export bill of materials as CSV or XML",
inputSchema: { /* ... */ },
handler: async (params) => { /* ... */ }
inputSchema: {
/* ... */
},
handler: async (params) => {
/* ... */
},
},
// ... more export tools
]
],
},
{
name: "drc",
description: "Design rule checking: clearance validation, electrical rules, manufacturing constraints",
description:
"Design rule checking: clearance validation, electrical rules, manufacturing constraints",
tools: [
{
name: "run_drc",
@@ -162,17 +169,21 @@ export const toolCategories: ToolCategory[] = [
properties: {
report_all: {
type: "boolean",
description: "Report all violations or stop at first"
}
}
description: "Report all violations or stop at first",
},
},
},
handler: async (params) => {
/* ... */
},
handler: async (params) => { /* ... */ }
},
{
name: "get_drc_errors",
description: "Get current DRC violations without re-running check",
inputSchema: { type: "object", properties: {} },
handler: async (params) => { /* ... */ }
handler: async (params) => {
/* ... */
},
},
{
name: "set_design_rules",
@@ -183,12 +194,14 @@ export const toolCategories: ToolCategory[] = [
min_clearance: { type: "number", description: "Minimum clearance in mm" },
min_track_width: { type: "number", description: "Minimum track width in mm" },
min_via_diameter: { type: "number", description: "Minimum via diameter in mm" },
min_via_drill: { type: "number", description: "Minimum via drill size in mm" }
}
min_via_drill: { type: "number", description: "Minimum via drill size in mm" },
},
handler: async (params) => { /* ... */ }
}
]
},
handler: async (params) => {
/* ... */
},
},
],
},
{
name: "zones",
@@ -208,22 +221,26 @@ export const toolCategories: ToolCategory[] = [
type: "object",
properties: {
x: { type: "number" },
y: { type: "number" }
}
y: { type: "number" },
},
description: "Polygon vertices in mm"
},
priority: { type: "number", description: "Fill priority (higher fills first)" }
description: "Polygon vertices in mm",
},
required: ["net_name", "layer", "points"]
priority: { type: "number", description: "Fill priority (higher fills first)" },
},
required: ["net_name", "layer", "points"],
},
handler: async (params) => {
/* ... */
},
handler: async (params) => { /* ... */ }
},
{
name: "fill_zones",
description: "Recalculate and fill all copper zones",
inputSchema: { type: "object", properties: {} },
handler: async (params) => { /* ... */ }
handler: async (params) => {
/* ... */
},
},
{
name: "remove_zone",
@@ -231,13 +248,15 @@ export const toolCategories: ToolCategory[] = [
inputSchema: {
type: "object",
properties: {
zone_id: { type: "string", description: "Zone identifier" }
zone_id: { type: "string", description: "Zone identifier" },
},
required: ["zone_id"]
required: ["zone_id"],
},
handler: async (params) => { /* ... */ }
}
]
handler: async (params) => {
/* ... */
},
},
],
},
// Add more categories...
];
@@ -293,7 +312,7 @@ export function searchTools(query: string): Array<{
matches.push({
category: category.name,
tool: tool.name,
description: tool.description
description: tool.description,
});
}
}
@@ -313,12 +332,7 @@ These are the tools that enable discovery and execution.
```typescript
// src/tools/router.ts
import {
getAllCategories,
getCategory,
getTool,
searchTools
} from "./registry.js";
import { getAllCategories, getCategory, getTool, searchTools } from "./registry.js";
export const routerTools = {
list_tool_categories: {
@@ -329,20 +343,20 @@ export const routerTools = {
inputSchema: {
type: "object" as const,
properties: {},
required: []
required: [],
},
handler: async () => {
const categories = getAllCategories();
return {
total_categories: categories.length,
total_tools: categories.reduce((sum, c) => sum + c.tools.length, 0),
categories: categories.map(c => ({
categories: categories.map((c) => ({
name: c.name,
description: c.description,
tool_count: c.tools.length
}))
tool_count: c.tools.length,
})),
};
}
},
},
get_category_tools: {
@@ -356,29 +370,29 @@ export const routerTools = {
properties: {
category: {
type: "string",
description: "Category name from list_tool_categories"
}
description: "Category name from list_tool_categories",
},
required: ["category"]
},
required: ["category"],
},
handler: async (params: { category: string }) => {
const category = getCategory(params.category);
if (!category) {
return {
error: `Unknown category: ${params.category}`,
available_categories: getAllCategories().map(c => c.name)
available_categories: getAllCategories().map((c) => c.name),
};
}
return {
category: category.name,
description: category.description,
tools: category.tools.map(t => ({
tools: category.tools.map((t) => ({
name: t.name,
description: t.description,
parameters: t.inputSchema
}))
parameters: t.inputSchema,
})),
};
}
},
},
execute_tool: {
@@ -391,21 +405,21 @@ export const routerTools = {
properties: {
tool_name: {
type: "string",
description: "Tool name (from get_category_tools)"
description: "Tool name (from get_category_tools)",
},
params: {
type: "object",
description: "Tool parameters (see get_category_tools for schema)"
}
description: "Tool parameters (see get_category_tools for schema)",
},
required: ["tool_name"]
},
required: ["tool_name"],
},
handler: async (input: { tool_name: string; params?: Record<string, unknown> }) => {
const entry = getTool(input.tool_name);
if (!entry) {
return {
error: `Unknown tool: ${input.tool_name}`,
hint: "Use list_tool_categories and get_category_tools to find available tools"
hint: "Use list_tool_categories and get_category_tools to find available tools",
};
}
@@ -414,16 +428,16 @@ export const routerTools = {
return {
tool: input.tool_name,
category: entry.category,
result
result,
};
} catch (err) {
return {
error: `Tool execution failed: ${(err as Error).message}`,
tool: input.tool_name,
category: entry.category
category: entry.category,
};
}
}
},
},
search_tools: {
@@ -436,20 +450,20 @@ export const routerTools = {
properties: {
query: {
type: "string",
description: "Search term (e.g., 'gerber', 'zone', 'differential', 'export')"
}
description: "Search term (e.g., 'gerber', 'zone', 'differential', 'export')",
},
required: ["query"]
},
required: ["query"],
},
handler: async (params: { query: string }) => {
const matches = searchTools(params.query);
return {
query: params.query,
count: matches.length,
matches: matches.slice(0, 20) // Limit results
matches: matches.slice(0, 20), // Limit results
};
}
}
},
},
};
```
@@ -478,15 +492,15 @@ export const directTools: ToolDefinition[] = [
template: {
type: "string",
description: "Optional template to use",
enum: ["blank", "arduino", "raspberry-pi"]
}
enum: ["blank", "arduino", "raspberry-pi"],
},
required: ["name", "path"]
},
required: ["name", "path"],
},
handler: async (params) => {
// Implementation
return { success: true, project_path: `${params.path}/${params.name}` };
}
},
},
{
name: "open_project",
@@ -494,29 +508,35 @@ export const directTools: ToolDefinition[] = [
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Path to project file or directory" }
path: { type: "string", description: "Path to project file or directory" },
},
required: ["path"]
required: ["path"],
},
handler: async (params) => {
/* ... */
},
handler: async (params) => { /* ... */ }
},
{
name: "save_project",
description: "Save all project files",
inputSchema: {
type: "object",
properties: {}
properties: {},
},
handler: async (params) => {
/* ... */
},
handler: async (params) => { /* ... */ }
},
{
name: "get_project_info",
description: "Get current project information: path, files, status",
inputSchema: {
type: "object",
properties: {}
properties: {},
},
handler: async (params) => {
/* ... */
},
handler: async (params) => { /* ... */ }
},
// === PRIMARY OPERATIONS (your core workflow) ===
@@ -530,11 +550,13 @@ export const directTools: ToolDefinition[] = [
reference: { type: "string", description: "Reference designator (e.g., R1, U1)" },
x: { type: "number", description: "X position" },
y: { type: "number", description: "Y position" },
rotation: { type: "number", description: "Rotation in degrees", default: 0 }
rotation: { type: "number", description: "Rotation in degrees", default: 0 },
},
required: ["type", "reference", "x", "y"]
required: ["type", "reference", "x", "y"],
},
handler: async (params) => {
/* ... */
},
handler: async (params) => { /* ... */ }
},
{
name: "move_component",
@@ -544,11 +566,13 @@ export const directTools: ToolDefinition[] = [
properties: {
reference: { type: "string", description: "Component reference (e.g., R1)" },
x: { type: "number", description: "New X position" },
y: { type: "number", description: "New Y position" }
y: { type: "number", description: "New Y position" },
},
required: ["reference", "x", "y"]
required: ["reference", "x", "y"],
},
handler: async (params) => {
/* ... */
},
handler: async (params) => { /* ... */ }
},
{
name: "list_components",
@@ -556,10 +580,12 @@ export const directTools: ToolDefinition[] = [
inputSchema: {
type: "object",
properties: {
filter: { type: "string", description: "Optional filter (e.g., 'R*' for resistors)" }
}
filter: { type: "string", description: "Optional filter (e.g., 'R*' for resistors)" },
},
},
handler: async (params) => {
/* ... */
},
handler: async (params) => { /* ... */ }
},
// === SECONDARY OPERATIONS (still common) ===
@@ -571,36 +597,42 @@ export const directTools: ToolDefinition[] = [
properties: {
start: {
type: "object",
properties: { x: { type: "number" }, y: { type: "number" } }
properties: { x: { type: "number" }, y: { type: "number" } },
},
end: {
type: "object",
properties: { x: { type: "number" }, y: { type: "number" } }
properties: { x: { type: "number" }, y: { type: "number" } },
},
net: { type: "string", description: "Net name" }
net: { type: "string", description: "Net name" },
},
required: ["start", "end"]
required: ["start", "end"],
},
handler: async (params) => {
/* ... */
},
handler: async (params) => { /* ... */ }
},
{
name: "list_nets",
description: "List all nets/connections",
inputSchema: {
type: "object",
properties: {}
properties: {},
},
handler: async (params) => {
/* ... */
},
handler: async (params) => { /* ... */ }
},
{
name: "get_info",
description: "Get general information about current state",
inputSchema: {
type: "object",
properties: {}
properties: {},
},
handler: async (params) => {
/* ... */
},
},
handler: async (params) => { /* ... */ }
}
];
```
@@ -613,10 +645,7 @@ Wire everything together in your main server file.
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { directTools } from "./tools/direct.js";
import { routerTools } from "./tools/router.js";
@@ -634,14 +663,11 @@ const server = new Server(
capabilities: {
tools: {},
},
}
},
);
// Combine all visible tools
const allVisibleTools = [
...directTools,
...Object.values(routerTools)
];
const allVisibleTools = [...directTools, ...Object.values(routerTools)];
// Build a handler map for quick lookup
const toolHandlers = new Map<string, (params: any) => Promise<any>>();
@@ -656,7 +682,7 @@ for (const tool of Object.values(routerTools)) {
// List tools handler - returns only direct + router tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: allVisibleTools.map(tool => ({
tools: allVisibleTools.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
@@ -676,9 +702,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
type: "text",
text: JSON.stringify({
error: `Unknown tool: ${name}`,
hint: "Use list_tool_categories and search_tools to find available tools"
})
}
hint: "Use list_tool_categories and search_tools to find available tools",
}),
},
],
isError: true,
};
@@ -690,8 +716,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2)
}
text: JSON.stringify(result, null, 2),
},
],
};
} catch (error) {
@@ -700,9 +726,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
{
type: "text",
text: JSON.stringify({
error: `Tool execution failed: ${(error as Error).message}`
})
}
error: `Tool execution failed: ${(error as Error).message}`,
}),
},
],
isError: true,
};
@@ -735,7 +761,7 @@ Include tools that are:
**Examples by domain:**
| Domain | Direct Tools |
|--------|-------------|
| ------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| **KiCAD** | create_project, open_project, save_project, add_component, move_component, add_track, list_components, list_nets, get_board_info |
| **IDA Pro** | open_database, save_database, get_function, list_functions, add_comment, rename, get_xrefs, decompile |
| **Git** | status, add, commit, push, pull, checkout, branch, log |
@@ -754,7 +780,7 @@ Include tools that are:
**Examples:**
| Category | Why Route It |
|----------|-------------|
| ----------------- | ---------------------------- |
| `export` | Only used at end of workflow |
| `drc/validation` | Used during review phase |
| `advanced_*` | Specialty operations |
@@ -854,52 +880,52 @@ const categories = [
{
name: "disassembly",
description: "Disassembly operations: undefine, make code/data, change types",
tools: ["make_code", "make_data", "undefine", "set_type", "make_array", "make_struct"]
tools: ["make_code", "make_data", "undefine", "set_type", "make_array", "make_struct"],
},
{
name: "functions",
description: "Function management: create, delete, modify boundaries, set types",
tools: ["create_function", "delete_function", "set_func_end", "set_func_type", "add_func_arg"]
tools: ["create_function", "delete_function", "set_func_end", "set_func_type", "add_func_arg"],
},
{
name: "types",
description: "Type system: structs, enums, typedefs, parse headers",
tools: ["create_struct", "add_struct_member", "create_enum", "parse_header", "import_types"]
tools: ["create_struct", "add_struct_member", "create_enum", "parse_header", "import_types"],
},
{
name: "patching",
description: "Binary patching: modify bytes, assemble, apply patches",
tools: ["patch_bytes", "patch_word", "patch_dword", "assemble", "apply_patches"]
tools: ["patch_bytes", "patch_word", "patch_dword", "assemble", "apply_patches"],
},
{
name: "scripting",
description: "IDAPython scripting: run scripts, evaluate expressions",
tools: ["run_script", "eval_python", "get_global", "set_global"]
tools: ["run_script", "eval_python", "get_global", "set_global"],
},
{
name: "signatures",
description: "Signatures and patterns: FLIRT, Lumina, create signatures",
tools: ["apply_flirt", "query_lumina", "create_sig", "find_pattern"]
tools: ["apply_flirt", "query_lumina", "create_sig", "find_pattern"],
},
{
name: "debugging",
description: "Debugger control: breakpoints, stepping, memory",
tools: ["set_breakpoint", "step_into", "step_over", "read_memory", "write_memory", "get_regs"]
tools: ["set_breakpoint", "step_into", "step_over", "read_memory", "write_memory", "get_regs"],
},
{
name: "export",
description: "Export: ASM listing, pseudocode, database info, reports",
tools: ["export_asm", "export_c", "export_json", "generate_report"]
tools: ["export_asm", "export_c", "export_json", "generate_report"],
},
{
name: "import",
description: "Import: symbols, types, comments from external sources",
tools: ["import_symbols", "import_pdb", "import_map", "import_comments"]
tools: ["import_symbols", "import_pdb", "import_map", "import_comments"],
},
{
name: "analysis",
description: "Analysis control: reanalyze, find patterns, auto-analysis settings",
tools: ["reanalyze", "find_crypto", "find_strings", "analyze_calls", "set_analysis_options"]
tools: ["reanalyze", "find_crypto", "find_strings", "analyze_calls", "set_analysis_options"],
},
];
```
@@ -962,19 +988,14 @@ Claude: "I've added length tuning meanders to match the trace lengths"
// tests/router.test.ts
import { describe, it, expect } from "vitest";
import {
searchTools,
getCategory,
getTool,
getAllCategories
} from "../src/tools/registry.js";
import { searchTools, getCategory, getTool, getAllCategories } from "../src/tools/registry.js";
import { routerTools } from "../src/tools/router.js";
describe("Tool Registry", () => {
it("should find tools by keyword", () => {
const results = searchTools("export");
expect(results.length).toBeGreaterThan(0);
expect(results.some(r => r.tool.includes("export"))).toBe(true);
expect(results.some((r) => r.tool.includes("export"))).toBe(true);
});
it("should return category info", () => {
@@ -998,7 +1019,7 @@ describe("Router Tools", () => {
it("get_category_tools returns tools for valid category", async () => {
const result = await routerTools.get_category_tools.handler({
category: "export"
category: "export",
});
expect(result.tools).toBeDefined();
expect(result.tools.length).toBeGreaterThan(0);
@@ -1006,7 +1027,7 @@ describe("Router Tools", () => {
it("get_category_tools returns error for invalid category", async () => {
const result = await routerTools.get_category_tools.handler({
category: "nonexistent"
category: "nonexistent",
});
expect(result.error).toBeDefined();
});
@@ -1014,7 +1035,7 @@ describe("Router Tools", () => {
it("execute_tool runs valid tool", async () => {
const result = await routerTools.execute_tool.handler({
tool_name: "export_gerber",
params: { output_dir: "/tmp/test" }
params: { output_dir: "/tmp/test" },
});
expect(result.error).toBeUndefined();
});
@@ -1022,7 +1043,7 @@ describe("Router Tools", () => {
it("execute_tool returns error for invalid tool", async () => {
const result = await routerTools.execute_tool.handler({
tool_name: "nonexistent_tool",
params: {}
params: {},
});
expect(result.error).toBeDefined();
});
@@ -1142,7 +1163,7 @@ Before shipping your router-based MCP server:
## Summary
| Before | After |
|--------|-------|
| ------------------------ | --------------------- |
| 100 tools visible | 15-18 tools visible |
| ~60K+ tokens consumed | ~10K tokens consumed |
| Tool selection confusion | Clear categories |

285
download_jlcpcb.py Normal file
View File

@@ -0,0 +1,285 @@
#!/usr/bin/env python3
"""
Download JLCPCB parts database from yaqwsx/jlcparts pre-built cache.
This downloads the full JLCPCB catalog (~421MB compressed, ~1.5GB SQLite)
from GitHub Pages in ~5 minutes instead of the broken JLCSearch API approach.
The cache.sqlite3 file contains all JLCPCB parts with stock, pricing,
and category data. We then convert it into the format expected by the
KiCad MCP server's JLCPCBPartsManager.
"""
import json
import os
import sqlite3
import subprocess
import sys
import time
from pathlib import Path
DATA_DIR = Path(__file__).parent / "data"
DATA_DIR.mkdir(exist_ok=True)
CACHE_DIR = DATA_DIR / "jlcparts_cache"
CACHE_DIR.mkdir(exist_ok=True)
BASE_URL = "https://yaqwsx.github.io/jlcparts/data"
PARTS = [f"cache.z{i:02d}" for i in range(1, 20)] + ["cache.zip"]
TARGET_DB = DATA_DIR / "jlcpcb_parts.db"
def download_files():
"""Download all split archive parts."""
print("Downloading jlcparts database (~421MB)...")
for part in PARTS:
dest = CACHE_DIR / part
if dest.exists() and dest.stat().st_size > 1000:
print(f" {part} already exists, skipping")
continue
url = f"{BASE_URL}/{part}"
print(f" Downloading {part}...")
result = subprocess.run(
["curl", "-L", "-o", str(dest), "--progress-bar", url], capture_output=False
)
if result.returncode != 0:
print(f" ERROR downloading {part}")
return False
return True
def extract_database():
"""Extract the split 7z archive to get cache.sqlite3."""
cache_sqlite = CACHE_DIR / "cache.sqlite3"
if cache_sqlite.exists() and cache_sqlite.stat().st_size > 100_000_000:
print(f"cache.sqlite3 already extracted ({cache_sqlite.stat().st_size // (1024*1024)}MB)")
return True
print("Extracting archive (requires 7z or p7zip)...")
# Try 7z first, then 7zz (homebrew)
for cmd in ["7z", "7zz", "7za"]:
try:
result = subprocess.run(
[cmd, "x", "-y", "-o" + str(CACHE_DIR), str(CACHE_DIR / "cache.zip")],
capture_output=True,
text=True,
)
if result.returncode == 0:
print(f"Extracted with {cmd}")
return True
else:
print(f" {cmd} failed: {result.stderr[:200]}")
except FileNotFoundError:
continue
print("\nERROR: 7z not found. Install with: brew install p7zip")
return False
def convert_to_mcp_format():
"""Convert jlcparts cache.sqlite3 to the MCP server's expected format."""
source = CACHE_DIR / "cache.sqlite3"
if not source.exists():
print("ERROR: cache.sqlite3 not found")
return False
print(f"Reading source database...")
src = sqlite3.connect(str(source))
src.row_factory = sqlite3.Row
# Check schema
tables = [
r[0] for r in src.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
]
print(f" Source tables: {tables}")
# Find the main components table
comp_table = None
for t in tables:
count = src.execute(f"SELECT COUNT(*) FROM [{t}]").fetchone()[0]
print(f" {t}: {count:,} rows")
if count > 10000 and comp_table is None:
comp_table = t
if not comp_table:
# Try 'components' specifically
comp_table = "components" if "components" in tables else tables[0]
# Get column names
cols = [r[1] for r in src.execute(f"PRAGMA table_info([{comp_table}])").fetchall()]
print(f" Using table '{comp_table}' with columns: {cols[:10]}...")
# Remove old target DB
if TARGET_DB.exists():
TARGET_DB.unlink()
# Create target DB in MCP format
dst = sqlite3.connect(str(TARGET_DB))
dst.execute("""
CREATE TABLE components (
lcsc TEXT PRIMARY KEY,
category TEXT,
subcategory TEXT,
mfr_part TEXT,
package TEXT,
solder_joints INTEGER,
manufacturer TEXT,
library_type TEXT,
description TEXT,
datasheet TEXT,
stock INTEGER,
price_json TEXT,
last_updated INTEGER
)
""")
dst.execute("CREATE INDEX idx_category ON components(category, subcategory)")
dst.execute("CREATE INDEX idx_package ON components(package)")
dst.execute("CREATE INDEX idx_manufacturer ON components(manufacturer)")
dst.execute("CREATE INDEX idx_library_type ON components(library_type)")
dst.execute("CREATE INDEX idx_mfr_part ON components(mfr_part)")
# Map source columns to our schema
# jlcparts schema varies but commonly has:
# lcsc, mfr, description, joint, manufacturer, basic, preferred, stock, price, url, etc.
print(f"\nConverting parts to MCP format...")
now = int(time.time())
batch = []
count = 0
for row in src.execute(f"SELECT * FROM [{comp_table}]"):
row_dict = dict(row)
# Adapt column names (jlcparts uses various schemas)
lcsc = row_dict.get("lcsc") or row_dict.get("LCSC_Part") or row_dict.get("lcsc_id")
if lcsc is None:
continue
if isinstance(lcsc, int):
lcsc = f"C{lcsc}"
elif not str(lcsc).startswith("C"):
lcsc = f"C{lcsc}"
mfr_part = row_dict.get("mfr") or row_dict.get("MFR_Part") or row_dict.get("mfr_part") or ""
package = row_dict.get("package") or row_dict.get("Package") or ""
manufacturer = row_dict.get("manufacturer") or row_dict.get("Manufacturer") or ""
description = row_dict.get("description") or row_dict.get("Description") or ""
stock = row_dict.get("stock") or row_dict.get("Stock") or 0
category = row_dict.get("category") or row_dict.get("First Category") or ""
subcategory = row_dict.get("subcategory") or row_dict.get("Second Category") or ""
datasheet = row_dict.get("datasheet") or row_dict.get("url") or ""
# Library type
is_basic = row_dict.get("basic") or row_dict.get("is_basic") or row_dict.get("Basic")
is_preferred = (
row_dict.get("preferred") or row_dict.get("is_preferred") or row_dict.get("Preferred")
)
if is_basic:
lib_type = "Basic"
elif is_preferred:
lib_type = "Preferred"
else:
lib_type = "Extended"
# Price
price = row_dict.get("price") or row_dict.get("Price") or 0
price_json = json.dumps([{"qty": 1, "price": price}] if price else [])
batch.append(
(
str(lcsc),
category,
subcategory,
mfr_part,
package,
0,
manufacturer,
lib_type,
description,
datasheet,
int(stock) if stock else 0,
price_json,
now,
)
)
if len(batch) >= 10000:
dst.executemany(
"""
INSERT OR REPLACE INTO components
(lcsc, category, subcategory, mfr_part, package,
solder_joints, manufacturer, library_type, description,
datasheet, stock, price_json, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
batch,
)
count += len(batch)
batch = []
if count % 100000 == 0:
print(f" Converted {count:,} parts...")
if batch:
dst.executemany(
"""
INSERT OR REPLACE INTO components
(lcsc, category, subcategory, mfr_part, package,
solder_joints, manufacturer, library_type, description,
datasheet, stock, price_json, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
batch,
)
count += len(batch)
# Build FTS index
print(f" Building full-text search index...")
dst.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS components_fts USING fts5(
lcsc, description, mfr_part, manufacturer,
content=components
)
""")
dst.execute("INSERT INTO components_fts(components_fts) VALUES('rebuild')")
dst.commit()
# Stats
total = dst.execute("SELECT COUNT(*) FROM components").fetchone()[0]
basic = dst.execute("SELECT COUNT(*) FROM components WHERE library_type='Basic'").fetchone()[0]
extended = dst.execute(
"SELECT COUNT(*) FROM components WHERE library_type='Extended'"
).fetchone()[0]
dst.close()
src.close()
db_size = TARGET_DB.stat().st_size / (1024 * 1024)
print(f"\nDatabase ready: {TARGET_DB}")
print(f" Total parts: {total:,}")
print(f" Basic parts: {basic:,}")
print(f" Extended parts: {extended:,}")
print(f" DB size: {db_size:.1f} MB")
return True
def main():
print("=" * 60)
print("JLCPCB Parts Database Downloader (jlcparts method)")
print("=" * 60)
start = time.time()
if not download_files():
sys.exit(1)
if not extract_database():
sys.exit(1)
if not convert_to_mcp_format():
sys.exit(1)
elapsed = time.time() - start
print(f"\nTotal time: {elapsed/60:.1f} minutes")
print("Done! Restart the MCP server (/mcp) to use the new database.")
if __name__ == "__main__":
main()

19
eslint.config.js Normal file
View File

@@ -0,0 +1,19 @@
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ["src/**/*.ts"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-unsafe-function-type": "off",
"preserve-caught-error": "off",
},
},
{
ignores: ["dist/", "node_modules/", "**/*.js", "!eslint.config.js"],
},
);

1391
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@
"test:py": "pytest tests/ -v",
"test:coverage": "pytest tests/ --cov=python --cov-report=html --cov-report=term",
"lint": "npm run lint:ts && npm run lint:py",
"lint:ts": "eslint src/ || echo 'ESLint not configured'",
"lint:ts": "eslint src/",
"lint:py": "cd python && black . && mypy . && flake8 .",
"format": "prettier --write 'src/**/*.ts' && black python/",
"prepare": "npm run build",
@@ -40,10 +40,14 @@
},
"devDependencies": {
"@cfworker/json-schema": "^4.1.1",
"@eslint/js": "^10.0.1",
"@types/express": "^5.0.5",
"@types/glob": "^8.1.0",
"@types/node": "^20.19.0",
"eslint": "^10.1.0",
"nodemon": "^3.0.1",
"typescript": "^5.9.3"
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.2"
}
}

42
pyproject.toml Normal file
View File

@@ -0,0 +1,42 @@
[project]
name = "kicad-mcp-server"
version = "2.1.0"
requires-python = ">=3.10"
[tool.black]
line-length = 100
target-version = ["py310"]
[tool.isort]
profile = "black"
line_length = 100
[tool.mypy]
python_version = "3.10"
warn_return_any = false
warn_unused_configs = true
check_untyped_defs = false
disallow_untyped_defs = false
explicit_package_bases = true
mypy_path = ".:python"
disable_error_code = [
"arg-type",
"attr-defined",
"union-attr",
"var-annotated",
"index",
]
[[tool.mypy.overrides]]
module = [
"pcbnew",
"cairosvg",
"sexpdata",
"skip",
"kipy",
"kipy.*",
"schematic",
"PIL",
"PIL.*",
]
ignore_missing_imports = true

View File

@@ -7,7 +7,7 @@ python_classes = Test*
python_functions = test_*
# Test paths
testpaths = tests python/tests
testpaths = tests
# Minimum Python version
minversion = 6.0

View File

@@ -2,18 +2,18 @@
KiCAD command implementations package
"""
from .project import ProjectCommands
from .board import BoardCommands
from .component import ComponentCommands
from .routing import RoutingCommands
from .design_rules import DesignRuleCommands
from .export import ExportCommands
from .project import ProjectCommands
from .routing import RoutingCommands
__all__ = [
'ProjectCommands',
'BoardCommands',
'ComponentCommands',
'RoutingCommands',
'DesignRuleCommands',
'ExportCommands'
"ProjectCommands",
"BoardCommands",
"ComponentCommands",
"RoutingCommands",
"DesignRuleCommands",
"ExportCommands",
]

View File

@@ -8,4 +8,4 @@ It imports and re-exports the BoardCommands class from the board package.
from commands.board import BoardCommands
# Re-export the BoardCommands class for backward compatibility
__all__ = ['BoardCommands']
__all__ = ["BoardCommands"]

View File

@@ -2,17 +2,20 @@
Board-related command implementations for KiCAD interface
"""
import pcbnew
import logging
from typing import Dict, Any, Optional
from typing import Any, Dict, Optional
import pcbnew
from .layers import BoardLayerCommands
from .outline import BoardOutlineCommands
# Import specialized modules
from .size import BoardSizeCommands
from .layers import BoardLayerCommands
from .outline import BoardOutlineCommands
from .view import BoardViewCommands
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
class BoardCommands:
"""Handles board-related KiCAD operations"""

View File

@@ -2,11 +2,13 @@
Board layer command implementations for KiCAD interface
"""
import pcbnew
import logging
from typing import Dict, Any, Optional
from typing import Any, Dict, Optional
import pcbnew
logger = logging.getLogger("kicad_interface")
logger = logging.getLogger('kicad_interface')
class BoardLayerCommands:
"""Handles board layer operations"""
@@ -22,7 +24,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
name = params.get("name")
@@ -34,7 +36,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "name, type, and position are required"
"errorDetails": "name, type, and position are required",
}
# Get layer stack
@@ -47,7 +49,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "Missing layer number",
"errorDetails": "number is required for inner layers"
"errorDetails": "number is required for inner layers",
}
layer_id = pcbnew.In1_Cu + (number - 1)
elif position == "top":
@@ -59,7 +61,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "Invalid layer position",
"errorDetails": "position must be 'top', 'bottom', or 'inner'"
"errorDetails": "position must be 'top', 'bottom', or 'inner'",
}
# Set layer properties
@@ -72,21 +74,12 @@ class BoardLayerCommands:
return {
"success": True,
"message": f"Added layer: {name}",
"layer": {
"name": name,
"type": layer_type,
"position": position,
"number": number
}
"layer": {"name": name, "type": layer_type, "position": position, "number": number},
}
except Exception as e:
logger.error(f"Error adding layer: {str(e)}")
return {
"success": False,
"message": "Failed to add layer",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to add layer", "errorDetails": str(e)}
def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Set the active layer for PCB operations"""
@@ -95,7 +88,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
layer = params.get("layer")
@@ -103,7 +96,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "No layer specified",
"errorDetails": "layer parameter is required"
"errorDetails": "layer parameter is required",
}
# Find layer ID by name
@@ -112,7 +105,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "Layer not found",
"errorDetails": f"Layer '{layer}' does not exist"
"errorDetails": f"Layer '{layer}' does not exist",
}
# Set active layer
@@ -121,10 +114,7 @@ class BoardLayerCommands:
return {
"success": True,
"message": f"Set active layer to: {layer}",
"layer": {
"name": layer,
"id": layer_id
}
"layer": {"name": layer, "id": layer_id},
}
except Exception as e:
@@ -132,7 +122,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "Failed to set active layer",
"errorDetails": str(e)
"errorDetails": str(e),
}
def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -142,32 +132,27 @@ class BoardLayerCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
layers = []
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
if self.board.IsLayerEnabled(layer_id):
layers.append({
layers.append(
{
"name": self.board.GetLayerName(layer_id),
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
"id": layer_id
"id": layer_id,
# Note: isActive removed - GetActiveLayer() doesn't exist in KiCAD 9.0
# Active layer is a UI concept not applicable to headless scripting
})
return {
"success": True,
"layers": layers
}
)
return {"success": True, "layers": layers}
except Exception as e:
logger.error(f"Error getting layer list: {str(e)}")
return {
"success": False,
"message": "Failed to get layer list",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to get layer list", "errorDetails": str(e)}
def _get_layer_type(self, type_name: str) -> int:
"""Convert layer type name to KiCAD layer type constant"""
@@ -175,7 +160,7 @@ class BoardLayerCommands:
"copper": pcbnew.LT_SIGNAL,
"technical": pcbnew.LT_SIGNAL,
"user": pcbnew.LT_SIGNAL, # LT_USER removed in KiCAD 9.0, use LT_SIGNAL instead
"signal": pcbnew.LT_SIGNAL
"signal": pcbnew.LT_SIGNAL,
}
return type_map.get(type_name.lower(), pcbnew.LT_SIGNAL)
@@ -185,7 +170,7 @@ class BoardLayerCommands:
pcbnew.LT_SIGNAL: "signal",
pcbnew.LT_POWER: "power",
pcbnew.LT_MIXED: "mixed",
pcbnew.LT_JUMPER: "jumper"
pcbnew.LT_JUMPER: "jumper",
}
# Note: LT_USER was removed in KiCAD 9.0
return type_map.get(type_id, "unknown")

View File

@@ -2,10 +2,11 @@
Board outline command implementations for KiCAD interface
"""
import pcbnew
import logging
import math
from typing import Dict, Any, Optional
from typing import Any, Dict, Optional
import pcbnew
logger = logging.getLogger("kicad_interface")
@@ -224,9 +225,7 @@ class BoardOutlineCommands:
}
# Convert to internal units (nanometers)
scale = (
1000000 if position.get("unit", "mm") == "mm" else 25400000
) # mm or inch to nm
scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
x_nm = int(position["x"] * scale)
y_nm = int(position["y"] * scale)
diameter_nm = int(diameter * scale)
@@ -252,9 +251,7 @@ class BoardOutlineCommands:
pad = pcbnew.PAD(module)
pad.SetNumber(1)
pad.SetShape(pcbnew.PAD_SHAPE_CIRCLE)
pad.SetAttribute(
pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH
)
pad.SetAttribute(pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH)
pad.SetSize(pcbnew.VECTOR2I(pad_diameter_nm, pad_diameter_nm))
pad.SetDrillSize(pcbnew.VECTOR2I(diameter_nm, diameter_nm))
pad.SetPosition(pcbnew.VECTOR2I(0, 0)) # Position relative to module
@@ -311,9 +308,7 @@ class BoardOutlineCommands:
}
# Convert to internal units (nanometers)
scale = (
1000000 if position.get("unit", "mm") == "mm" else 25400000
) # mm or inch to nm
scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
x_nm = int(position["x"] * scale)
y_nm = int(position["y"] * scale)
size_nm = int(size * scale)
@@ -372,9 +367,7 @@ class BoardOutlineCommands:
"errorDetails": str(e),
}
def _add_edge_line(
self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int
) -> None:
def _add_edge_line(self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int) -> None:
"""Add a line to the edge cuts layer"""
line = pcbnew.PCB_SHAPE(self.board)
line.SetShape(pcbnew.SHAPE_T_SEGMENT)
@@ -396,18 +389,12 @@ class BoardOutlineCommands:
"""Add a rounded rectangle to the edge cuts layer"""
if radius_nm <= 0:
# If no radius, create regular rectangle
top_left = pcbnew.VECTOR2I(
center_x_nm - width_nm // 2, center_y_nm - height_nm // 2
)
top_right = pcbnew.VECTOR2I(
center_x_nm + width_nm // 2, center_y_nm - height_nm // 2
)
top_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm - height_nm // 2)
top_right = pcbnew.VECTOR2I(center_x_nm + width_nm // 2, center_y_nm - height_nm // 2)
bottom_right = pcbnew.VECTOR2I(
center_x_nm + width_nm // 2, center_y_nm + height_nm // 2
)
bottom_left = pcbnew.VECTOR2I(
center_x_nm - width_nm // 2, center_y_nm + height_nm // 2
)
bottom_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm + height_nm // 2)
self._add_edge_line(top_left, top_right, layer)
self._add_edge_line(top_right, bottom_right, layer)

View File

@@ -2,11 +2,13 @@
Board size command implementations for KiCAD interface
"""
import pcbnew
import logging
from typing import Dict, Any, Optional
from typing import Any, Dict, Optional
import pcbnew
logger = logging.getLogger("kicad_interface")
logger = logging.getLogger('kicad_interface')
class BoardSizeCommands:
"""Handles board size operations"""
@@ -22,7 +24,7 @@ class BoardSizeCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
width = params.get("width")
@@ -33,41 +35,36 @@ class BoardSizeCommands:
return {
"success": False,
"message": "Missing dimensions",
"errorDetails": "Both width and height are required"
"errorDetails": "Both width and height are required",
}
# Create board outline using BoardOutlineCommands
# This properly creates edge cuts on Edge.Cuts layer
from commands.board.outline import BoardOutlineCommands
outline_commands = BoardOutlineCommands(self.board)
# Create rectangular outline centered at origin
result = outline_commands.add_board_outline({
result = outline_commands.add_board_outline(
{
"shape": "rectangle",
"centerX": width / 2, # Center X
"centerY": height / 2, # Center Y
"width": width,
"height": height,
"unit": unit
})
"unit": unit,
}
)
if result.get("success"):
return {
"success": True,
"message": f"Created board outline: {width}x{height} {unit}",
"size": {
"width": width,
"height": height,
"unit": unit
}
"size": {"width": width, "height": height, "unit": unit},
}
else:
return result
except Exception as e:
logger.error(f"Error setting board size: {str(e)}")
return {
"success": False,
"message": "Failed to set board size",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to set board size", "errorDetails": str(e)}

View File

@@ -2,15 +2,17 @@
Board view command implementations for KiCAD interface
"""
import os
import pcbnew
import logging
from typing import Dict, Any, Optional, List, Tuple
from PIL import Image
import io
import base64
import io
import logging
import os
from typing import Any, Dict, List, Optional, Tuple
import pcbnew
from PIL import Image
logger = logging.getLogger("kicad_interface")
logger = logging.getLogger('kicad_interface')
class BoardViewCommands:
"""Handles board viewing operations"""
@@ -26,7 +28,7 @@ class BoardViewCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
# Get board dimensions
@@ -42,26 +44,24 @@ class BoardViewCommands:
layers = []
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
if self.board.IsLayerEnabled(layer_id):
layers.append({
layers.append(
{
"name": self.board.GetLayerName(layer_id),
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
"id": layer_id
})
"id": layer_id,
}
)
return {
"success": True,
"board": {
"filename": self.board.GetFileName(),
"size": {
"width": width_mm,
"height": height_mm,
"unit": "mm"
},
"size": {"width": width_mm, "height": height_mm, "unit": "mm"},
"layers": layers,
"title": self.board.GetTitleBlock().GetTitle()
"title": self.board.GetTitleBlock().GetTitle(),
# Note: activeLayer removed - GetActiveLayer() doesn't exist in KiCAD 9.0
# Active layer is a UI concept not applicable to headless scripting
}
},
}
except Exception as e:
@@ -69,7 +69,7 @@ class BoardViewCommands:
return {
"success": False,
"message": "Failed to get board information",
"errorDetails": str(e)
"errorDetails": str(e),
}
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -79,7 +79,7 @@ class BoardViewCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
# Get parameters
@@ -126,17 +126,14 @@ class BoardViewCommands:
# Convert SVG to requested format
if format == "svg":
with open(temp_svg, 'r') as f:
with open(temp_svg, "r") as f:
svg_data = f.read()
os.remove(temp_svg)
return {
"success": True,
"imageData": svg_data,
"format": "svg"
}
return {"success": True, "imageData": svg_data, "format": "svg"}
else:
# Use PIL to convert SVG to PNG/JPG
from cairosvg import svg2png
png_data = svg2png(url=temp_svg, output_width=width, output_height=height)
os.remove(temp_svg)
@@ -144,18 +141,18 @@ class BoardViewCommands:
# Convert PNG to JPG
img = Image.open(io.BytesIO(png_data))
jpg_buffer = io.BytesIO()
img.convert('RGB').save(jpg_buffer, format='JPEG')
img.convert("RGB").save(jpg_buffer, format="JPEG")
jpg_data = jpg_buffer.getvalue()
return {
"success": True,
"imageData": base64.b64encode(jpg_data).decode('utf-8'),
"format": "jpg"
"imageData": base64.b64encode(jpg_data).decode("utf-8"),
"format": "jpg",
}
else:
return {
"success": True,
"imageData": base64.b64encode(png_data).decode('utf-8'),
"format": "png"
"imageData": base64.b64encode(png_data).decode("utf-8"),
"format": "png",
}
except Exception as e:
@@ -163,7 +160,7 @@ class BoardViewCommands:
return {
"success": False,
"message": "Failed to get board 2D view",
"errorDetails": str(e)
"errorDetails": str(e),
}
def _get_layer_type_name(self, type_id: int) -> str:
@@ -172,7 +169,7 @@ class BoardViewCommands:
pcbnew.LT_SIGNAL: "signal",
pcbnew.LT_POWER: "power",
pcbnew.LT_MIXED: "mixed",
pcbnew.LT_JUMPER: "jumper"
pcbnew.LT_JUMPER: "jumper",
}
# Note: LT_USER was removed in KiCAD 9.0
return type_map.get(type_id, "unknown")
@@ -184,7 +181,7 @@ class BoardViewCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
# Get unit preference (default to mm)
@@ -215,12 +212,9 @@ class BoardViewCommands:
"bottom": bottom,
"width": width,
"height": height,
"center": {
"x": center_x,
"y": center_y
"center": {"x": center_x, "y": center_y},
"unit": unit,
},
"unit": unit
}
}
except Exception as e:
@@ -228,5 +222,5 @@ class BoardViewCommands:
return {
"success": False,
"message": "Failed to get board extents",
"errorDetails": str(e)
"errorDetails": str(e),
}

View File

@@ -2,20 +2,24 @@
Component-related command implementations for KiCAD interface
"""
import os
import pcbnew
import base64
import logging
import math
from typing import Dict, Any, Optional, List, Tuple
import base64
import os
from typing import Any, Dict, List, Optional, Tuple
import pcbnew
from commands.library import LibraryManager
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
class ComponentCommands:
"""Handles component-related KiCAD operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None, library_manager: Optional[LibraryManager] = None):
def __init__(
self, board: Optional[pcbnew.BOARD] = None, library_manager: Optional[LibraryManager] = None
):
"""Initialize with optional board instance and library manager"""
self.board = board
self.library_manager = library_manager or LibraryManager()
@@ -27,7 +31,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
# Get parameters
@@ -43,7 +47,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "componentId and position are required"
"errorDetails": "componentId and position are required",
}
# Find footprint using library manager
@@ -55,13 +59,14 @@ class ComponentCommands:
suggestions = self.library_manager.search_footprints(f"*{component_id}*", limit=5)
suggestion_text = ""
if suggestions:
suggestion_text = "\n\nDid you mean one of these?\n" + \
"\n".join([f" - {s['full_name']}" for s in suggestions])
suggestion_text = "\n\nDid you mean one of these?\n" + "\n".join(
[f" - {s['full_name']}" for s in suggestions]
)
return {
"success": False,
"message": "Footprint not found",
"errorDetails": f"Could not find footprint: {component_id}{suggestion_text}"
"errorDetails": f"Could not find footprint: {component_id}{suggestion_text}",
}
library_path, footprint_name = footprint_result
@@ -78,7 +83,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Internal error",
"errorDetails": "Could not determine library nickname"
"errorDetails": "Could not determine library nickname",
}
# Load the footprint
@@ -87,7 +92,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Failed to load footprint",
"errorDetails": f"Could not load footprint from {library_path}/{footprint_name}"
"errorDetails": f"Could not load footprint from {library_path}/{footprint_name}",
}
# Set position
@@ -145,14 +150,10 @@ class ComponentCommands:
"component": {
"reference": module.GetReference(),
"value": module.GetValue(),
"position": {
"x": position["x"],
"y": position["y"],
"unit": position["unit"]
},
"position": {"x": position["x"], "y": position["y"], "unit": position["unit"]},
"rotation": rotation,
"layer": layer
}
"layer": layer,
},
}
except Exception as e:
@@ -160,7 +161,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Failed to place component",
"errorDetails": str(e)
"errorDetails": str(e),
}
def move_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -170,18 +171,19 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
reference = params.get("reference")
position = params.get("position")
rotation = params.get("rotation")
layer = params.get("layer")
if not reference or not position:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "reference and position are required"
"errorDetails": "reference and position are required",
}
# Find the component
@@ -190,7 +192,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {reference}"
"errorDetails": f"Could not find component: {reference}",
}
# Set new position
@@ -204,27 +206,30 @@ class ComponentCommands:
angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T)
module.SetOrientation(angle)
# Flip to target layer if specified
if layer:
current_layer = self.board.GetLayerName(module.GetLayer())
if layer == "B.Cu" and current_layer != "B.Cu":
module.Flip(module.GetPosition(), False)
elif layer == "F.Cu" and current_layer != "F.Cu":
module.Flip(module.GetPosition(), False)
return {
"success": True,
"message": f"Moved component: {reference}",
"component": {
"reference": reference,
"position": {
"x": position["x"],
"y": position["y"],
"unit": position["unit"]
"position": {"x": position["x"], "y": position["y"], "unit": position["unit"]},
"rotation": (
rotation if rotation is not None else module.GetOrientation().AsDegrees()
),
"layer": self.board.GetLayerName(module.GetLayer()),
},
"rotation": rotation if rotation is not None else module.GetOrientation().AsDegrees()
}
}
except Exception as e:
logger.error(f"Error moving component: {str(e)}")
return {
"success": False,
"message": "Failed to move component",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to move component", "errorDetails": str(e)}
def rotate_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Rotate an existing component"""
@@ -233,7 +238,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
reference = params.get("reference")
@@ -243,7 +248,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "reference and angle are required"
"errorDetails": "reference and angle are required",
}
# Find the component
@@ -252,7 +257,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {reference}"
"errorDetails": f"Could not find component: {reference}",
}
# Set rotation
@@ -262,10 +267,7 @@ class ComponentCommands:
return {
"success": True,
"message": f"Rotated component: {reference}",
"component": {
"reference": reference,
"rotation": angle
}
"component": {"reference": reference, "rotation": angle},
}
except Exception as e:
@@ -273,7 +275,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Failed to rotate component",
"errorDetails": str(e)
"errorDetails": str(e),
}
def delete_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -283,7 +285,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
reference = params.get("reference")
@@ -291,7 +293,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing reference",
"errorDetails": "reference parameter is required"
"errorDetails": "reference parameter is required",
}
# Find the component
@@ -300,23 +302,20 @@ class ComponentCommands:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {reference}"
"errorDetails": f"Could not find component: {reference}",
}
# Remove from board
self.board.Remove(module)
return {
"success": True,
"message": f"Deleted component: {reference}"
}
return {"success": True, "message": f"Deleted component: {reference}"}
except Exception as e:
logger.error(f"Error deleting component: {str(e)}")
return {
"success": False,
"message": "Failed to delete component",
"errorDetails": str(e)
"errorDetails": str(e),
}
def edit_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -326,7 +325,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
reference = params.get("reference")
@@ -338,7 +337,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing reference",
"errorDetails": "reference parameter is required"
"errorDetails": "reference parameter is required",
}
# Find the component
@@ -347,7 +346,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {reference}"
"errorDetails": f"Could not find component: {reference}",
}
# Update properties
@@ -375,17 +374,13 @@ class ComponentCommands:
"component": {
"reference": new_reference or reference,
"value": value or module.GetValue(),
"footprint": footprint or module.GetFPIDAsString()
}
"footprint": footprint or module.GetFPIDAsString(),
},
}
except Exception as e:
logger.error(f"Error editing component: {str(e)}")
return {
"success": False,
"message": "Failed to edit component",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to edit component", "errorDetails": str(e)}
def get_component_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get detailed properties of a component"""
@@ -394,7 +389,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
reference = params.get("reference")
@@ -402,7 +397,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing reference",
"errorDetails": "reference parameter is required"
"errorDetails": "reference parameter is required",
}
# Find the component
@@ -411,7 +406,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {reference}"
"errorDetails": f"Could not find component: {reference}",
}
# Get position in mm
@@ -425,19 +420,15 @@ class ComponentCommands:
"reference": module.GetReference(),
"value": module.GetValue(),
"footprint": module.GetFPIDAsString(),
"position": {
"x": x_mm,
"y": y_mm,
"unit": "mm"
},
"position": {"x": x_mm, "y": y_mm, "unit": "mm"},
"rotation": module.GetOrientation().AsDegrees(),
"layer": self.board.GetLayerName(module.GetLayer()),
"attributes": {
"smd": module.GetAttributes() & pcbnew.FP_SMD,
"through_hole": module.GetAttributes() & pcbnew.FP_THROUGH_HOLE,
"board_only": module.GetAttributes() & pcbnew.FP_BOARD_ONLY
}
}
"board_only": module.GetAttributes() & pcbnew.FP_BOARD_ONLY,
},
},
}
except Exception as e:
@@ -445,7 +436,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Failed to get component properties",
"errorDetails": str(e)
"errorDetails": str(e),
}
def get_component_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -455,7 +446,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
components = []
@@ -464,30 +455,25 @@ class ComponentCommands:
x_mm = pos.x / 1000000
y_mm = pos.y / 1000000
components.append({
components.append(
{
"reference": module.GetReference(),
"value": module.GetValue(),
"footprint": module.GetFPIDAsString(),
"position": {
"x": x_mm,
"y": y_mm,
"unit": "mm"
},
"position": {"x": x_mm, "y": y_mm, "unit": "mm"},
"rotation": module.GetOrientation().AsDegrees(),
"layer": self.board.GetLayerName(module.GetLayer())
})
return {
"success": True,
"components": components
"layer": self.board.GetLayerName(module.GetLayer()),
}
)
return {"success": True, "components": components}
except Exception as e:
logger.error(f"Error getting component list: {str(e)}")
return {
"success": False,
"message": "Failed to get component list",
"errorDetails": str(e)
"errorDetails": str(e),
}
def find_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -497,7 +483,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
# Get search parameters
@@ -509,7 +495,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing search criteria",
"errorDetails": "At least one of reference, value, or footprint pattern is required"
"errorDetails": "At least one of reference, value, or footprint pattern is required",
}
matches = []
@@ -529,31 +515,25 @@ class ComponentCommands:
if match:
pos = module.GetPosition()
matches.append({
matches.append(
{
"reference": module.GetReference(),
"value": module.GetValue(),
"footprint": module.GetFPIDAsString(),
"position": {
"x": pos.x / 1000000,
"y": pos.y / 1000000,
"unit": "mm"
},
"position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"},
"rotation": module.GetOrientation().AsDegrees(),
"layer": self.board.GetLayerName(module.GetLayer())
})
return {
"success": True,
"matchCount": len(matches),
"components": matches
"layer": self.board.GetLayerName(module.GetLayer()),
}
)
return {"success": True, "matchCount": len(matches), "components": matches}
except Exception as e:
logger.error(f"Error finding components: {str(e)}")
return {
"success": False,
"message": "Failed to find components",
"errorDetails": str(e)
"errorDetails": str(e),
}
def get_component_pads(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -563,7 +543,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
reference = params.get("reference")
@@ -571,7 +551,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing reference",
"errorDetails": "reference parameter is required"
"errorDetails": "reference parameter is required",
}
# Find the component
@@ -580,7 +560,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {reference}"
"errorDetails": f"Could not find component: {reference}",
}
pads = []
@@ -596,7 +576,7 @@ class ComponentCommands:
pcbnew.PAD_SHAPE_TRAPEZOID: "trapezoid",
pcbnew.PAD_SHAPE_ROUNDRECT: "roundrect",
pcbnew.PAD_SHAPE_CHAMFERED_RECT: "chamfered_rect",
pcbnew.PAD_SHAPE_CUSTOM: "custom"
pcbnew.PAD_SHAPE_CUSTOM: "custom",
}
shape = shape_map.get(pad.GetShape(), "unknown")
@@ -605,29 +585,25 @@ class ComponentCommands:
pcbnew.PAD_ATTRIB_PTH: "through_hole",
pcbnew.PAD_ATTRIB_SMD: "smd",
pcbnew.PAD_ATTRIB_CONN: "connector",
pcbnew.PAD_ATTRIB_NPTH: "npth"
pcbnew.PAD_ATTRIB_NPTH: "npth",
}
pad_type = type_map.get(pad.GetAttribute(), "unknown")
pads.append({
pads.append(
{
"name": pad.GetName(),
"number": pad.GetNumber(),
"position": {
"x": pos.x / 1000000,
"y": pos.y / 1000000,
"unit": "mm"
},
"position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"},
"net": pad.GetNetname(),
"netCode": pad.GetNetCode(),
"shape": shape,
"type": pad_type,
"size": {
"x": size.x / 1000000,
"y": size.y / 1000000,
"unit": "mm"
},
"drillSize": pad.GetDrillSize().x / 1000000 if pad.GetDrillSize().x > 0 else None
})
"size": {"x": size.x / 1000000, "y": size.y / 1000000, "unit": "mm"},
"drillSize": (
pad.GetDrillSize().x / 1000000 if pad.GetDrillSize().x > 0 else None
),
}
)
# Get component position for reference
comp_pos = module.GetPosition()
@@ -638,10 +614,10 @@ class ComponentCommands:
"componentPosition": {
"x": comp_pos.x / 1000000,
"y": comp_pos.y / 1000000,
"unit": "mm"
"unit": "mm",
},
"padCount": len(pads),
"pads": pads
"pads": pads,
}
except Exception as e:
@@ -649,7 +625,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Failed to get component pads",
"errorDetails": str(e)
"errorDetails": str(e),
}
def get_pad_position(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -659,7 +635,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
reference = params.get("reference")
@@ -669,13 +645,13 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing reference",
"errorDetails": "reference parameter is required"
"errorDetails": "reference parameter is required",
}
if not pad_name:
return {
"success": False,
"message": "Missing pad identifier",
"errorDetails": "padName or padNumber parameter is required"
"errorDetails": "padName or padNumber parameter is required",
}
# Find the component
@@ -684,7 +660,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {reference}"
"errorDetails": f"Could not find component: {reference}",
}
# Find the specific pad
@@ -695,7 +671,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Pad not found",
"errorDetails": f"Pad '{pad_name}' not found on {reference}. Available pads: {', '.join(available_pads)}"
"errorDetails": f"Pad '{pad_name}' not found on {reference}. Available pads: {', '.join(available_pads)}",
}
pos = pad.GetPosition()
@@ -705,18 +681,10 @@ class ComponentCommands:
"success": True,
"reference": reference,
"padName": pad.GetNumber(),
"position": {
"x": pos.x / 1000000,
"y": pos.y / 1000000,
"unit": "mm"
},
"position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"},
"net": pad.GetNetname(),
"netCode": pad.GetNetCode(),
"size": {
"x": size.x / 1000000,
"y": size.y / 1000000,
"unit": "mm"
}
"size": {"x": size.x / 1000000, "y": size.y / 1000000, "unit": "mm"},
}
except Exception as e:
@@ -724,7 +692,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Failed to get pad position",
"errorDetails": str(e)
"errorDetails": str(e),
}
def place_component_array(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -734,7 +702,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
component_id = params.get("componentId")
@@ -747,7 +715,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "componentId and count are required"
"errorDetails": "componentId and count are required",
}
if pattern == "grid":
@@ -763,14 +731,14 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing grid parameters",
"errorDetails": "For grid pattern, startPosition, rows, columns, spacingX, and spacingY are required"
"errorDetails": "For grid pattern, startPosition, rows, columns, spacingX, and spacingY are required",
}
if rows * columns != count:
return {
"success": False,
"message": "Invalid grid parameters",
"errorDetails": "rows * columns must equal count"
"errorDetails": "rows * columns must equal count",
}
placed_components = self._place_grid_array(
@@ -783,7 +751,7 @@ class ComponentCommands:
reference_prefix,
value,
rotation,
layer
layer,
)
elif pattern == "circular":
@@ -798,7 +766,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing circular parameters",
"errorDetails": "For circular pattern, center, radius, and angleStep are required"
"errorDetails": "For circular pattern, center, radius, and angleStep are required",
}
placed_components = self._place_circular_array(
@@ -811,20 +779,20 @@ class ComponentCommands:
reference_prefix,
value,
rotation_offset,
layer
layer,
)
else:
return {
"success": False,
"message": "Invalid pattern",
"errorDetails": "Pattern must be 'grid' or 'circular'"
"errorDetails": "Pattern must be 'grid' or 'circular'",
}
return {
"success": True,
"message": f"Placed {count} components in {pattern} pattern",
"components": placed_components
"components": placed_components,
}
except Exception as e:
@@ -832,7 +800,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Failed to place component array",
"errorDetails": str(e)
"errorDetails": str(e),
}
def align_components(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -842,7 +810,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
references = params.get("references", [])
@@ -854,7 +822,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing references",
"errorDetails": "At least two component references are required"
"errorDetails": "At least two component references are required",
}
# Find all referenced components
@@ -865,7 +833,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {ref}"
"errorDetails": f"Could not find component: {ref}",
}
components.append(module)
@@ -880,36 +848,34 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing edge parameter",
"errorDetails": "Edge parameter is required for edge alignment"
"errorDetails": "Edge parameter is required for edge alignment",
}
self._align_components_to_edge(components, edge)
else:
return {
"success": False,
"message": "Invalid alignment option",
"errorDetails": "Alignment must be 'horizontal', 'vertical', or 'edge'"
"errorDetails": "Alignment must be 'horizontal', 'vertical', or 'edge'",
}
# Prepare result data
aligned_components = []
for module in components:
pos = module.GetPosition()
aligned_components.append({
aligned_components.append(
{
"reference": module.GetReference(),
"position": {
"x": pos.x / 1000000,
"y": pos.y / 1000000,
"unit": "mm"
},
"rotation": module.GetOrientation().AsDegrees()
})
"position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"},
"rotation": module.GetOrientation().AsDegrees(),
}
)
return {
"success": True,
"message": f"Aligned {len(components)} components",
"alignment": alignment,
"distribution": distribution,
"components": aligned_components
"components": aligned_components,
}
except Exception as e:
@@ -917,7 +883,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Failed to align components",
"errorDetails": str(e)
"errorDetails": str(e),
}
def duplicate_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -927,7 +893,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
reference = params.get("reference")
@@ -939,7 +905,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "reference and newReference are required"
"errorDetails": "reference and newReference are required",
}
# Find the source component
@@ -948,7 +914,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {reference}"
"errorDetails": f"Could not find component: {reference}",
}
# Check if new reference already exists
@@ -956,7 +922,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Reference already exists",
"errorDetails": f"A component with reference {new_reference} already exists"
"errorDetails": f"A component with reference {new_reference} already exists",
}
# Create new footprint with the same properties
@@ -1004,14 +970,10 @@ class ComponentCommands:
"reference": new_reference,
"value": new_module.GetValue(),
"footprint": new_module.GetFPIDAsString(),
"position": {
"x": pos.x / 1000000,
"y": pos.y / 1000000,
"unit": "mm"
},
"position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"},
"rotation": new_module.GetOrientation().AsDegrees(),
"layer": self.board.GetLayerName(new_module.GetLayer())
}
"layer": self.board.GetLayerName(new_module.GetLayer()),
},
}
except Exception as e:
@@ -1019,12 +981,22 @@ class ComponentCommands:
return {
"success": False,
"message": "Failed to duplicate component",
"errorDetails": str(e)
"errorDetails": str(e),
}
def _place_grid_array(self, component_id: str, start_position: Dict[str, Any],
rows: int, columns: int, spacing_x: float, spacing_y: float,
reference_prefix: str, value: str, rotation: float, layer: str) -> List[Dict[str, Any]]:
def _place_grid_array(
self,
component_id: str,
start_position: Dict[str, Any],
rows: int,
columns: int,
spacing_x: float,
spacing_y: float,
reference_prefix: str,
value: str,
rotation: float,
layer: str,
) -> List[Dict[str, Any]]:
"""Place components in a grid pattern and return the list of placed components"""
placed = []
@@ -1048,24 +1020,35 @@ class ComponentCommands:
component_reference = f"{reference_prefix}{index}"
# Place component
result = self.place_component({
result = self.place_component(
{
"componentId": component_id,
"position": {"x": x, "y": y, "unit": unit},
"reference": component_reference,
"value": value,
"rotation": rotation,
"layer": layer
})
"layer": layer,
}
)
if result["success"]:
placed.append(result["component"])
return placed
def _place_circular_array(self, component_id: str, center: Dict[str, Any],
radius: float, count: int, angle_start: float,
angle_step: float, reference_prefix: str,
value: str, rotation_offset: float, layer: str) -> List[Dict[str, Any]]:
def _place_circular_array(
self,
component_id: str,
center: Dict[str, Any],
radius: float,
count: int,
angle_start: float,
angle_step: float,
reference_prefix: str,
value: str,
rotation_offset: float,
layer: str,
) -> List[Dict[str, Any]]:
"""Place components in a circular pattern and return the list of placed components"""
placed = []
@@ -1088,22 +1071,25 @@ class ComponentCommands:
component_rotation = angle + rotation_offset
# Place component
result = self.place_component({
result = self.place_component(
{
"componentId": component_id,
"position": {"x": x, "y": y, "unit": unit},
"reference": component_reference,
"value": value,
"rotation": component_rotation,
"layer": layer
})
"layer": layer,
}
)
if result["success"]:
placed.append(result["component"])
return placed
def _align_components_horizontally(self, components: List[pcbnew.FOOTPRINT],
distribution: str, spacing: Optional[float]) -> None:
def _align_components_horizontally(
self, components: List[pcbnew.FOOTPRINT], distribution: str, spacing: Optional[float]
) -> None:
"""Align components horizontally and optionally distribute them"""
if not components:
return
@@ -1147,8 +1133,9 @@ class ComponentCommands:
x_current += spacing_nm
components[i].SetPosition(pcbnew.VECTOR2I(x_current, pos.y))
def _align_components_vertically(self, components: List[pcbnew.FOOTPRINT],
distribution: str, spacing: Optional[float]) -> None:
def _align_components_vertically(
self, components: List[pcbnew.FOOTPRINT], distribution: str, spacing: Optional[float]
) -> None:
"""Align components vertically and optionally distribute them"""
if not components:
return

View File

@@ -1,9 +1,10 @@
from skip import Schematic
import logging
import os
import uuid
import logging
from pathlib import Path
from typing import Optional
from typing import Any, Dict, List, Optional, Tuple
from skip import Schematic
logger = logging.getLogger(__name__)
@@ -13,9 +14,7 @@ try:
DYNAMIC_LOADING_AVAILABLE = True
except ImportError:
logger.warning(
"Dynamic symbol loader not available - falling back to template-only mode"
)
logger.warning("Dynamic symbol loader not available - falling back to template-only mode")
DYNAMIC_LOADING_AVAILABLE = False
@@ -26,7 +25,7 @@ class ComponentManager:
_dynamic_loader = None
@classmethod
def get_dynamic_loader(cls):
def get_dynamic_loader(cls) -> Any:
"""Get or create dynamic symbol loader instance"""
if cls._dynamic_loader is None and DYNAMIC_LOADING_AVAILABLE:
cls._dynamic_loader = DynamicSymbolLoader()
@@ -87,7 +86,7 @@ class ComponentManager:
"""
# Helper function to check if template exists in schematic
def template_exists(schematic, template_ref):
def template_exists(schematic: Any, template_ref: str) -> bool:
"""Check if template exists by iterating symbols (handles special characters)"""
for symbol in schematic.symbol:
if (
@@ -135,32 +134,22 @@ class ComponentManager:
# Check if schematic path is available
if schematic_path is None:
logger.warning(
"Dynamic loading requires schematic file path but none was provided"
)
logger.warning("Dynamic loading requires schematic file path but none was provided")
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
return (fallback, False)
# Determine library name
if library is None:
# Default library for common component types
library = (
"Device" # Most passives and basic components are in Device library
)
library = "Device" # Most passives and basic components are in Device library
try:
logger.info(
f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}"
)
logger.info(f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}")
# Use dynamic symbol loader to inject symbol and create template
template_ref = loader.load_symbol_dynamically(
schematic_path, library, comp_type
)
template_ref = loader.load_symbol_dynamically(schematic_path, library, comp_type)
logger.info(
f"Successfully loaded symbol dynamically. Template ref: {template_ref}"
)
logger.info(f"Successfully loaded symbol dynamically. Template ref: {template_ref}")
# Signal that schematic needs reload to see new template
return (template_ref, True)
@@ -176,7 +165,7 @@ class ComponentManager:
@staticmethod
def add_component(
schematic: Schematic, component_def: dict, schematic_path: Optional[Path] = None
):
) -> Any:
"""
Add a component to the schematic by cloning from template
@@ -198,9 +187,7 @@ class ComponentManager:
# Get component type and determine template
comp_type = component_def.get("type", "R")
library = component_def.get(
"library", None
) # Optional library specification
library = component_def.get("library", None) # Optional library specification
# Get template reference (static or dynamic)
template_ref, needs_reload = ComponentManager.get_or_create_template(
@@ -209,9 +196,7 @@ class ComponentManager:
# If dynamic loading occurred, reload schematic to see new template
if needs_reload and schematic_path:
logger.info(
f"Reloading schematic after dynamic loading: {schematic_path}"
)
logger.info(f"Reloading schematic after dynamic loading: {schematic_path}")
schematic = SchematicManager.load_schematic(str(schematic_path))
# Find template symbol by reference (handles special characters like +)
@@ -280,7 +265,7 @@ class ComponentManager:
raise
@staticmethod
def remove_component(schematic: Schematic, component_ref: str):
def remove_component(schematic: Schematic, component_ref: str) -> bool:
"""Remove a component from the schematic by reference designator"""
try:
# kicad-skip doesn't have a direct remove_symbol method by reference.
@@ -293,19 +278,17 @@ class ComponentManager:
if symbol_to_remove:
schematic.symbol._elements.remove(symbol_to_remove)
print(f"Removed component {component_ref} from schematic.")
logger.info(f"Removed component {component_ref} from schematic.")
return True
else:
print(f"Component with reference {component_ref} not found.")
logger.warning(f"Component with reference {component_ref} not found.")
return False
except Exception as e:
print(f"Error removing component {component_ref}: {e}")
logger.error(f"Error removing component {component_ref}: {e}")
return False
@staticmethod
def update_component(
schematic: Schematic, component_ref: str, new_properties: dict
):
def update_component(schematic: Schematic, component_ref: str, new_properties: dict) -> bool:
"""Update component properties by reference designator"""
try:
symbol_to_update = None
@@ -319,29 +302,28 @@ class ComponentManager:
if key in symbol_to_update.property:
symbol_to_update.property[key].value = value
else:
# Add as a new property if it doesn't exist
symbol_to_update.property.append(key, value)
print(f"Updated properties for component {component_ref}.")
logger.info(f"Updated properties for component {component_ref}.")
return True
else:
print(f"Component with reference {component_ref} not found.")
logger.warning(f"Component with reference {component_ref} not found.")
return False
except Exception as e:
print(f"Error updating component {component_ref}: {e}")
logger.error(f"Error updating component {component_ref}: {e}")
return False
@staticmethod
def get_component(schematic: Schematic, component_ref: str):
def get_component(schematic: Schematic, component_ref: str) -> Any:
"""Get a component by reference designator"""
for symbol in schematic.symbol:
if symbol.reference == component_ref:
print(f"Found component with reference {component_ref}.")
logger.debug(f"Found component with reference {component_ref}.")
return symbol
print(f"Component with reference {component_ref} not found.")
logger.warning(f"Component with reference {component_ref} not found.")
return None
@staticmethod
def search_components(schematic: Schematic, query: str):
def search_components(schematic: Schematic, query: str) -> List[Any]:
"""Search for components matching criteria (basic implementation)"""
# This is a basic search, could be expanded to use regex or more complex logic
matching_components = []
@@ -356,21 +338,21 @@ class ComponentManager:
)
):
matching_components.append(symbol)
print(f"Found {len(matching_components)} components matching query '{query}'.")
logger.debug(f"Found {len(matching_components)} components matching query '{query}'.")
return matching_components
@staticmethod
def get_all_components(schematic: Schematic):
def get_all_components(schematic: Schematic) -> List[Any]:
"""Get all components in schematic"""
print(f"Retrieving all {len(schematic.symbol)} components.")
logger.debug(f"Retrieving all {len(schematic.symbol)} components.")
return list(schematic.symbol)
if __name__ == "__main__":
# Example Usage (for testing)
from schematic import (
from schematic import ( # Assuming schematic.py is in the same directory
SchematicManager,
) # Assuming schematic.py is in the same directory
)
# Create a new schematic
test_sch = SchematicManager.create_schematic("ComponentTestSchematic")
@@ -401,19 +383,13 @@ if __name__ == "__main__":
# Get a component
retrieved_comp = ComponentManager.get_component(test_sch, "C1")
if retrieved_comp:
print(
f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})"
)
print(f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})")
# Update a component
ComponentManager.update_component(
test_sch, "R1", {"value": "20k", "Tolerance": "5%"}
)
ComponentManager.update_component(test_sch, "R1", {"value": "20k", "Tolerance": "5%"})
# Search components
matching_comps = ComponentManager.search_components(
test_sch, "100"
) # Search by position
matching_comps = ComponentManager.search_components(test_sch, "100") # Search by position
print(f"Search results for '100': {[c.reference for c in matching_comps]}")
# Get all components
@@ -423,9 +399,7 @@ if __name__ == "__main__":
# Remove a component
ComponentManager.remove_component(test_sch, "D1")
all_comps_after_remove = ComponentManager.get_all_components(test_sch)
print(
f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}"
)
print(f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}")
# Save the schematic (optional)
# SchematicManager.save_schematic(test_sch, "component_test.kicad_sch")

View File

@@ -1,15 +1,16 @@
from skip import Schematic
import os
import logging
import os
from pathlib import Path
from typing import Optional
from typing import Any, Dict, List, Optional
from skip import Schematic
logger = logging.getLogger(__name__)
# Import new wire and pin managers
try:
from commands.wire_manager import WireManager
from commands.pin_locator import PinLocator
from commands.wire_manager import WireManager
WIRE_MANAGER_AVAILABLE = True
except ImportError:
@@ -24,180 +25,14 @@ class ConnectionManager:
_pin_locator = None
@classmethod
def get_pin_locator(cls):
def get_pin_locator(cls) -> Any:
"""Get or create pin locator instance"""
if cls._pin_locator is None and WIRE_MANAGER_AVAILABLE:
cls._pin_locator = PinLocator()
return cls._pin_locator
@staticmethod
def add_wire(
schematic_path: Path,
start_point: list,
end_point: list,
properties: dict = None,
):
"""
Add a wire between two points using WireManager
Args:
schematic_path: Path to .kicad_sch file
start_point: [x, y] coordinates for wire start
end_point: [x, y] coordinates for wire end
properties: Optional wire properties (stroke_width, stroke_type)
Returns:
True if successful, False otherwise
"""
try:
if not WIRE_MANAGER_AVAILABLE:
logger.error("WireManager not available")
return False
stroke_width = properties.get("stroke_width", 0) if properties else 0
stroke_type = (
properties.get("stroke_type", "default") if properties else "default"
)
success = WireManager.add_wire(
schematic_path,
start_point,
end_point,
stroke_width=stroke_width,
stroke_type=stroke_type,
)
return success
except Exception as e:
logger.error(f"Error adding wire: {e}")
return False
@staticmethod
def get_pin_location(symbol, pin_name: str):
"""
Get the absolute location of a pin on a symbol
Args:
symbol: Symbol object
pin_name: Name or number of the pin (e.g., "1", "GND", "VCC")
Returns:
[x, y] coordinates or None if pin not found
"""
try:
if not hasattr(symbol, "pin"):
logger.warning(f"Symbol {symbol.property.Reference.value} has no pins")
return None
# Find the pin by name
target_pin = None
for pin in symbol.pin:
if pin.name == pin_name:
target_pin = pin
break
if not target_pin:
logger.warning(
f"Pin '{pin_name}' not found on {symbol.property.Reference.value}"
)
return None
# Get pin location relative to symbol
pin_loc = target_pin.location
# Get symbol location
symbol_at = symbol.at.value
# Calculate absolute position
# pin_loc is relative to symbol origin, need to add symbol position
abs_x = symbol_at[0] + pin_loc[0]
abs_y = symbol_at[1] + pin_loc[1]
return [abs_x, abs_y]
except Exception as e:
logger.error(f"Error getting pin location: {e}")
return None
@staticmethod
def add_connection(
schematic_path: Path,
source_ref: str,
source_pin: str,
target_ref: str,
target_pin: str,
routing: str = "direct",
):
"""
Add a wire connection between two component pins
Args:
schematic_path: Path to .kicad_sch file
source_ref: Reference designator of source component (e.g., "R1", "R1_")
source_pin: Pin name/number on source component
target_ref: Reference designator of target component (e.g., "C1", "C1_")
target_pin: Pin name/number on target component
routing: Routing style ('direct', 'orthogonal_h', 'orthogonal_v')
Returns:
True if connection was successful, False otherwise
"""
try:
if not WIRE_MANAGER_AVAILABLE:
logger.error("WireManager/PinLocator not available")
return False
locator = ConnectionManager.get_pin_locator()
if not locator:
logger.error("Pin locator unavailable")
return False
# Get pin locations
source_loc = locator.get_pin_location(
schematic_path, source_ref, source_pin
)
target_loc = locator.get_pin_location(
schematic_path, target_ref, target_pin
)
if not source_loc or not target_loc:
logger.error("Could not determine pin locations")
return False
# Create wire based on routing style
if routing == "direct":
# Simple direct wire
success = WireManager.add_wire(schematic_path, source_loc, target_loc)
elif routing == "orthogonal_h":
# Orthogonal routing (horizontal first)
path = WireManager.create_orthogonal_path(
source_loc, target_loc, prefer_horizontal_first=True
)
success = WireManager.add_polyline_wire(schematic_path, path)
elif routing == "orthogonal_v":
# Orthogonal routing (vertical first)
path = WireManager.create_orthogonal_path(
source_loc, target_loc, prefer_horizontal_first=False
)
success = WireManager.add_polyline_wire(schematic_path, path)
else:
logger.error(f"Unknown routing style: {routing}")
return False
if success:
logger.info(
f"Connected {source_ref}/{source_pin} to {target_ref}/{target_pin} (routing: {routing})"
)
return True
else:
return False
except Exception as e:
logger.error(f"Error adding connection: {e}")
import traceback
logger.error(traceback.format_exc())
return False
@staticmethod
def add_net_label(schematic: Schematic, net_name: str, position: list):
def add_net_label(schematic: Schematic, net_name: str, position: list) -> Any:
"""
Add a net label to the schematic
@@ -214,9 +49,7 @@ class ConnectionManager:
logger.error("Schematic does not have label collection")
return None
label = schematic.label.append(
text=net_name, at={"x": position[0], "y": position[1]}
)
label = schematic.label.append(text=net_name, at={"x": position[0], "y": position[1]})
logger.info(f"Added net label '{net_name}' at {position}")
return label
except Exception as e:
@@ -226,9 +59,9 @@ class ConnectionManager:
@staticmethod
def connect_to_net(
schematic_path: Path, component_ref: str, pin_name: str, net_name: str
):
) -> Dict[str, Any]:
"""
Connect a component pin to a named net using a wire stub and label
Connect a component pin to a named net using a wire stub and label.
Args:
schematic_path: Path to .kicad_sch file
@@ -237,59 +70,78 @@ class ConnectionManager:
net_name: Name of the net to connect to (e.g., "VCC", "GND", "SIGNAL_1")
Returns:
True if successful, False otherwise
Dict with keys:
success bool
pin_location [x, y] exact pin endpoint used (present on success)
label_location [x, y] where the net label was placed (present on success)
wire_stub [[x1,y1],[x2,y2]] the wire segment added (present on success)
message human-readable status
"""
try:
if not WIRE_MANAGER_AVAILABLE:
logger.error("WireManager/PinLocator not available")
return False
return {"success": False, "message": "WireManager/PinLocator not available"}
locator = ConnectionManager.get_pin_locator()
if not locator:
logger.error("Pin locator unavailable")
return False
return {"success": False, "message": "Pin locator unavailable"}
# Get pin location using PinLocator
pin_loc = locator.get_pin_location(schematic_path, component_ref, pin_name)
if not pin_loc:
logger.error(f"Could not locate pin {component_ref}/{pin_name}")
return False
msg = f"Could not locate pin {component_ref}/{pin_name}"
logger.error(msg)
return {"success": False, "message": msg}
# Add a small wire stub from the pin (2.54mm = 0.1 inch, standard grid spacing)
# Stub direction follows the pin's outward angle from the PinLocator
pin_angle_deg = getattr(locator, '_last_pin_angle', 0)
try:
pin_angle_deg = locator.get_pin_angle(schematic_path, component_ref, pin_name) or 0
except Exception:
except Exception as e:
logger.warning(
f"Could not get pin angle for {component_ref}/{pin_name}, defaulting to 0: {e}"
)
pin_angle_deg = 0
import math as _math
angle_rad = _math.radians(pin_angle_deg)
stub_end = [round(pin_loc[0] + 2.54 * _math.cos(angle_rad), 4),
round(pin_loc[1] - 2.54 * _math.sin(angle_rad), 4)]
stub_end = [
round(pin_loc[0] + 2.54 * _math.cos(angle_rad), 4),
round(pin_loc[1] - 2.54 * _math.sin(angle_rad), 4),
]
# Create wire stub using WireManager
wire_success = WireManager.add_wire(schematic_path, pin_loc, stub_end)
if not wire_success:
logger.error(f"Failed to create wire stub for net connection")
return False
msg = "Failed to create wire stub for net connection"
logger.error(msg)
return {"success": False, "message": msg}
# Add label at the end of the stub using WireManager
label_success = WireManager.add_label(
schematic_path, net_name, stub_end, label_type="label"
)
if not label_success:
logger.error(f"Failed to add net label '{net_name}'")
return False
msg = f"Failed to add net label '{net_name}'"
logger.error(msg)
return {"success": False, "message": msg}
logger.info(f"Connected {component_ref}/{pin_name} to net '{net_name}'")
return True
return {
"success": True,
"message": f"Connected {component_ref}/{pin_name} to net '{net_name}'",
"pin_location": pin_loc,
"label_location": stub_end,
"wire_stub": [pin_loc, stub_end],
}
except Exception as e:
logger.error(f"Error connecting to net: {e}")
import traceback
logger.error(traceback.format_exc())
return False
return {"success": False, "message": str(e)}
@staticmethod
def connect_passthrough(
@@ -298,7 +150,7 @@ class ConnectionManager:
target_ref: str,
net_prefix: str = "PIN",
pin_offset: int = 0,
):
) -> Dict[str, List[str]]:
"""
Connect all pins of source_ref to matching pins of target_ref via shared net labels.
Useful for passthrough adapters: J1 pin N <-> J2 pin N on net {net_prefix}_{N}.
@@ -335,20 +187,24 @@ class ConnectionManager:
for pin_num in sorted(src_pins.keys(), key=lambda x: int(x) if x.isdigit() else 0):
try:
net_name = f"{net_prefix}_{int(pin_num) + pin_offset}" if pin_num.isdigit() else f"{net_prefix}_{pin_num}"
net_name = (
f"{net_prefix}_{int(pin_num) + pin_offset}"
if pin_num.isdigit()
else f"{net_prefix}_{pin_num}"
)
ok_src = ConnectionManager.connect_to_net(
res_src = ConnectionManager.connect_to_net(
schematic_path, source_ref, pin_num, net_name
)
if not ok_src:
if not res_src.get("success"):
failed.append(f"{source_ref}/{pin_num}")
continue
if pin_num in tgt_pins:
ok_tgt = ConnectionManager.connect_to_net(
res_tgt = ConnectionManager.connect_to_net(
schematic_path, target_ref, pin_num, net_name
)
if not ok_tgt:
if not res_tgt.get("success"):
failed.append(f"{target_ref}/{pin_num}")
continue
else:
@@ -365,7 +221,7 @@ class ConnectionManager:
@staticmethod
def get_net_connections(
schematic: Schematic, net_name: str, schematic_path: Optional[Path] = None
):
) -> List[Dict]:
"""
Get all connections for a named net using wire graph analysis
@@ -383,7 +239,7 @@ class ConnectionManager:
connections = []
tolerance = 0.5 # 0.5mm tolerance for point coincidence (grid spacing consideration)
def points_coincide(p1, p2):
def points_coincide(p1: Any, p2: Any) -> bool:
"""Check if two points are the same (within tolerance)"""
if not p1 or not p2:
return False
@@ -407,22 +263,21 @@ class ConnectionManager:
logger.info(f"No labels found for net '{net_name}'")
return connections
logger.debug(
f"Found {len(net_label_positions)} labels for net '{net_name}'"
)
logger.debug(f"Found {len(net_label_positions)} labels for net '{net_name}'")
# 2. Find all wires connected to these label positions
if not hasattr(schematic, "wire"):
logger.warning("Schematic has no wires")
return connections
connected_wire_points = set()
if hasattr(schematic, "wire"):
for wire in schematic.wire:
if hasattr(wire, "pts") and hasattr(wire.pts, "xy"):
# Get all points in this wire (polyline)
wire_points = []
for point in wire.pts.xy:
if hasattr(point, "value"):
wire_points.append(
[float(point.value[0]), float(point.value[1])]
)
wire_points.append([float(point.value[0]), float(point.value[1])])
# Check if any wire point touches a label
wire_connected = False
@@ -439,20 +294,24 @@ class ConnectionManager:
for pt in wire_points:
connected_wire_points.add((pt[0], pt[1]))
# Build the full set of candidate match points:
# wire endpoints that touch this net PLUS label positions themselves.
# This handles labels placed directly at pin endpoints (no wire needed).
# Build match points: union of wire endpoints AND label positions.
# This handles the valid KiCad style where a net label is placed
# directly at a pin endpoint with no wire segment in between.
all_match_points = connected_wire_points | {
(p[0], p[1]) for p in net_label_positions
}
if not all_match_points:
logger.debug(f"No connection points found for net '{net_name}'")
return connections
logger.debug(
f"Net '{net_name}': {len(connected_wire_points)} wire points, "
f"Found {len(connected_wire_points)} wire points, "
f"{len(net_label_positions)} direct label positions, "
f"{len(all_match_points)} total match points"
f"{len(all_match_points)} total match points for net '{net_name}'"
)
# 3. Find component pins at wire endpoints or direct label positions
# 3. Find component pins at wire endpoints
if not hasattr(schematic, "symbol"):
logger.warning("Schematic has no symbols")
return connections
@@ -487,19 +346,15 @@ class ConnectionManager:
# Check each pin
for pin_num, pin_data in pins.items():
# Get pin location
pin_loc = locator.get_pin_location(
schematic_path, ref, pin_num
)
pin_loc = locator.get_pin_location(schematic_path, ref, pin_num)
if not pin_loc:
continue
# Check if pin coincides with any wire point or label position
for match_pt in all_match_points:
if points_coincide(pin_loc, list(match_pt)):
connections.append(
{"component": ref, "pin": pin_num}
)
break # Pin found, no need to check more points
# Check if pin coincides with any match point
for wire_pt_tup in all_match_points:
if points_coincide(pin_loc, list(wire_pt_tup)):
connections.append({"component": ref, "pin": pin_num})
break # Pin found, no need to check more wire points
except Exception as e:
logger.warning(f"Error matching pins for {ref}: {e}")
@@ -515,10 +370,10 @@ class ConnectionManager:
symbol_x = float(symbol_pos[0])
symbol_y = float(symbol_pos[1])
# Check if symbol is near any wire point or label position (within 10mm)
for wire_pt in all_match_points:
# Check if symbol is near any match point (within 10mm)
for wire_pt_tup in all_match_points:
dist = (
(symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2
(symbol_x - wire_pt_tup[0]) ** 2 + (symbol_y - wire_pt_tup[1]) ** 2
) ** 0.5
if dist < 10.0: # 10mm proximity threshold
connections.append({"component": ref, "pin": "unknown"})
@@ -535,7 +390,9 @@ class ConnectionManager:
return []
@staticmethod
def generate_netlist(schematic: Schematic, schematic_path: Optional[Path] = None):
def generate_netlist(
schematic: Schematic, schematic_path: Optional[Path] = None
) -> Dict[str, Any]:
"""
Generate a netlist from the schematic
@@ -572,9 +429,7 @@ class ConnectionManager:
component_info = {
"reference": symbol.property.Reference.value,
"value": (
symbol.property.Value.value
if hasattr(symbol.property, "Value")
else ""
symbol.property.Value.value if hasattr(symbol.property, "Value") else ""
),
"footprint": (
symbol.property.Footprint.value
@@ -597,9 +452,7 @@ class ConnectionManager:
schematic, net_name, schematic_path
)
if connections:
netlist["nets"].append(
{"name": net_name, "connections": connections}
)
netlist["nets"].append({"name": net_name, "connections": connections})
logger.info(
f"Generated netlist with {len(netlist['nets'])} nets and {len(netlist['components'])} components"
@@ -609,35 +462,3 @@ class ConnectionManager:
except Exception as e:
logger.error(f"Error generating netlist: {e}")
return {"nets": [], "components": []}
if __name__ == "__main__":
# Example Usage (for testing)
from schematic import (
SchematicManager,
) # Assuming schematic.py is in the same directory
# Create a new schematic
test_sch = SchematicManager.create_schematic("ConnectionTestSchematic")
# Add some wires
wire1 = ConnectionManager.add_wire(test_sch, [100, 100], [200, 100])
wire2 = ConnectionManager.add_wire(test_sch, [200, 100], [200, 200])
# Note: add_connection, remove_connection, get_net_connections are placeholders
# and require more complex implementation based on kicad-skip's structure.
# Example of how you might add a net label (requires finding a point on a wire)
# from skip import Label
# if wire1:
# net_label_pos = wire1.start # Or calculate a point on the wire
# net_label = test_sch.add_label(text="Net_01", at=net_label_pos)
# print(f"Added net label 'Net_01' at {net_label_pos}")
# Save the schematic (optional)
# SchematicManager.save_schematic(test_sch, "connection_test.kicad_sch")
# Clean up (if saved)
# if os.path.exists("connection_test.kicad_sch"):
# os.remove("connection_test.kicad_sch")
# print("Cleaned up connection_test.kicad_sch")

View File

@@ -9,10 +9,10 @@ URL schema: https://www.lcsc.com/datasheet/{LCSC#}.pdf
No API key required.
"""
import re
import logging
import re
from pathlib import Path
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Tuple
logger = logging.getLogger("kicad_interface")
@@ -49,7 +49,7 @@ class DatasheetManager:
return None
@staticmethod
def _find_lib_symbols_range(lines: List[str]):
def _find_lib_symbols_range(lines: List[str]) -> Tuple[Optional[int], Optional[int]]:
"""
Find the line range of the (lib_symbols ...) section.
Returns (start, end) line indices or (None, None) if not found.
@@ -81,9 +81,7 @@ class DatasheetManager:
return lib_sym_start, lib_sym_end
@staticmethod
def _process_symbol_block(
lines: List[str], block_start: int, block_end: int
) -> Optional[Dict]:
def _process_symbol_block(lines: List[str], block_start: int, block_end: int) -> Optional[Dict]:
"""
Extract LCSC and Datasheet info from a placed symbol block.
@@ -114,9 +112,7 @@ class DatasheetManager:
"datasheet_value": datasheet_current,
}
def enrich_schematic(
self, schematic_path: Path, dry_run: bool = False
) -> Dict:
def enrich_schematic(self, schematic_path: Path, dry_run: bool = False) -> Dict:
"""
Scan a .kicad_sch file and fill in missing LCSC datasheet URLs.
@@ -223,9 +219,7 @@ class DatasheetManager:
no_lcsc += 1
elif ds_value not in EMPTY_DATASHEET_VALUES:
already_set += 1
logger.debug(
f"Symbol {reference}: Datasheet already set to {ds_value!r}"
)
logger.debug(f"Symbol {reference}: Datasheet already set to {ds_value!r}")
else:
url = LCSC_DATASHEET_URL.format(lcsc=lcsc_norm)
if not dry_run:
@@ -256,9 +250,7 @@ class DatasheetManager:
if not dry_run and updated > 0:
with open(schematic_path, "w", encoding="utf-8") as f:
f.write("\n".join(new_lines))
logger.info(
f"Saved {schematic_path.name}: {updated} datasheet URLs written"
)
logger.info(f"Saved {schematic_path.name}: {updated} datasheet URLs written")
return {
"success": True,

View File

@@ -2,10 +2,11 @@
Design rules command implementations for KiCAD interface
"""
import os
import pcbnew
import logging
from typing import Dict, Any, Optional, List, Tuple
import os
from typing import Any, Dict, List, Optional, Tuple
import pcbnew
logger = logging.getLogger("kicad_interface")
@@ -58,13 +59,9 @@ class DesignRuleCommands:
# Set micro via settings (use properties - methods removed in KiCAD 9.0)
if "microViaDiameter" in params:
design_settings.m_MicroViasMinSize = int(
params["microViaDiameter"] * scale
)
design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale)
if "microViaDrill" in params:
design_settings.m_MicroViasMinDrill = int(
params["microViaDrill"] * scale
)
design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale)
# Set minimum values
if "minTrackWidth" in params:
@@ -77,19 +74,13 @@ class DesignRuleCommands:
design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale)
if "minMicroViaDiameter" in params:
design_settings.m_MicroViasMinSize = int(
params["minMicroViaDiameter"] * scale
)
design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale)
if "minMicroViaDrill" in params:
design_settings.m_MicroViasMinDrill = int(
params["minMicroViaDrill"] * scale
)
design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale)
# KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill
if "minHoleDiameter" in params:
design_settings.m_MinThroughDrill = int(
params["minHoleDiameter"] * scale
)
design_settings.m_MinThroughDrill = int(params["minHoleDiameter"] * scale)
# KiCAD 9.0: Added hole clearance settings
if "holeClearance" in params:
@@ -181,11 +172,11 @@ class DesignRuleCommands:
def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Run Design Rule Check using kicad-cli"""
import subprocess
import json
import tempfile
import platform
import shutil
import subprocess
import tempfile
try:
if not self.board:
@@ -216,9 +207,7 @@ class DesignRuleCommands:
}
# Create temporary JSON output file
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False
) as tmp:
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
json_output = tmp.name
try:
@@ -297,9 +286,7 @@ class DesignRuleCommands:
# Determine where to save the violations file
board_dir = os.path.dirname(board_file)
board_name = os.path.splitext(os.path.basename(board_file))[0]
violations_file = os.path.join(
board_dir, f"{board_name}_drc_violations.json"
)
violations_file = os.path.join(board_dir, f"{board_name}_drc_violations.json")
# Always save violations to JSON file (for large result sets)
with open(violations_file, "w", encoding="utf-8") as f:
@@ -453,9 +440,7 @@ class DesignRuleCommands:
# Filter by severity if specified
if severity != "all":
filtered_violations = [
v for v in all_violations if v.get("severity") == severity
]
filtered_violations = [v for v in all_violations if v.get("severity") == severity]
else:
filtered_violations = all_violations

View File

@@ -7,10 +7,10 @@ on-the-fly using TEXT MANIPULATION (not sexpdata) to preserve file formatting.
This enables access to all ~10,000+ KiCad symbols dynamically.
"""
import logging
import os
import re
import uuid
import logging
from pathlib import Path
from typing import Dict, List, Optional, Tuple
@@ -41,10 +41,17 @@ class DynamicSymbolLoader:
Path("C:/Program Files/KiCad/9.0/share/kicad/symbols"),
Path("C:/Program Files/KiCad/8.0/share/kicad/symbols"),
Path("/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols"),
Path.home() / ".local" / "share" / "kicad" / "10.0" / "symbols",
Path.home() / ".local" / "share" / "kicad" / "9.0" / "symbols",
Path.home() / "Documents" / "KiCad" / "10.0" / "3rdparty" / "symbols",
Path.home() / "Documents" / "KiCad" / "9.0" / "3rdparty" / "symbols",
]
for env_var in ["KICAD9_SYMBOL_DIR", "KICAD8_SYMBOL_DIR", "KICAD_SYMBOL_DIR"]:
for env_var in [
"KICAD10_SYMBOL_DIR",
"KICAD9_SYMBOL_DIR",
"KICAD8_SYMBOL_DIR",
"KICAD_SYMBOL_DIR",
]:
if env_var in os.environ:
possible_paths.insert(0, Path(os.environ[env_var]))
@@ -81,7 +88,9 @@ class DynamicSymbolLoader:
with open(table_path, "r", encoding="utf-8") as f:
content = f.read()
lib_pattern = r'\(lib\s+\(name\s+"?([^"\)\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^"\)\s]+)"?'
lib_pattern = (
r'\(lib\s+\(name\s+"?([^"\)\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^"\)\s]+)"?'
)
for match in re.finditer(lib_pattern, content, re.IGNORECASE):
nickname = match.group(1)
if nickname != library_name:
@@ -97,6 +106,11 @@ class DynamicSymbolLoader:
def _resolve_sym_uri(self, uri: str) -> Optional[str]:
"""Resolve environment variables in a sym-lib-table URI."""
env_map = {
"KICAD10_SYMBOL_DIR": [
"/usr/share/kicad/symbols",
"C:/Program Files/KiCad/10.0/share/kicad/symbols",
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols",
],
"KICAD9_SYMBOL_DIR": [
"C:/Program Files/KiCad/9.0/share/kicad/symbols",
"/usr/share/kicad/symbols",
@@ -201,9 +215,7 @@ class DynamicSymbolLoader:
return items
def _inline_extends_symbol(
self, lib_content: str, symbol_name: str, child_block: str
) -> str:
def _inline_extends_symbol(self, lib_content: str, symbol_name: str, child_block: str) -> str:
"""
Fully inline a child symbol that uses (extends "ParentName") by merging
the parent's pins / graphics into the child definition.
@@ -248,22 +260,16 @@ class DynamicSymbolLoader:
for item in self._iter_top_level_items(parent_block):
prop_match = re.match(r'[\s\t]*\(property "([^"]+)"', item)
sub_match = re.search(
r'\(symbol "' + re.escape(parent_name) + r'_\d+_\d+"', item
)
sub_match = re.search(r'\(symbol "' + re.escape(parent_name) + r'_\d+_\d+"', item)
if prop_match:
pname = prop_match.group(1)
parent_prop_names.add(pname)
body_lines.append(
child_props[pname] if pname in child_props else item
)
body_lines.append(child_props[pname] if pname in child_props else item)
elif sub_match:
# Rename ParentName_0_1 → ChildName_0_1
body_lines.append(
item.replace(f'"{parent_name}_', f'"{symbol_name}_')
)
elif re.match(r'[\s\t]*\(extends ', item):
body_lines.append(item.replace(f'"{parent_name}_', f'"{symbol_name}_'))
elif re.match(r"[\s\t]*\(extends ", item):
pass # drop extends clause
else:
body_lines.append(item) # pin_names, in_bom, on_board …
@@ -273,16 +279,12 @@ class DynamicSymbolLoader:
if pname not in parent_prop_names:
body_lines.append(pblock)
first_line = parent_block.split("\n")[0].replace(
f'"{parent_name}"', f'"{symbol_name}"'
)
first_line = parent_block.split("\n")[0].replace(f'"{parent_name}"', f'"{symbol_name}"')
last_line = parent_block.split("\n")[-1]
return first_line + "\n" + "\n".join(body_lines) + "\n" + last_line
def extract_symbol_from_library(
self, library_name: str, symbol_name: str
) -> Optional[str]:
def extract_symbol_from_library(self, library_name: str, symbol_name: str) -> Optional[str]:
"""
Extract a symbol definition from a KiCad .kicad_sym library file.
Returns the raw text block, ready to be injected into a schematic.
@@ -304,9 +306,7 @@ class DynamicSymbolLoader:
block = self._extract_symbol_block(lib_content, symbol_name)
if block is None:
logger.warning(
f"Symbol '{symbol_name}' not found in {library_name}.kicad_sym"
)
logger.warning(f"Symbol '{symbol_name}' not found in {library_name}.kicad_sym")
return None
# If the symbol uses (extends "ParentName"), inline the parent content
@@ -315,9 +315,7 @@ class DynamicSymbolLoader:
# load a schematic whose lib_symbols section contains it.
if re.search(r'\(extends "([^"]+)"\)', block):
parent_name = re.search(r'\(extends "([^"]+)"\)', block).group(1)
logger.info(
f"Symbol {symbol_name} extends {parent_name}, inlining parent content"
)
logger.info(f"Symbol {symbol_name} extends {parent_name}, inlining parent content")
block = self._inline_extends_symbol(lib_content, symbol_name, block)
# Prefix top-level symbol name with library
@@ -355,9 +353,7 @@ class DynamicSymbolLoader:
# Extract symbol from library
symbol_block = self.extract_symbol_from_library(library_name, symbol_name)
if not symbol_block:
raise ValueError(
f"Symbol '{symbol_name}' not found in library '{library_name}'"
)
raise ValueError(f"Symbol '{symbol_name}' not found in library '{library_name}'")
# Indent the block to match lib_symbols indentation (4 spaces for top-level)
indented_lines = []
@@ -392,11 +388,7 @@ class DynamicSymbolLoader:
f.write(content)
# Handle both Path objects and strings
sch_name = (
schematic_path.name
if hasattr(schematic_path, "name")
else str(schematic_path)
)
sch_name = schematic_path.name if hasattr(schematic_path, "name") else str(schematic_path)
logger.info(f"Injected symbol {full_name} into {sch_name}")
return True
@@ -433,6 +425,14 @@ class DynamicSymbolLoader:
(property "Datasheet" "~" (at {x} {y} 0)
(effects (font (size 1.27 1.27)) (hide yes))
)
(instances
(project "project"
(path "/"
(reference "{reference}")
(unit 1)
)
)
)
)"""
with open(schematic_path, "r", encoding="utf-8") as f:
@@ -450,9 +450,7 @@ class DynamicSymbolLoader:
with open(schematic_path, "w", encoding="utf-8") as f:
f.write(content)
logger.info(
f"Added component instance {reference} ({full_lib_id}) at ({x}, {y})"
)
logger.info(f"Added component instance {reference} ({full_lib_id}) at ({x}, {y})")
return True
def load_symbol_dynamically(

View File

@@ -2,13 +2,14 @@
Export command implementations for KiCAD interface
"""
import os
import pcbnew
import logging
from typing import Dict, Any, Optional, List, Tuple
import base64
import logging
import os
import shutil
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
import pcbnew
logger = logging.getLogger("kicad_interface")
@@ -105,22 +106,16 @@ class ExportCommands:
]
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode == 0:
# Get list of generated drill files
for file in os.listdir(output_dir):
if file.endswith((".drl", ".cnc")):
drill_files.append(file)
else:
logger.warning(
f"Drill file generation failed: {result.stderr}"
)
logger.warning(f"Drill file generation failed: {result.stderr}")
except Exception as drill_error:
logger.warning(
f"Could not generate drill files: {str(drill_error)}"
)
logger.warning(f"Could not generate drill files: {str(drill_error)}")
else:
logger.warning("kicad-cli not available for drill file generation")
@@ -236,9 +231,7 @@ class ExportCommands:
# Get the actual output filename that was created
board_name = os.path.splitext(os.path.basename(self.board.GetFileName()))[0]
actual_filename = f"{board_name}-{base_name}.pdf"
actual_output_path = os.path.join(
os.path.dirname(output_path), actual_filename
)
actual_output_path = os.path.join(os.path.dirname(output_path), actual_filename)
return {
"success": True,
@@ -329,9 +322,9 @@ class ExportCommands:
def export_3d(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Export 3D model files using kicad-cli (KiCAD 9.0 compatible)"""
import subprocess
import platform
import shutil
import subprocess
try:
if not self.board:
@@ -395,9 +388,7 @@ class ExportCommands:
if not include_components:
cmd.append("--no-components")
if include_copper:
cmd.extend(
["--include-tracks", "--include-pads", "--include-zones"]
)
cmd.extend(["--include-tracks", "--include-pads", "--include-zones"])
if include_silkscreen:
cmd.append("--include-silkscreen")
if include_solder_mask:
@@ -618,8 +609,8 @@ class ExportCommands:
Returns:
Path to kicad-cli executable, or None if not found
"""
import shutil
import platform
import shutil
# Try system PATH first
cli_path = shutil.which("kicad-cli")
@@ -696,6 +687,7 @@ class ExportCommands:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
from pathlib import Path
logs_dir = Path(project_dir) / "logs"
logs_dir.mkdir(exist_ok=True)
dest = str(logs_dir / f"mcp_log_{timestamp}.txt")

View File

@@ -8,9 +8,9 @@ KiCAD 9 .kicad_mod format reference:
https://dev-docs.kicad.org/en/file-formats/sexpr-footprint/
"""
import logging
import os
import re
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -106,7 +106,7 @@ class FootprintCreator:
# ---- header ----
lines.append(f'(footprint "{name}"')
lines.append(f' (version {KICAD9_FOOTPRINT_VERSION})')
lines.append(f" (version {KICAD9_FOOTPRINT_VERSION})")
lines.append(f' (generator "kicad-mcp")')
lines.append(f' (generator_version "9.0")')
lines.append(f' (layer "F.Cu")')
@@ -122,25 +122,21 @@ class FootprintCreator:
val_x = value_position.get("x", 0.0) if value_position else 0.0
val_y = value_position.get("y", 1.27) if value_position else 1.27
lines.append(
f' (property "Reference" "REF**" (at {_fmt(ref_x)} {_fmt(ref_y)} 0)'
)
lines.append(f' (property "Reference" "REF**" (at {_fmt(ref_x)} {_fmt(ref_y)} 0)')
lines.append(f' (layer "F.SilkS")')
lines.append(f' (uuid "{_new_uuid()}")')
lines.append(f' (effects (font (size 1 1) (thickness 0.15)))')
lines.append(f' )')
lines.append(
f' (property "Value" "{_esc(name)}" (at {_fmt(val_x)} {_fmt(val_y)} 0)'
)
lines.append(f" (effects (font (size 1 1) (thickness 0.15)))")
lines.append(f" )")
lines.append(f' (property "Value" "{_esc(name)}" (at {_fmt(val_x)} {_fmt(val_y)} 0)')
lines.append(f' (layer "F.Fab")')
lines.append(f' (uuid "{_new_uuid()}")')
lines.append(f' (effects (font (size 1 1) (thickness 0.15)))')
lines.append(f' )')
lines.append(f" (effects (font (size 1 1) (thickness 0.15)))")
lines.append(f" )")
lines.append(f' (property "Datasheet" "" (at 0 0 0)')
lines.append(f' (layer "F.Fab")')
lines.append(f' (uuid "{_new_uuid()}")')
lines.append(f' (effects (font (size 1 1) (thickness 0.15)))')
lines.append(f' )')
lines.append(f" (effects (font (size 1 1) (thickness 0.15)))")
lines.append(f" )")
lines.append("")
# ---- courtyard ----
@@ -217,33 +213,35 @@ class FootprintCreator:
changes = []
if size:
new_size = f'(size {_fmt(size["w"])} {_fmt(size["h"])})'
block, n = re.subn(r'\(size\s+[\d.]+\s+[\d.]+\)', new_size, block)
block, n = re.subn(r"\(size\s+[\d.]+\s+[\d.]+\)", new_size, block)
if n:
changes.append(f"size→{new_size}")
if at:
angle = at.get("angle", 0)
new_at = f'(at {_fmt(at["x"])} {_fmt(at["y"])} {_fmt(angle)})'
block, n = re.subn(r'\(at\s+[-\d.]+\s+[-\d.]+(?:\s+[-\d.]+)?\)', new_at, block)
block, n = re.subn(r"\(at\s+[-\d.]+\s+[-\d.]+(?:\s+[-\d.]+)?\)", new_at, block)
if n:
changes.append(f"at→{new_at}")
if drill is not None:
if isinstance(drill, (int, float)):
new_drill = f'(drill {_fmt(drill)})'
new_drill = f"(drill {_fmt(drill)})"
else:
new_drill = f'(drill oval {_fmt(drill["w"])} {_fmt(drill["h"])})'
block, n = re.subn(r'\(drill(?:\s+oval)?\s+[-\d.]+(?:\s+[-\d.]+)?\)', new_drill, block)
block, n = re.subn(
r"\(drill(?:\s+oval)?\s+[-\d.]+(?:\s+[-\d.]+)?\)", new_drill, block
)
if n:
changes.append(f"drill→{new_drill}")
else:
# Insert drill before closing paren of pad block
block = block.rstrip().rstrip(')') + f'\n {new_drill}\n )'
block = block.rstrip().rstrip(")") + f"\n {new_drill}\n )"
changes.append(f"drill (inserted)→{new_drill}")
if shape:
block, n = re.subn(
r'(pad\s+"[^"]*"\s+\w+\s+)\w+',
lambda m: m.group(1) + shape,
lambda m: str(m.group(1)) + shape,
block,
count=1
count=1,
)
if n:
changes.append(f"shape→{shape}")
@@ -280,7 +278,7 @@ class FootprintCreator:
if not updated:
return {
"success": False,
"error": f"Pad \"{pad_number}\" not found or no changes made in {footprint_path}",
"error": f'Pad "{pad_number}" not found or no changes made in {footprint_path}',
}
path.write_text("\n".join(result_lines), encoding="utf-8")
@@ -429,6 +427,7 @@ class FootprintCreator:
# Internal helpers #
# ------------------------------------------------------------------ #
def _esc(s: str) -> str:
"""Escape double-quotes inside S-Expression string values."""
return s.replace('"', '\\"')
@@ -436,6 +435,7 @@ def _esc(s: str) -> str:
def _new_uuid() -> str:
import uuid
return str(uuid.uuid4())
@@ -462,7 +462,9 @@ def _pad_lines(pad: Dict[str, Any]) -> List[str]:
sh = _fmt(size.get("h", 1.0))
if layers is None:
layers = _DEFAULT_THT_LAYERS if ptype in ("thru_hole", "np_thru_hole") else _DEFAULT_SMD_LAYERS
layers = (
_DEFAULT_THT_LAYERS if ptype in ("thru_hole", "np_thru_hole") else _DEFAULT_SMD_LAYERS
)
layers_str = " ".join(f'"{l}"' for l in layers)
lines = [f' (pad "{number}" {ptype} {shape}']
@@ -494,13 +496,13 @@ def _rect_lines(rect: Dict[str, Any], layer: str, default_width: float = 0.05) -
y2 = _fmt(rect.get("y2", 1.0))
w = _fmt(rect.get("width", default_width))
return [
f' (fp_rect',
f' (start {x1} {y1})',
f' (end {x2} {y2})',
f' (stroke (width {w}) (type default))',
f' (fill none)',
f" (fp_rect",
f" (start {x1} {y1})",
f" (end {x2} {y2})",
f" (stroke (width {w}) (type default))",
f" (fill none)",
f' (layer "{layer}")',
f' (uuid "{_new_uuid()}")',
f' )',
f" )",
"",
]

View File

@@ -9,22 +9,20 @@ Supports two execution modes:
- Docker: docker run eclipse-temurin:21-jre (requires Docker)
"""
import os
import subprocess
import shutil
import time
import logging
import os
import shutil
import subprocess
import time
from pathlib import Path
from typing import Dict, Any, Optional, List
from typing import Any, Dict, List, Optional
logger = logging.getLogger("kicad_interface")
# Default Freerouting JAR location
DEFAULT_FREEROUTING_JAR = os.environ.get(
"FREEROUTING_JAR",
os.path.join(
os.path.expanduser("~"), ".kicad-mcp", "freerouting.jar"
),
os.path.join(os.path.expanduser("~"), ".kicad-mcp", "freerouting.jar"),
)
DOCKER_IMAGE = "eclipse-temurin:21-jre"
@@ -97,38 +95,55 @@ def _build_freerouting_cmd(
"""Build the command to run Freerouting."""
if use_docker:
docker_exe = _find_docker()
if docker_exe is None:
raise RuntimeError("Docker/Podman executable not found")
board_dir = os.path.dirname(dsn_path)
dsn_name = os.path.basename(dsn_path)
ses_name = os.path.basename(ses_path)
jar_name = os.path.basename(jar_path)
return [
docker_exe, "run", "--rm",
"-v", f"{jar_path}:/app/{jar_name}:ro",
"-v", f"{board_dir}:/work",
docker_exe,
"run",
"--rm",
"-v",
f"{jar_path}:/app/{jar_name}:ro",
"-v",
f"{board_dir}:/work",
DOCKER_IMAGE,
"java", "-jar", f"/app/{jar_name}",
"-de", f"/work/{dsn_name}",
"-do", f"/work/{ses_name}",
"-mp", str(passes),
"java",
"-jar",
f"/app/{jar_name}",
"-de",
f"/work/{dsn_name}",
"-do",
f"/work/{ses_name}",
"-mp",
str(passes),
]
else:
java_exe = _find_java()
if java_exe is None:
raise RuntimeError("Java executable not found")
return [
java_exe, "-jar", jar_path,
"-de", dsn_path, "-do", ses_path,
"-mp", str(passes),
java_exe,
"-jar",
jar_path,
"-de",
dsn_path,
"-do",
ses_path,
"-mp",
str(passes),
]
class FreeroutingCommands:
"""Handles Freerouting autoroute operations."""
def __init__(self, board=None):
def __init__(self, board: Any = None) -> None:
self.board = board
def _resolve_execution_mode(
self, jar_path: str
) -> Dict[str, Any]:
def _resolve_execution_mode(self, jar_path: str) -> Dict[str, Any]:
"""Determine how to run Freerouting: direct or docker.
Returns dict with 'mode', 'use_docker', or 'error'.
@@ -152,8 +167,7 @@ class FreeroutingCommands:
return {
"mode": "error",
"error": (
"Neither Java 21+ nor Docker found. "
"Install one of them to use Freerouting."
"Neither Java 21+ nor Docker found. " "Install one of them to use Freerouting."
),
}
@@ -190,14 +204,10 @@ class FreeroutingCommands:
return {
"success": False,
"message": "No board file path available",
"errorDetails": (
"Provide boardPath or open a project first"
),
"errorDetails": ("Provide boardPath or open a project first"),
}
jar_path = params.get(
"freeroutingJar", DEFAULT_FREEROUTING_JAR
)
jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR)
timeout = params.get("timeout", 300)
passes = params.get("maxPasses", 20)
@@ -238,9 +248,7 @@ class FreeroutingCommands:
return {
"success": False,
"message": "DSN export failed",
"errorDetails": (
f"ExportSpecctraDSN returned: {result}"
),
"errorDetails": (f"ExportSpecctraDSN returned: {result}"),
}
except Exception as e:
return {
@@ -260,14 +268,10 @@ class FreeroutingCommands:
logger.info(f"DSN exported: {dsn_size} bytes")
# Step 2: Run Freerouting
cmd = _build_freerouting_cmd(
jar_path, dsn_path, ses_path, passes, use_docker
)
cmd = _build_freerouting_cmd(jar_path, dsn_path, ses_path, passes, use_docker)
mode_label = "docker" if use_docker else "direct"
logger.info(
f"Running Freerouting ({mode_label}): {' '.join(cmd)}"
)
logger.info(f"Running Freerouting ({mode_label}): {' '.join(cmd)}")
start_time = time.time()
try:
@@ -283,10 +287,7 @@ class FreeroutingCommands:
if proc.returncode != 0:
return {
"success": False,
"message": (
f"Freerouting exited with code "
f"{proc.returncode}"
),
"message": (f"Freerouting exited with code " f"{proc.returncode}"),
"errorDetails": proc.stderr or proc.stdout,
"elapsed_seconds": elapsed,
"mode": mode_label,
@@ -294,12 +295,8 @@ class FreeroutingCommands:
except subprocess.TimeoutExpired:
return {
"success": False,
"message": (
f"Freerouting timed out after {timeout}s"
),
"errorDetails": (
"Increase timeout or reduce board complexity"
),
"message": (f"Freerouting timed out after {timeout}s"),
"errorDetails": ("Increase timeout or reduce board complexity"),
}
except Exception as e:
return {
@@ -313,10 +310,7 @@ class FreeroutingCommands:
return {
"success": False,
"message": "Freerouting did not produce SES output",
"errorDetails": (
f"Expected at: {ses_path}. "
f"Stdout: {proc.stdout[:500]}"
),
"errorDetails": (f"Expected at: {ses_path}. " f"Stdout: {proc.stdout[:500]}"),
"elapsed_seconds": elapsed,
}
@@ -331,9 +325,7 @@ class FreeroutingCommands:
return {
"success": False,
"message": "SES import failed",
"errorDetails": (
f"ImportSpecctraSES returned: {result}"
),
"errorDetails": (f"ImportSpecctraSES returned: {result}"),
"elapsed_seconds": elapsed,
}
except Exception as e:
@@ -348,9 +340,7 @@ class FreeroutingCommands:
try:
self.board.Save(board_path)
except Exception as e:
logger.warning(
f"Board save after autoroute failed: {e}"
)
logger.warning(f"Board save after autoroute failed: {e}")
# Collect stats
tracks = self.board.GetTracks()
@@ -373,9 +363,7 @@ class FreeroutingCommands:
"tracks": track_count,
"vias": via_count,
},
"freerouting_stdout": (
proc.stdout[:1000] if proc.stdout else ""
),
"freerouting_stdout": (proc.stdout[:1000] if proc.stdout else ""),
}
def export_dsn(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -396,36 +384,26 @@ class FreeroutingCommands:
"errorDetails": "Load or create a board first",
}
board_path = (
params.get("boardPath") or self.board.GetFileName()
)
board_path = params.get("boardPath") or self.board.GetFileName()
output_path = params.get("outputPath")
if not output_path:
if board_path:
output_path = (
os.path.splitext(board_path)[0] + ".dsn"
)
output_path = os.path.splitext(board_path)[0] + ".dsn"
else:
return {
"success": False,
"message": "No output path",
"errorDetails": (
"Provide outputPath or have a board open"
),
"errorDetails": ("Provide outputPath or have a board open"),
}
try:
result = pcbnew.ExportSpecctraDSN(
self.board, output_path
)
result = pcbnew.ExportSpecctraDSN(self.board, output_path)
if result is not True and result != 0:
return {
"success": False,
"message": "DSN export failed",
"errorDetails": (
f"ExportSpecctraDSN returned: {result}"
),
"errorDetails": (f"ExportSpecctraDSN returned: {result}"),
}
except Exception as e:
return {
@@ -434,11 +412,7 @@ class FreeroutingCommands:
"errorDetails": str(e),
}
file_size = (
os.path.getsize(output_path)
if os.path.isfile(output_path)
else 0
)
file_size = os.path.getsize(output_path) if os.path.isfile(output_path) else 0
return {
"success": True,
"message": f"Exported DSN to {output_path}",
@@ -469,9 +443,7 @@ class FreeroutingCommands:
return {
"success": False,
"message": "Missing sesPath parameter",
"errorDetails": (
"Provide the path to the .ses file"
),
"errorDetails": ("Provide the path to the .ses file"),
}
if not os.path.isfile(ses_path):
@@ -482,16 +454,12 @@ class FreeroutingCommands:
}
try:
result = pcbnew.ImportSpecctraSES(
self.board, ses_path
)
result = pcbnew.ImportSpecctraSES(self.board, ses_path)
if result is not True and result != 0:
return {
"success": False,
"message": "SES import failed",
"errorDetails": (
f"ImportSpecctraSES returned: {result}"
),
"errorDetails": (f"ImportSpecctraSES returned: {result}"),
}
except Exception as e:
return {
@@ -500,24 +468,16 @@ class FreeroutingCommands:
"errorDetails": str(e),
}
board_path = (
params.get("boardPath") or self.board.GetFileName()
)
board_path = params.get("boardPath") or self.board.GetFileName()
if board_path:
try:
self.board.Save(board_path)
except Exception as e:
logger.warning(
f"Board save after SES import failed: {e}"
)
logger.warning(f"Board save after SES import failed: {e}")
tracks = self.board.GetTracks()
track_count = sum(
1 for t in tracks if t.GetClass() != "PCB_VIA"
)
via_count = sum(
1 for t in tracks if t.GetClass() == "PCB_VIA"
)
track_count = sum(1 for t in tracks if t.GetClass() != "PCB_VIA")
via_count = sum(1 for t in tracks if t.GetClass() == "PCB_VIA")
return {
"success": True,
@@ -528,13 +488,9 @@ class FreeroutingCommands:
},
}
def check_freerouting(
self, params: Dict[str, Any]
) -> Dict[str, Any]:
def check_freerouting(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Check if Freerouting and Java/Docker are available."""
jar_path = params.get(
"freeroutingJar", DEFAULT_FREEROUTING_JAR
)
jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR)
# Check local Java
java_exe = _find_java()
@@ -548,11 +504,7 @@ class FreeroutingCommands:
text=True,
timeout=10,
)
java_version = (
(proc.stderr or proc.stdout)
.strip()
.split("\n")[0]
)
java_version = (proc.stderr or proc.stdout).strip().split("\n")[0]
java_21_ok = _java_version_ok(java_exe)
except Exception:
pass

View File

@@ -5,20 +5,21 @@ Handles authentication and downloading the JLCPCB parts library
for integration with KiCAD component selection.
"""
import os
import logging
import requests
import time
import hmac
import base64
import hashlib
import hmac
import json
import logging
import os
import secrets
import string
import base64
import json
from typing import Optional, Dict, List, Callable
import time
from pathlib import Path
from typing import Callable, Dict, List, Optional
logger = logging.getLogger('kicad_interface')
import requests
logger = logging.getLogger("kicad_interface")
class JLCPCBClient:
@@ -31,7 +32,12 @@ class JLCPCBClient:
BASE_URL = "https://jlcpcb.com/external"
def __init__(self, app_id: Optional[str] = None, access_key: Optional[str] = None, secret_key: Optional[str] = None):
def __init__(
self,
app_id: Optional[str] = None,
access_key: Optional[str] = None,
secret_key: Optional[str] = None,
):
"""
Initialize JLCPCB API client
@@ -40,20 +46,24 @@ class JLCPCBClient:
access_key: JLCPCB Access Key (or reads from JLCPCB_API_KEY env var)
secret_key: JLCPCB Secret Key (or reads from JLCPCB_API_SECRET env var)
"""
self.app_id = app_id or os.getenv('JLCPCB_APP_ID')
self.access_key = access_key or os.getenv('JLCPCB_API_KEY')
self.secret_key = secret_key or os.getenv('JLCPCB_API_SECRET')
self.app_id = app_id or os.getenv("JLCPCB_APP_ID")
self.access_key = access_key or os.getenv("JLCPCB_API_KEY")
self.secret_key = secret_key or os.getenv("JLCPCB_API_SECRET")
if not self.app_id or not self.access_key or not self.secret_key:
logger.warning("JLCPCB API credentials not found. Set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables.")
logger.warning(
"JLCPCB API credentials not found. Set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables."
)
@staticmethod
def _generate_nonce() -> str:
"""Generate a 32-character random nonce"""
chars = string.ascii_letters + string.digits
return ''.join(secrets.choice(chars) for _ in range(32))
return "".join(secrets.choice(chars) for _ in range(32))
def _build_signature_string(self, method: str, path: str, timestamp: int, nonce: str, body: str) -> str:
def _build_signature_string(
self, method: str, path: str, timestamp: int, nonce: str, body: str
) -> str:
"""
Build the signature string according to JLCPCB spec
@@ -87,11 +97,9 @@ class JLCPCBClient:
Base64-encoded signature
"""
signature_bytes = hmac.new(
self.secret_key.encode('utf-8'),
signature_string.encode('utf-8'),
hashlib.sha256
self.secret_key.encode("utf-8"), signature_string.encode("utf-8"), hashlib.sha256
).digest()
return base64.b64encode(signature_bytes).decode('utf-8')
return base64.b64encode(signature_bytes).decode("utf-8")
def _get_auth_header(self, method: str, path: str, body: str = "") -> str:
"""
@@ -106,7 +114,9 @@ class JLCPCBClient:
Authorization header value
"""
if not self.app_id or not self.access_key or not self.secret_key:
raise Exception("JLCPCB API credentials not configured. Please set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables.")
raise Exception(
"JLCPCB API credentials not configured. Please set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables."
)
nonce = self._generate_nonce()
timestamp = int(time.time())
@@ -116,7 +126,9 @@ class JLCPCBClient:
logger.debug(f"Signature string:\n{repr(signature_string)}")
logger.debug(f"Signature: {signature}")
logger.debug(f"Auth header: JOP appid=\"{self.app_id}\",accesskey=\"{self.access_key}\",nonce=\"{nonce}\",timestamp=\"{timestamp}\",signature=\"{signature}\"")
logger.debug(
f'Auth header: JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"'
)
return f'JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"'
@@ -138,22 +150,16 @@ class JLCPCBClient:
# Convert payload to JSON string for signing
# For POST requests, we always send JSON, even if empty dict
body_str = json.dumps(payload, separators=(',', ':'))
body_str = json.dumps(payload, separators=(",", ":"))
# Generate authorization header
auth_header = self._get_auth_header("POST", path, body_str)
headers = {
"Authorization": auth_header,
"Content-Type": "application/json"
}
headers = {"Authorization": auth_header, "Content-Type": "application/json"}
try:
response = requests.post(
f"{self.BASE_URL}{path}",
headers=headers,
json=payload,
timeout=60
f"{self.BASE_URL}{path}", headers=headers, json=payload, timeout=60
)
logger.debug(f"Response status: {response.status_code}")
@@ -163,18 +169,19 @@ class JLCPCBClient:
response.raise_for_status()
data = response.json()
if data.get('code') != 200:
raise Exception(f"API request failed (code {data.get('code')}): {data.get('msg', 'Unknown error')} - Full response: {data}")
if data.get("code") != 200:
raise Exception(
f"API request failed (code {data.get('code')}): {data.get('msg', 'Unknown error')} - Full response: {data}"
)
return data['data']
return data["data"]
except requests.exceptions.RequestException as e:
logger.error(f"Failed to fetch parts page: {e}")
raise Exception(f"JLCPCB API request failed: {e}")
def download_full_database(
self,
callback: Optional[Callable[[int, int, str], None]] = None
self, callback: Optional[Callable[[int, int, str], None]] = None
) -> List[Dict]:
"""
Download entire parts library from JLCPCB
@@ -197,10 +204,10 @@ class JLCPCBClient:
try:
data = self.fetch_parts_page(last_key)
parts = data.get('componentInfos', [])
parts = data.get("componentInfos", [])
all_parts.extend(parts)
last_key = data.get('lastKey')
last_key = data.get("lastKey")
if callback:
callback(page, len(all_parts), f"Downloaded {len(all_parts)} parts...")
@@ -245,7 +252,9 @@ class JLCPCBClient:
return None
def test_jlcpcb_connection(app_id: Optional[str] = None, access_key: Optional[str] = None, secret_key: Optional[str] = None) -> bool:
def test_jlcpcb_connection(
app_id: Optional[str] = None, access_key: Optional[str] = None, secret_key: Optional[str] = None
) -> bool:
"""
Test JLCPCB API connection
@@ -268,7 +277,7 @@ def test_jlcpcb_connection(app_id: Optional[str] = None, access_key: Optional[st
return False
if __name__ == '__main__':
if __name__ == "__main__":
# Test the JLCPCB client
logging.basicConfig(level=logging.INFO)
@@ -279,7 +288,7 @@ if __name__ == '__main__':
client = JLCPCBClient()
print("\nFetching first page of parts...")
data = client.fetch_parts_page()
parts = data.get('componentInfos', [])
parts = data.get("componentInfos", [])
print(f"✓ Retrieved {len(parts)} parts in first page")
if parts:

View File

@@ -5,15 +5,15 @@ Manages local SQLite database of JLCPCB parts for fast searching
and component selection.
"""
import os
import sqlite3
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional
import os
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
class JLCPCBPartsManager:
@@ -38,10 +38,10 @@ class JLCPCBPartsManager:
db_path = str(data_dir / "jlcpcb_parts.db")
self.db_path = db_path
self.conn = None
self.conn: Optional[sqlite3.Connection] = None
self._init_database()
def _init_database(self):
def _init_database(self) -> None:
"""Initialize SQLite database with schema"""
self.conn = sqlite3.connect(self.db_path)
self.conn.row_factory = sqlite3.Row # Return rows as dicts
@@ -49,7 +49,7 @@ class JLCPCBPartsManager:
cursor = self.conn.cursor()
# Create components table
cursor.execute('''
cursor.execute("""
CREATE TABLE IF NOT EXISTS components (
lcsc TEXT PRIMARY KEY,
category TEXT,
@@ -65,17 +65,19 @@ class JLCPCBPartsManager:
price_json TEXT,
last_updated INTEGER
)
''')
""")
# Create indexes for fast searching
cursor.execute('CREATE INDEX IF NOT EXISTS idx_category ON components(category, subcategory)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_package ON components(package)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_manufacturer ON components(manufacturer)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_library_type ON components(library_type)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_mfr_part ON components(mfr_part)')
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_category ON components(category, subcategory)"
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_package ON components(package)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_manufacturer ON components(manufacturer)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_type ON components(library_type)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_mfr_part ON components(mfr_part)")
# Full-text search index for descriptions
cursor.execute('''
cursor.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS components_fts USING fts5(
lcsc,
description,
@@ -83,12 +85,14 @@ class JLCPCBPartsManager:
manufacturer,
content=components
)
''')
""")
self.conn.commit()
logger.info(f"Initialized JLCPCB parts database at {self.db_path}")
def import_parts(self, parts: List[Dict], progress_callback=None):
def import_parts(
self, parts: List[Dict], progress_callback: Optional[Callable[..., Any]] = None
) -> None:
"""
Import parts into database from JLCPCB API response
@@ -103,32 +107,35 @@ class JLCPCBPartsManager:
for i, part in enumerate(parts):
try:
# Extract price breaks
price_json = json.dumps(part.get('prices', []))
price_json = json.dumps(part.get("prices", []))
# Determine library type
library_type = self._determine_library_type(part)
cursor.execute('''
cursor.execute(
"""
INSERT OR REPLACE INTO components (
lcsc, category, subcategory, mfr_part, package,
solder_joints, manufacturer, library_type, description,
datasheet, stock, price_json, last_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
part.get('componentCode'), # lcsc
part.get('firstSortName'), # category
part.get('secondSortName'), # subcategory
part.get('componentModelEn'), # mfr_part
part.get('componentSpecificationEn'), # package
part.get('soldPoint'), # solder_joints
part.get('componentBrandEn'), # manufacturer
""",
(
part.get("componentCode"), # lcsc
part.get("firstSortName"), # category
part.get("secondSortName"), # subcategory
part.get("componentModelEn"), # mfr_part
part.get("componentSpecificationEn"), # package
part.get("soldPoint"), # solder_joints
part.get("componentBrandEn"), # manufacturer
library_type, # library_type
part.get('describe'), # description
part.get('dataManualUrl'), # datasheet
part.get('stockCount', 0), # stock
part.get("describe"), # description
part.get("dataManualUrl"), # datasheet
part.get("stockCount", 0), # stock
price_json, # price_json
int(datetime.now().timestamp()) # last_updated
))
int(datetime.now().timestamp()), # last_updated
),
)
imported += 1
@@ -140,10 +147,10 @@ class JLCPCBPartsManager:
skipped += 1
# Update FTS index
cursor.execute('''
cursor.execute("""
INSERT INTO components_fts(components_fts, rowid, lcsc, description, mfr_part, manufacturer)
SELECT 'rebuild', rowid, lcsc, description, mfr_part, manufacturer FROM components
''')
""")
self.conn.commit()
logger.info(f"Import complete: {imported} parts imported, {skipped} skipped")
@@ -151,18 +158,20 @@ class JLCPCBPartsManager:
def _determine_library_type(self, part: Dict) -> str:
"""Determine if part is Basic, Extended, or Preferred"""
# JLCPCB API should provide this, but if not, we infer from assembly type
assembly_type = part.get('assemblyType', '')
assembly_type = part.get("assemblyType", "")
if 'Basic' in assembly_type or part.get('libraryType') == 'base':
return 'Basic'
elif 'Extended' in assembly_type:
return 'Extended'
elif 'Prefer' in assembly_type:
return 'Preferred'
if "Basic" in assembly_type or part.get("libraryType") == "base":
return "Basic"
elif "Extended" in assembly_type:
return "Extended"
elif "Prefer" in assembly_type:
return "Preferred"
else:
return 'Extended' # Default to Extended
return "Extended" # Default to Extended
def import_jlcsearch_parts(self, parts: List[Dict], progress_callback=None):
def import_jlcsearch_parts(
self, parts: List[Dict], progress_callback: Optional[Callable[..., Any]] = None
) -> None:
"""
Import parts into database from JLCSearch API response
@@ -178,56 +187,59 @@ class JLCPCBPartsManager:
try:
# JLCSearch format is different from official API
# LCSC is an integer, we need to add 'C' prefix
lcsc = part.get('lcsc')
lcsc = part.get("lcsc")
if isinstance(lcsc, int):
lcsc = f"C{lcsc}"
# Build price JSON from jlcsearch single price
price = part.get('price') or part.get('price1')
price = part.get("price") or part.get("price1")
price_json = json.dumps([{"qty": 1, "price": price}] if price else [])
# Determine library type from is_basic flag
library_type = 'Basic' if part.get('is_basic') else 'Extended'
if part.get('is_preferred'):
library_type = 'Preferred'
library_type = "Basic" if part.get("is_basic") else "Extended"
if part.get("is_preferred"):
library_type = "Preferred"
# Extract description from various fields
description_parts = []
if 'resistance' in part:
if "resistance" in part:
description_parts.append(f"{part['resistance']}Ω")
if 'capacitance' in part:
if "capacitance" in part:
description_parts.append(f"{part['capacitance']}F")
if 'tolerance_fraction' in part:
tol = part['tolerance_fraction'] * 100
if "tolerance_fraction" in part:
tol = part["tolerance_fraction"] * 100
description_parts.append(f"±{tol}%")
if 'power_watts' in part:
if "power_watts" in part:
description_parts.append(f"{part['power_watts']}mW")
if 'voltage' in part:
if "voltage" in part:
description_parts.append(f"{part['voltage']}V")
description = part.get('description', ' '.join(description_parts))
description = part.get("description", " ".join(description_parts))
cursor.execute('''
cursor.execute(
"""
INSERT OR REPLACE INTO components (
lcsc, category, subcategory, mfr_part, package,
solder_joints, manufacturer, library_type, description,
datasheet, stock, price_json, last_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
""",
(
lcsc, # lcsc with C prefix
part.get('category', ''), # category
part.get('subcategory', ''), # subcategory
part.get('mfr', ''), # mfr_part
part.get('package', ''), # package
part.get("category", ""), # category
part.get("subcategory", ""), # subcategory
part.get("mfr", ""), # mfr_part
part.get("package", ""), # package
0, # solder_joints (not in jlcsearch)
part.get('manufacturer', ''), # manufacturer
part.get("manufacturer", ""), # manufacturer
library_type, # library_type
description, # description
'', # datasheet (not in jlcsearch)
part.get('stock', 0), # stock
"", # datasheet (not in jlcsearch)
part.get("stock", 0), # stock
price_json, # price_json
int(datetime.now().timestamp()) # last_updated
))
int(datetime.now().timestamp()), # last_updated
),
)
imported += 1
@@ -239,10 +251,10 @@ class JLCPCBPartsManager:
skipped += 1
# Update FTS index
cursor.execute('''
cursor.execute("""
INSERT INTO components_fts(components_fts)
VALUES('rebuild')
''')
""")
self.conn.commit()
logger.info(f"Import complete: {imported} parts imported, {skipped} skipped")
@@ -255,7 +267,7 @@ class JLCPCBPartsManager:
library_type: Optional[str] = None,
manufacturer: Optional[str] = None,
in_stock: bool = True,
limit: int = 20
limit: int = 20,
) -> List[Dict]:
"""
Search for parts with filters
@@ -280,13 +292,18 @@ class JLCPCBPartsManager:
if query:
# Use FTS for text search
sql_parts.append('''
# Add prefix wildcard to each term for partial matching
# (e.g., "BQ25895" becomes "BQ25895*" so FTS matches "BQ25895RTWR")
fts_query = " ".join(
f"{term}*" if not term.endswith("*") else term for term in query.strip().split()
)
sql_parts.append("""
AND lcsc IN (
SELECT lcsc FROM components_fts
WHERE components_fts MATCH ?
)
''')
params.append(query)
""")
params.append(fts_query)
if category:
sql_parts.append("AND category LIKE ?")
@@ -337,11 +354,11 @@ class JLCPCBPartsManager:
if row:
part = dict(row)
# Parse price JSON
if part.get('price_json'):
if part.get("price_json"):
try:
part['price_breaks'] = json.loads(part['price_json'])
part["price_breaks"] = json.loads(part["price_json"])
except:
part['price_breaks'] = []
part["price_breaks"] = []
return part
return None
@@ -350,23 +367,25 @@ class JLCPCBPartsManager:
cursor = self.conn.cursor()
cursor.execute("SELECT COUNT(*) as total FROM components")
total = cursor.fetchone()['total']
total = cursor.fetchone()["total"]
cursor.execute("SELECT COUNT(*) as basic FROM components WHERE library_type = 'Basic'")
basic = cursor.fetchone()['basic']
basic = cursor.fetchone()["basic"]
cursor.execute("SELECT COUNT(*) as extended FROM components WHERE library_type = 'Extended'")
extended = cursor.fetchone()['extended']
cursor.execute(
"SELECT COUNT(*) as extended FROM components WHERE library_type = 'Extended'"
)
extended = cursor.fetchone()["extended"]
cursor.execute("SELECT COUNT(*) as in_stock FROM components WHERE stock > 0")
in_stock = cursor.fetchone()['in_stock']
in_stock = cursor.fetchone()["in_stock"]
return {
'total_parts': total,
'basic_parts': basic,
'extended_parts': extended,
'in_stock': in_stock,
'db_path': self.db_path
"total_parts": total,
"basic_parts": basic,
"extended_parts": extended,
"in_stock": in_stock,
"db_path": self.db_path,
}
def map_package_to_footprint(self, package: str) -> List[str]:
@@ -384,43 +403,22 @@ class JLCPCBPartsManager:
"0402": [
"Resistor_SMD:R_0402_1005Metric",
"Capacitor_SMD:C_0402_1005Metric",
"LED_SMD:LED_0402_1005Metric"
"LED_SMD:LED_0402_1005Metric",
],
"0603": [
"Resistor_SMD:R_0603_1608Metric",
"Capacitor_SMD:C_0603_1608Metric",
"LED_SMD:LED_0603_1608Metric"
"LED_SMD:LED_0603_1608Metric",
],
"0805": [
"Resistor_SMD:R_0805_2012Metric",
"Capacitor_SMD:C_0805_2012Metric"
],
"1206": [
"Resistor_SMD:R_1206_3216Metric",
"Capacitor_SMD:C_1206_3216Metric"
],
"SOT-23": [
"Package_TO_SOT_SMD:SOT-23",
"Package_TO_SOT_SMD:SOT-23-3"
],
"SOT-23-5": [
"Package_TO_SOT_SMD:SOT-23-5"
],
"SOT-23-6": [
"Package_TO_SOT_SMD:SOT-23-6"
],
"SOIC-8": [
"Package_SO:SOIC-8_3.9x4.9mm_P1.27mm"
],
"SOIC-16": [
"Package_SO:SOIC-16_3.9x9.9mm_P1.27mm"
],
"QFN-20": [
"Package_DFN_QFN:QFN-20-1EP_4x4mm_P0.5mm_EP2.5x2.5mm"
],
"QFN-32": [
"Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm"
]
"0805": ["Resistor_SMD:R_0805_2012Metric", "Capacitor_SMD:C_0805_2012Metric"],
"1206": ["Resistor_SMD:R_1206_3216Metric", "Capacitor_SMD:C_1206_3216Metric"],
"SOT-23": ["Package_TO_SOT_SMD:SOT-23", "Package_TO_SOT_SMD:SOT-23-3"],
"SOT-23-5": ["Package_TO_SOT_SMD:SOT-23-5"],
"SOT-23-6": ["Package_TO_SOT_SMD:SOT-23-6"],
"SOIC-8": ["Package_SO:SOIC-8_3.9x4.9mm_P1.27mm"],
"SOIC-16": ["Package_SO:SOIC-16_3.9x9.9mm_P1.27mm"],
"QFN-20": ["Package_DFN_QFN:QFN-20-1EP_4x4mm_P0.5mm_EP2.5x2.5mm"],
"QFN-32": ["Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm"],
}
# Normalize package name
@@ -451,24 +449,21 @@ class JLCPCBPartsManager:
# Search for parts in same category with same package
alternatives = self.search_parts(
category=part['subcategory'],
package=part['package'],
in_stock=True,
limit=limit * 3
category=part["subcategory"], package=part["package"], in_stock=True, limit=limit * 3
)
# Filter out the original part
alternatives = [p for p in alternatives if p['lcsc'] != lcsc_number]
alternatives = [p for p in alternatives if p["lcsc"] != lcsc_number]
# Sort by: Basic first, then by price, then by stock
def sort_key(p):
is_basic = 1 if p.get('library_type') == 'Basic' else 0
def sort_key(p: Dict[str, Any]) -> Tuple[int, float, int]:
is_basic = 1 if p.get("library_type") == "Basic" else 0
try:
prices = json.loads(p.get('price_json', '[]'))
price = float(prices[0].get('price', 999)) if prices else 999
prices = json.loads(p.get("price_json", "[]"))
price = float(prices[0].get("price", 999)) if prices else 999
except:
price = 999
stock = p.get('stock', 0)
stock = p.get("stock", 0)
return (-is_basic, price, -stock)
@@ -476,13 +471,13 @@ class JLCPCBPartsManager:
return alternatives[:limit]
def close(self):
def close(self) -> None:
"""Close database connection"""
if self.conn:
self.conn.close()
if __name__ == '__main__':
if __name__ == "__main__":
# Test the parts manager
logging.basicConfig(level=logging.INFO)
@@ -497,8 +492,10 @@ if __name__ == '__main__':
print(f" In stock: {stats['in_stock']}")
print(f" Database: {stats['db_path']}")
if stats['total_parts'] > 0:
if stats["total_parts"] > 0:
print("\nSearching for '10k resistor'...")
results = manager.search_parts(query="10k resistor", limit=5)
for part in results:
print(f" {part['lcsc']}: {part['mfr_part']} - {part['description']} ({part['library_type']})")
print(
f" {part['lcsc']}: {part['mfr_part']} - {part['description']} ({part['library_type']})"
)

View File

@@ -6,11 +6,12 @@ jlcsearch service at https://jlcsearch.tscircuit.com/
"""
import logging
import requests
from typing import Optional, Dict, List, Callable
import time
from typing import Any, Callable, Dict, List, Optional, Union
logger = logging.getLogger('kicad_interface')
import requests
logger = logging.getLogger("kicad_interface")
class JLCSearchClient:
@@ -23,16 +24,12 @@ class JLCSearchClient:
BASE_URL = "https://jlcsearch.tscircuit.com"
def __init__(self):
def __init__(self) -> None:
"""Initialize JLCSearch API client"""
pass
def search_components(
self,
category: str = "components",
limit: int = 100,
offset: int = 0,
**filters
self, category: str = "components", limit: int = 100, offset: int = 0, **filters: Dict
) -> List[Dict]:
"""
Search components in JLCSearch database
@@ -48,11 +45,7 @@ class JLCSearchClient:
"""
url = f"{self.BASE_URL}/{category}/list.json"
params = {
"limit": limit,
"offset": offset,
**filters
}
params = {"limit": limit, "offset": offset, **filters}
try:
response = requests.get(url, params=params, timeout=30)
@@ -71,7 +64,9 @@ class JLCSearchClient:
logger.error(f"Failed to search JLCSearch: {e}")
raise Exception(f"JLCSearch API request failed: {e}")
def search_resistors(self, resistance: Optional[int] = None, package: Optional[str] = None, limit: int = 100) -> List[Dict]:
def search_resistors(
self, resistance: Optional[int] = None, package: Optional[str] = None, limit: int = 100
) -> List[Dict]:
"""
Search for resistors
@@ -92,7 +87,7 @@ class JLCSearchClient:
- stock: Available stock
- price1: Price per unit
"""
filters = {}
filters: Dict[str, Any] = {}
if resistance is not None:
filters["resistance"] = resistance
if package:
@@ -100,7 +95,9 @@ class JLCSearchClient:
return self.search_components("resistors", limit=limit, **filters)
def search_capacitors(self, capacitance: Optional[float] = None, package: Optional[str] = None, limit: int = 100) -> List[Dict]:
def search_capacitors(
self, capacitance: Optional[float] = None, package: Optional[str] = None, limit: int = 100
) -> List[Dict]:
"""
Search for capacitors
@@ -112,7 +109,7 @@ class JLCSearchClient:
Returns:
List of capacitor dicts
"""
filters = {}
filters: Dict[str, Any] = {}
if capacitance is not None:
filters["capacitance"] = capacitance
if package:
@@ -141,9 +138,7 @@ class JLCSearchClient:
return None
def download_all_components(
self,
callback: Optional[Callable[[int, str], None]] = None,
batch_size: int = 100
self, callback: Optional[Callable[[int, str], None]] = None, batch_size: int = 100
) -> List[Dict]:
"""
Download all components from jlcsearch database
@@ -165,11 +160,7 @@ class JLCSearchClient:
while True:
try:
batch = self.search_components(
"components",
limit=batch_size,
offset=offset
)
batch = self.search_components("components", limit=batch_size, offset=offset)
# Stop if no results returned (end of catalog)
if not batch or len(batch) == 0:
@@ -219,7 +210,7 @@ def test_jlcsearch_connection() -> bool:
return False
if __name__ == '__main__':
if __name__ == "__main__":
# Test the JLCSearch client
logging.basicConfig(level=logging.INFO)

View File

@@ -5,12 +5,12 @@ Handles parsing fp-lib-table files, discovering footprints,
and providing search functionality for component placement.
"""
import glob
import logging
import os
import re
import logging
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import glob
logger = logging.getLogger("kicad_interface")
@@ -35,7 +35,7 @@ class LibraryManager:
self.footprint_cache: Dict[str, List[str]] = {} # library -> [footprint names]
self._load_libraries()
def _load_libraries(self):
def _load_libraries(self) -> None:
"""Load libraries from fp-lib-table files"""
# Load global libraries
global_table = self._get_global_fp_lib_table()
@@ -58,13 +58,16 @@ class LibraryManager:
"""Get path to global fp-lib-table file"""
# Try different possible locations
kicad_config_paths = [
Path.home() / ".config" / "kicad" / "10.0" / "fp-lib-table",
Path.home() / ".config" / "kicad" / "9.0" / "fp-lib-table",
Path.home() / ".config" / "kicad" / "8.0" / "fp-lib-table",
Path.home() / ".config" / "kicad" / "fp-lib-table",
# Windows paths
Path.home() / "AppData" / "Roaming" / "kicad" / "10.0" / "fp-lib-table",
Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "fp-lib-table",
Path.home() / "AppData" / "Roaming" / "kicad" / "8.0" / "fp-lib-table",
# macOS paths
Path.home() / "Library" / "Preferences" / "kicad" / "10.0" / "fp-lib-table",
Path.home() / "Library" / "Preferences" / "kicad" / "9.0" / "fp-lib-table",
Path.home() / "Library" / "Preferences" / "kicad" / "8.0" / "fp-lib-table",
]
@@ -75,7 +78,7 @@ class LibraryManager:
return None
def _parse_fp_lib_table(self, table_path: Path):
def _parse_fp_lib_table(self, table_path: Path) -> None:
"""
Parse fp-lib-table file
@@ -90,11 +93,21 @@ class LibraryManager:
# Simple regex-based parser for lib entries
# Pattern: (lib (name "NAME")(type TYPE)(uri "URI")...)
lib_pattern = r'\(lib\s+\(name\s+"?([^")\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^")\s]+)"?'
lib_pattern = r'\(lib\s+\(name\s+"?([^")\s]+)"?\)\s*\(type\s+"?([^")\s]+)"?\)\s*\(uri\s+"?([^")\s]+)"?'
for match in re.finditer(lib_pattern, content, re.IGNORECASE):
nickname = match.group(1)
uri = match.group(2)
lib_type = match.group(2)
uri = match.group(3)
if lib_type.lower() == "table":
table_uri = uri
if os.path.isabs(table_uri) and os.path.isfile(table_uri):
logger.info(f" Following Table reference: {nickname} -> {table_uri}")
self._parse_fp_lib_table(Path(table_uri))
else:
logger.warning(f" Could not resolve Table URI: {table_uri}")
continue
# Resolve environment variables in URI
resolved_uri = self._resolve_uri(uri)
@@ -103,9 +116,7 @@ class LibraryManager:
self.libraries[nickname] = resolved_uri
logger.debug(f" Found library: {nickname} -> {resolved_uri}")
else:
logger.warning(
f" Could not resolve URI for library {nickname}: {uri}"
)
logger.warning(f" Could not resolve URI for library {nickname}: {uri}")
except Exception as e:
logger.error(f"Error parsing fp-lib-table at {table_path}: {e}")
@@ -126,10 +137,12 @@ class LibraryManager:
# Common KiCAD environment variables
env_vars = {
"KICAD10_FOOTPRINT_DIR": self._find_kicad_footprint_dir(),
"KICAD9_FOOTPRINT_DIR": self._find_kicad_footprint_dir(),
"KICAD8_FOOTPRINT_DIR": self._find_kicad_footprint_dir(),
"KICAD_FOOTPRINT_DIR": self._find_kicad_footprint_dir(),
"KISYSMOD": self._find_kicad_footprint_dir(),
"KICAD10_3RD_PARTY": self._find_kicad_3rdparty_dir(),
"KICAD9_3RD_PARTY": self._find_kicad_3rdparty_dir(),
"KICAD8_3RD_PARTY": self._find_kicad_3rdparty_dir(),
}
@@ -206,12 +219,7 @@ class LibraryManager:
/ "9.0"
/ "kicad_common.json", # macOS
Path.home() / ".config" / "kicad" / "9.0" / "kicad_common.json", # Linux
Path.home()
/ "AppData"
/ "Roaming"
/ "kicad"
/ "9.0"
/ "kicad_common.json", # Windows
Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "kicad_common.json", # Windows
]
for config_path in kicad_common_paths:
@@ -337,9 +345,7 @@ class LibraryManager:
for library_nickname, library_path in self.libraries.items():
fp_file = Path(library_path) / f"{footprint_name}.kicad_mod"
if fp_file.exists():
logger.info(
f"Found footprint {footprint_name} in library {library_nickname}"
)
logger.info(f"Found footprint {footprint_name} in library {library_nickname}")
return (library_path, footprint_name)
logger.warning(f"Footprint not found in any library: {footprint_name}")
@@ -446,9 +452,7 @@ class LibraryCommands:
# Filter by library if specified
if library_filter:
results = [
r
for r in results
if r.get("library", "").lower() == library_filter.lower()
r for r in results if r.get("library", "").lower() == library_filter.lower()
]
results = results[:limit]
@@ -492,7 +496,7 @@ class LibraryCommands:
def get_footprint_info(self, params: Dict) -> Dict:
"""Get information about a specific footprint"""
try:
footprint_spec = params.get("footprint")
footprint_spec = params.get("footprint_name")
if not footprint_spec:
return {"success": False, "message": "Missing footprint parameter"}
@@ -508,19 +512,39 @@ class LibraryCommands:
library_nickname = nick
break
info = {
# Minimal info — always returned even if the parser fails
info: Dict = {
"library": library_nickname,
"footprint": footprint_name,
"name": footprint_name,
"full_name": f"{library_nickname}:{footprint_name}",
"library_path": library_path,
}
return {"success": True, "footprint_info": info}
# Attempt to enrich with parsed .kicad_mod data
try:
from pathlib import Path as _Path
from parsers.kicad_mod_parser import parse_kicad_mod
mod_file = str(_Path(library_path) / f"{footprint_name}.kicad_mod")
parsed = parse_kicad_mod(mod_file)
if parsed:
# Merge parser output into info; keep our resolved library context
info.update(parsed)
info["name"] = footprint_name # entry name wins over in-file name
info["library"] = library_nickname
info["full_name"] = f"{library_nickname}:{footprint_name}"
info["library_path"] = library_path
else:
return {
"success": False,
"message": f"Footprint not found: {footprint_spec}",
}
logger.warning(
f"get_footprint_info: parser returned nothing for {mod_file}, using minimal info"
)
except Exception as parse_err:
logger.warning(
f"get_footprint_info: parser error ({parse_err}), using minimal info"
)
return {"success": True, "info": info}
except Exception as e:
logger.error(f"Error getting footprint info: {e}")

View File

@@ -1,13 +1,20 @@
from skip import Schematic
import glob
import logging
# Symbol class might not be directly importable in the current version
import os
import glob
from typing import Any, Dict, List, Optional
from skip import Schematic
logger = logging.getLogger(__name__)
class LibraryManager:
"""Manage symbol libraries"""
@staticmethod
def list_available_libraries(search_paths=None):
def list_available_libraries(search_paths: Optional[List[str]] = None) -> Dict[str, List[str]]:
"""List all available symbol libraries"""
if search_paths is None:
# Default library paths based on common KiCAD installations
@@ -16,7 +23,9 @@ class LibraryManager:
"C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym", # Windows path pattern
"/usr/share/kicad/symbols/*.kicad_sym", # Linux path pattern
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", # macOS path pattern
os.path.expanduser("~/Documents/KiCad/*/symbols/*.kicad_sym") # User libraries pattern
os.path.expanduser(
"~/Documents/KiCad/*/symbols/*.kicad_sym"
), # User libraries pattern
]
libraries = []
@@ -26,17 +35,19 @@ class LibraryManager:
matching_libs = glob.glob(path_pattern, recursive=True)
libraries.extend(matching_libs)
except Exception as e:
print(f"Error searching for libraries at {path_pattern}: {e}")
logger.error(f"Error searching for libraries at {path_pattern}: {e}")
# Extract library names from paths
library_names = [os.path.splitext(os.path.basename(lib))[0] for lib in libraries]
print(f"Found {len(library_names)} libraries: {', '.join(library_names[:10])}{'...' if len(library_names) > 10 else ''}")
logger.info(
f"Found {len(library_names)} libraries: {', '.join(library_names[:10])}{'...' if len(library_names) > 10 else ''}"
)
# Return both full paths and library names
return {"paths": libraries, "names": library_names}
@staticmethod
def list_library_symbols(library_path):
def list_library_symbols(library_path: str) -> List[Any]:
"""List all symbols in a library"""
try:
# kicad-skip doesn't provide a direct way to simply list symbols in a library
@@ -47,26 +58,30 @@ class LibraryManager:
# A potential approach would be to load the library file using KiCAD's Python API
# or by parsing the library file format.
# KiCAD symbol libraries are .kicad_sym files which are S-expression format
print(f"Attempted to list symbols in library {library_path}. This requires advanced implementation.")
logger.warning(
f"Attempted to list symbols in library {library_path}. This requires advanced implementation."
)
return []
except Exception as e:
print(f"Error listing symbols in library {library_path}: {e}")
logger.error(f"Error listing symbols in library {library_path}: {e}")
return []
@staticmethod
def get_symbol_details(library_path, symbol_name):
def get_symbol_details(library_path: str, symbol_name: str) -> Dict[str, Any]:
"""Get detailed information about a symbol"""
try:
# Similar to list_library_symbols, this might require a more direct approach
# using KiCAD's Python API or by parsing the symbol library.
print(f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation.")
logger.warning(
f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation."
)
return {}
except Exception as e:
print(f"Error getting symbol details for {symbol_name} in {library_path}: {e}")
logger.error(f"Error getting symbol details for {symbol_name} in {library_path}: {e}")
return {}
@staticmethod
def search_symbols(query, search_paths=None):
def search_symbols(query: str, search_paths: Optional[List[str]] = None) -> List[Any]:
"""Search for symbols matching criteria"""
try:
# This would typically involve:
@@ -78,14 +93,18 @@ class LibraryManager:
libraries = LibraryManager.list_available_libraries(search_paths)
results = []
print(f"Searched for symbols matching '{query}'. This requires advanced implementation.")
logger.warning(
f"Searched for symbols matching '{query}'. This requires advanced implementation."
)
return results
except Exception as e:
print(f"Error searching for symbols matching '{query}': {e}")
logger.error(f"Error searching for symbols matching '{query}': {e}")
return []
@staticmethod
def get_default_symbol_for_component_type(component_type, search_paths=None):
def get_default_symbol_for_component_type(
component_type: str, search_paths: Optional[List[str]] = None
) -> Dict[str, str]:
"""Get a recommended default symbol for a given component type"""
# This method provides a simplified way to get a symbol for common component types
# It's useful when the user doesn't specify a particular library/symbol
@@ -119,7 +138,8 @@ class LibraryManager:
# Default fallback
return {"library": "Device", "symbol": "R"}
if __name__ == '__main__':
if __name__ == "__main__":
# Example Usage (for testing)
# List available libraries
libraries = LibraryManager.list_available_libraries()

View File

@@ -5,19 +5,20 @@ Handles parsing sym-lib-table files, discovering symbols,
and providing search functionality for component selection.
"""
import logging
import os
import re
import logging
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Dict, List, Optional
from dataclasses import dataclass, asdict
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
@dataclass
class SymbolInfo:
"""Information about a symbol in a library"""
name: str # Symbol name (without library prefix)
library: str # Library nickname
full_ref: str # "Library:SymbolName"
@@ -54,7 +55,7 @@ class SymbolLibraryManager:
self.symbol_cache: Dict[str, List[SymbolInfo]] = {} # library -> [SymbolInfo]
self._load_libraries()
def _load_libraries(self):
def _load_libraries(self) -> None:
"""Load libraries from sym-lib-table files"""
# Load global libraries
global_table = self._get_global_sym_lib_table()
@@ -77,13 +78,16 @@ class SymbolLibraryManager:
"""Get path to global sym-lib-table file"""
# Try different possible locations (same as fp-lib-table but for symbols)
kicad_config_paths = [
Path.home() / ".config" / "kicad" / "10.0" / "sym-lib-table",
Path.home() / ".config" / "kicad" / "9.0" / "sym-lib-table",
Path.home() / ".config" / "kicad" / "8.0" / "sym-lib-table",
Path.home() / ".config" / "kicad" / "sym-lib-table",
# Windows paths
Path.home() / "AppData" / "Roaming" / "kicad" / "10.0" / "sym-lib-table",
Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "sym-lib-table",
Path.home() / "AppData" / "Roaming" / "kicad" / "8.0" / "sym-lib-table",
# macOS paths
Path.home() / "Library" / "Preferences" / "kicad" / "10.0" / "sym-lib-table",
Path.home() / "Library" / "Preferences" / "kicad" / "9.0" / "sym-lib-table",
Path.home() / "Library" / "Preferences" / "kicad" / "8.0" / "sym-lib-table",
]
@@ -94,7 +98,7 @@ class SymbolLibraryManager:
return None
def _parse_sym_lib_table(self, table_path: Path):
def _parse_sym_lib_table(self, table_path: Path) -> None:
"""
Parse sym-lib-table file
@@ -104,16 +108,26 @@ class SymbolLibraryManager:
)
"""
try:
with open(table_path, 'r', encoding='utf-8') as f:
with open(table_path, "r", encoding="utf-8") as f:
content = f.read()
# Simple regex-based parser for lib entries
# Pattern: (lib (name "NAME")(type TYPE)(uri "URI")...)
lib_pattern = r'\(lib\s+\(name\s+"?([^")\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^")\s]+)"?'
lib_pattern = r'\(lib\s+\(name\s+"?([^")\s]+)"?\)\s*\(type\s+"?([^")\s]+)"?\)\s*\(uri\s+"?([^")\s]+)"?'
for match in re.finditer(lib_pattern, content, re.IGNORECASE):
nickname = match.group(1)
uri = match.group(2)
lib_type = match.group(2)
uri = match.group(3)
if lib_type.lower() == "table":
table_uri = uri
if os.path.isabs(table_uri) and os.path.isfile(table_uri):
logger.info(f" Following Table reference: {nickname} -> {table_uri}")
self._parse_sym_lib_table(Path(table_uri))
else:
logger.warning(f" Could not resolve Table URI: {table_uri}")
continue
# Resolve environment variables in URI
resolved_uri = self._resolve_uri(uri)
@@ -142,23 +156,25 @@ class SymbolLibraryManager:
# Common KiCAD environment variables
env_vars = {
'KICAD9_SYMBOL_DIR': self._find_kicad_symbol_dir(),
'KICAD8_SYMBOL_DIR': self._find_kicad_symbol_dir(),
'KICAD_SYMBOL_DIR': self._find_kicad_symbol_dir(),
'KICAD9_3RD_PARTY': self._find_3rd_party_dir(),
'KICAD8_3RD_PARTY': self._find_3rd_party_dir(),
'KISYSSYM': self._find_kicad_symbol_dir(),
"KICAD10_SYMBOL_DIR": self._find_kicad_symbol_dir(),
"KICAD9_SYMBOL_DIR": self._find_kicad_symbol_dir(),
"KICAD8_SYMBOL_DIR": self._find_kicad_symbol_dir(),
"KICAD_SYMBOL_DIR": self._find_kicad_symbol_dir(),
"KICAD10_3RD_PARTY": self._find_3rd_party_dir(),
"KICAD9_3RD_PARTY": self._find_3rd_party_dir(),
"KICAD8_3RD_PARTY": self._find_3rd_party_dir(),
"KISYSSYM": self._find_kicad_symbol_dir(),
}
# Project directory
if self.project_path:
env_vars['KIPRJMOD'] = str(self.project_path)
env_vars["KIPRJMOD"] = str(self.project_path)
# Replace environment variables
for var, value in env_vars.items():
if value:
resolved = resolved.replace(f'${{{var}}}', value)
resolved = resolved.replace(f'${var}', value)
resolved = resolved.replace(f"${{{var}}}", value)
resolved = resolved.replace(f"${var}", value)
# Expand ~ to home directory
resolved = os.path.expanduser(resolved)
@@ -184,10 +200,10 @@ class SymbolLibraryManager:
]
# Check environment variable
if 'KICAD9_SYMBOL_DIR' in os.environ:
possible_paths.insert(0, os.environ['KICAD9_SYMBOL_DIR'])
if 'KICAD8_SYMBOL_DIR' in os.environ:
possible_paths.insert(0, os.environ['KICAD8_SYMBOL_DIR'])
if "KICAD9_SYMBOL_DIR" in os.environ:
possible_paths.insert(0, os.environ["KICAD9_SYMBOL_DIR"])
if "KICAD8_SYMBOL_DIR" in os.environ:
possible_paths.insert(0, os.environ["KICAD8_SYMBOL_DIR"])
for path in possible_paths:
if os.path.isdir(path):
@@ -198,15 +214,18 @@ class SymbolLibraryManager:
def _find_3rd_party_dir(self) -> Optional[str]:
"""Find KiCAD 3rd party library directory (PCM installed libs)"""
possible_paths = [
str(Path.home() / "Documents" / "KiCad" / "10.0" / "3rdparty"),
str(Path.home() / "Documents" / "KiCad" / "9.0" / "3rdparty"),
str(Path.home() / "Documents" / "KiCad" / "8.0" / "3rdparty"),
]
# Check environment variable
if 'KICAD9_3RD_PARTY' in os.environ:
possible_paths.insert(0, os.environ['KICAD9_3RD_PARTY'])
if 'KICAD8_3RD_PARTY' in os.environ:
possible_paths.insert(0, os.environ['KICAD8_3RD_PARTY'])
if "KICAD10_3RD_PARTY" in os.environ:
possible_paths.insert(0, os.environ["KICAD10_3RD_PARTY"])
if "KICAD9_3RD_PARTY" in os.environ:
possible_paths.insert(0, os.environ["KICAD9_3RD_PARTY"])
if "KICAD8_3RD_PARTY" in os.environ:
possible_paths.insert(0, os.environ["KICAD8_3RD_PARTY"])
for path in possible_paths:
if os.path.isdir(path):
@@ -228,7 +247,7 @@ class SymbolLibraryManager:
symbols = []
try:
with open(library_path, 'r', encoding='utf-8') as f:
with open(library_path, "r", encoding="utf-8") as f:
content = f.read()
# Find all top-level symbol definitions
@@ -243,7 +262,7 @@ class SymbolLibraryManager:
symbol_name = match.group(1)
# Skip sub-symbols (they contain _0_, _1_, etc. suffixes)
if re.search(r'_\d+_\d+$', symbol_name):
if re.search(r"_\d+_\d+$", symbol_name):
continue
# Find the start position of this symbol
@@ -262,17 +281,17 @@ class SymbolLibraryManager:
name=symbol_name,
library=library_name,
full_ref=f"{library_name}:{symbol_name}",
value=properties.get('Value', ''),
description=properties.get('Description', ''),
footprint=properties.get('Footprint', ''),
lcsc_id=properties.get('LCSC', ''),
manufacturer=properties.get('Manufacturer', ''),
mpn=properties.get('Part', properties.get('MPN', '')),
category=properties.get('Category', ''),
datasheet=properties.get('Datasheet', ''),
stock=properties.get('Stock', ''),
price=properties.get('Price', ''),
lib_class=properties.get('Class', ''),
value=properties.get("Value", ""),
description=properties.get("Description", ""),
footprint=properties.get("Footprint", ""),
lcsc_id=properties.get("LCSC", ""),
manufacturer=properties.get("Manufacturer", ""),
mpn=properties.get("Part", properties.get("MPN", "")),
category=properties.get("Category", ""),
datasheet=properties.get("Datasheet", ""),
stock=properties.get("Stock", ""),
price=properties.get("Price", ""),
lib_class=properties.get("Class", ""),
)
symbols.append(symbol_info)
@@ -333,7 +352,9 @@ class SymbolLibraryManager:
return symbols
def search_symbols(self, query: str, limit: int = 20, library_filter: Optional[str] = None) -> List[SymbolInfo]:
def search_symbols(
self, query: str, limit: int = 20, library_filter: Optional[str] = None
) -> List[SymbolInfo]:
"""
Search for symbols matching a query
@@ -349,10 +370,12 @@ class SymbolLibraryManager:
query_lower = query.lower()
# Determine which libraries to search
libraries_to_search = self.libraries.keys()
libraries_to_search: list[str] = list(self.libraries.keys())
if library_filter:
filter_lower = library_filter.lower()
libraries_to_search = [lib for lib in libraries_to_search if filter_lower in lib.lower()]
libraries_to_search = [
lib for lib in libraries_to_search if filter_lower in lib.lower()
]
for library_nickname in libraries_to_search:
symbols = self.list_symbols(library_nickname)
@@ -477,17 +500,13 @@ class SymbolLibraryCommands:
"""List all available symbol libraries"""
try:
libraries = self.library_manager.list_libraries()
return {
"success": True,
"libraries": libraries,
"count": len(libraries)
}
return {"success": True, "libraries": libraries, "count": len(libraries)}
except Exception as e:
logger.error(f"Error listing symbol libraries: {e}")
return {
"success": False,
"message": "Failed to list symbol libraries",
"errorDetails": str(e)
"errorDetails": str(e),
}
def search_symbols(self, params: Dict) -> Dict:
@@ -495,10 +514,7 @@ class SymbolLibraryCommands:
try:
query = params.get("query", "")
if not query:
return {
"success": False,
"message": "Missing query parameter"
}
return {"success": False, "message": "Missing query parameter"}
limit = params.get("limit", 20)
library_filter = params.get("library")
@@ -509,25 +525,18 @@ class SymbolLibraryCommands:
"success": True,
"symbols": [asdict(s) for s in results],
"count": len(results),
"query": query
"query": query,
}
except Exception as e:
logger.error(f"Error searching symbols: {e}")
return {
"success": False,
"message": "Failed to search symbols",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to search symbols", "errorDetails": str(e)}
def list_library_symbols(self, params: Dict) -> Dict:
"""List all symbols in a specific library"""
try:
library = params.get("library")
if not library:
return {
"success": False,
"message": "Missing library parameter"
}
return {"success": False, "message": "Missing library parameter"}
# Check if library exists in sym-lib-table
if library not in self.library_manager.libraries:
@@ -539,7 +548,7 @@ class SymbolLibraryCommands:
f"Found {len(available_libs)} libraries. "
f"Please add this library to your sym-lib-table file, or use one of the available libraries.",
"available_libraries_count": len(available_libs),
"suggestion": "Use 'list_symbol_libraries' to see all available libraries"
"suggestion": "Use 'list_symbol_libraries' to see all available libraries",
}
symbols = self.library_manager.list_symbols(library)
@@ -548,14 +557,14 @@ class SymbolLibraryCommands:
"success": True,
"library": library,
"symbols": [asdict(s) for s in symbols],
"count": len(symbols)
"count": len(symbols),
}
except Exception as e:
logger.error(f"Error listing library symbols: {e}")
return {
"success": False,
"message": "Failed to list library symbols",
"errorDetails": str(e)
"errorDetails": str(e),
}
def get_symbol_info(self, params: Dict) -> Dict:
@@ -563,34 +572,25 @@ class SymbolLibraryCommands:
try:
symbol_spec = params.get("symbol")
if not symbol_spec:
return {
"success": False,
"message": "Missing symbol parameter"
}
return {"success": False, "message": "Missing symbol parameter"}
result = self.library_manager.find_symbol(symbol_spec)
if result:
return {
"success": True,
"symbol_info": asdict(result)
}
return {"success": True, "symbol_info": asdict(result)}
else:
return {
"success": False,
"message": f"Symbol not found: {symbol_spec}"
}
return {"success": False, "message": f"Symbol not found: {symbol_spec}"}
except Exception as e:
logger.error(f"Error getting symbol info: {e}")
return {
"success": False,
"message": "Failed to get symbol info",
"errorDetails": str(e)
"errorDetails": str(e),
}
if __name__ == '__main__':
if __name__ == "__main__":
# Test the symbol library manager
import json

View File

@@ -9,7 +9,8 @@ import logging
import math
import tempfile
from pathlib import Path
from typing import List, Tuple, Optional, Dict
from typing import Any, Dict, List, Optional, Tuple
import sexpdata
from sexpdata import Symbol
from skip import Schematic
@@ -20,7 +21,7 @@ logger = logging.getLogger("kicad_interface")
class PinLocator:
"""Locate pins on symbol instances in KiCad schematics"""
def __init__(self):
def __init__(self) -> None:
"""Initialize pin locator with empty cache"""
self.pin_definition_cache = {} # Cache: "lib_id:symbol_name" -> pin_data
self._schematic_cache: Dict[str, object] = {} # Cache: path -> loaded Schematic
@@ -40,9 +41,9 @@ class PinLocator:
"2": {"x": 0, "y": -3.81, "angle": 90, "length": 1.27, "name": "~", "type": "passive"}
}
"""
pins = {}
pins: Dict[str, Dict[str, Any]] = {}
def extract_pins_recursive(sexp):
def extract_pins_recursive(sexp: Any) -> None:
"""Recursively search for pin definitions"""
if not isinstance(sexp, list):
return
@@ -117,11 +118,7 @@ class PinLocator:
# Find lib_symbols section
lib_symbols = None
for item in sch_data:
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("lib_symbols")
):
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol("lib_symbols"):
lib_symbols = item
break
@@ -131,11 +128,7 @@ class PinLocator:
# Find the specific symbol definition
for item in lib_symbols[1:]: # Skip 'lib_symbols' itself
if (
isinstance(item, list)
and len(item) > 1
and item[0] == Symbol("symbol")
):
if isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol"):
symbol_name = str(item[1]).strip('"')
if symbol_name == lib_id:
# Found the symbol, parse pins
@@ -284,9 +277,7 @@ class PinLocator:
symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0
# Get symbol lib_id
lib_id = (
target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
)
lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
if not lib_id:
logger.error(f"Symbol {symbol_reference} has no lib_id")
return None
@@ -309,7 +300,9 @@ class PinLocator:
None,
)
if matched_num:
logger.debug(f"Resolved pin name '{pin_number}' to pin number '{matched_num}' on {symbol_reference}")
logger.debug(
f"Resolved pin name '{pin_number}' to pin number '{matched_num}' on {symbol_reference}"
)
pin_number = matched_num
else:
logger.error(
@@ -324,26 +317,18 @@ class PinLocator:
pin_rel_x = pin_data["x"]
pin_rel_y = pin_data["y"]
logger.debug(
f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})"
)
logger.debug(f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})")
# Apply symbol rotation to pin position
if symbol_rotation != 0:
pin_rel_x, pin_rel_y = self.rotate_point(
pin_rel_x, pin_rel_y, symbol_rotation
)
logger.debug(
f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})"
)
pin_rel_x, pin_rel_y = self.rotate_point(pin_rel_x, pin_rel_y, symbol_rotation)
logger.debug(f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})")
# Calculate absolute position
abs_x = symbol_x + pin_rel_x
abs_y = symbol_y + pin_rel_y
logger.info(
f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})"
)
logger.info(f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})")
return [abs_x, abs_y]
except Exception as e:
@@ -385,9 +370,7 @@ class PinLocator:
return {}
# Get lib_id
lib_id = (
target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
)
lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
if not lib_id:
logger.error(f"Symbol {symbol_reference} has no lib_id")
return {}
@@ -400,9 +383,7 @@ class PinLocator:
# Calculate location for each pin
result = {}
for pin_num in pins.keys():
location = self.get_pin_location(
schematic_path, symbol_reference, pin_num
)
location = self.get_pin_location(schematic_path, symbol_reference, pin_num)
if location:
result[pin_num] = location
@@ -416,14 +397,14 @@ class PinLocator:
if __name__ == "__main__":
# Test pin location discovery
import shutil
import sys
sys.path.insert(0, "/home/chris/MCP/KiCAD-MCP-Server/python")
from pathlib import Path
from commands.component_schematic import ComponentManager
from commands.schematic import SchematicManager
import shutil
sys.path.insert(0, str(Path(__file__).parent.parent))
print("=" * 80)
print("PIN LOCATOR TEST")
@@ -431,8 +412,8 @@ if __name__ == "__main__":
# Create test schematic with components (cross-platform temp directory)
test_path = Path(tempfile.gettempdir()) / "test_pin_locator.kicad_sch"
template_path = Path(
"/home/chris/MCP/KiCAD-MCP-Server/python/templates/template_with_symbols_expanded.kicad_sch"
template_path = (
Path(__file__).parent.parent / "templates" / "template_with_symbols_expanded.kicad_sch"
)
shutil.copy(template_path, test_path)

View File

@@ -2,11 +2,12 @@
Project-related command implementations for KiCAD interface
"""
import os
import pcbnew # type: ignore
import logging
import os
import shutil
from typing import Dict, Any, Optional
from typing import Any, Dict, Optional
import pcbnew # type: ignore
logger = logging.getLogger("kicad_interface")
@@ -22,9 +23,7 @@ class ProjectCommands:
"""Create a new KiCAD project"""
try:
# Accept both 'name' (from MCP tool) and 'projectName' (legacy)
project_name = params.get("name") or params.get(
"projectName", "New_Project"
)
project_name = params.get("name") or params.get("projectName", "New_Project")
path = params.get("path", os.getcwd())
template = params.get("template")
@@ -101,9 +100,7 @@ class ProjectCommands:
schematic_uuid = str(uuid_module.uuid4())
with open(schematic_path, "w", encoding="utf-8", newline="\n") as f:
f.write(
'(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n'
)
f.write('(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n')
f.write(f" (uuid {schematic_uuid})\n\n")
f.write(' (paper "A4")\n\n')
f.write(" (lib_symbols\n )\n\n")
@@ -207,9 +204,7 @@ class ProjectCommands:
"success": True,
"message": f"Saved project to: {self.board.GetFileName()}",
"project": {
"name": os.path.splitext(
os.path.basename(self.board.GetFileName())
)[0],
"name": os.path.splitext(os.path.basename(self.board.GetFileName()))[0],
"path": self.board.GetFileName(),
},
}

View File

@@ -2,11 +2,12 @@
Routing-related command implementations for KiCAD interface
"""
import os
import pcbnew
import logging
import math
from typing import Dict, Any, Optional, List, Tuple
import os
from typing import Any, Dict, List, Optional, Tuple
import pcbnew
logger = logging.getLogger("kicad_interface")
@@ -114,7 +115,7 @@ class RoutingCommands:
"errorDetails": f"'{ref}' does not exist on the board",
}
def find_pad(ref: str, pad_num: str):
def find_pad(ref: str, pad_num: str) -> Any:
fp = footprints[ref]
for pad in fp.Pads():
if pad.GetNumber() == pad_num:
@@ -168,24 +169,34 @@ class RoutingCommands:
via_y = (start_pos.y + end_pos.y) / 2 / scale
# Trace on start layer: start_pad → via
r1 = self.route_trace({
r1 = self.route_trace(
{
"start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"},
"end": {"x": via_x, "y": via_y, "unit": "mm"},
"layer": start_layer, "width": width, "net": net,
})
"layer": start_layer,
"width": width,
"net": net,
}
)
# Via connecting both layers
self.add_via({
self.add_via(
{
"position": {"x": via_x, "y": via_y, "unit": "mm"},
"net": net,
"from_layer": start_layer,
"to_layer": end_layer,
})
}
)
# Trace on end layer: via → end_pad
r2 = self.route_trace({
r2 = self.route_trace(
{
"start": {"x": via_x, "y": via_y, "unit": "mm"},
"end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"},
"layer": end_layer, "width": width, "net": net,
})
"layer": end_layer,
"width": width,
"net": net,
}
)
success = r1.get("success") and r2.get("success")
result = {
"success": success,
@@ -195,21 +206,28 @@ class RoutingCommands:
}
else:
# Same layer — direct trace
result = self.route_trace({
result = self.route_trace(
{
"start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"},
"end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"},
"layer": layer if layer else start_layer,
"width": width, "net": net,
})
"width": width,
"net": net,
}
)
if result.get("success"):
result["fromPad"] = {
"ref": from_ref, "pad": from_pad,
"x": start_pos.x / scale, "y": start_pos.y / scale,
"ref": from_ref,
"pad": from_pad,
"x": start_pos.x / scale,
"y": start_pos.y / scale,
}
result["toPad"] = {
"ref": to_ref, "pad": to_pad,
"x": end_pos.x / scale, "y": end_pos.y / scale,
"ref": to_ref,
"pad": to_pad,
"x": end_pos.x / scale,
"y": end_pos.y / scale,
}
return result
@@ -352,21 +370,15 @@ class RoutingCommands:
via = pcbnew.PCB_VIA(self.board)
# Set position
scale = (
1000000 if position["unit"] == "mm" else 25400000
) # mm or inch to nm
scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm
x_nm = int(position["x"] * scale)
y_nm = int(position["y"] * scale)
via.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
# Set size and drill (default to board's current via settings)
design_settings = self.board.GetDesignSettings()
via.SetWidth(
int(size * 1000000) if size else design_settings.GetCurrentViaSize()
)
via.SetDrill(
int(drill * 1000000) if drill else design_settings.GetCurrentViaDrill()
)
via.SetWidth(int(size * 1000000) if size else design_settings.GetCurrentViaSize())
via.SetDrill(int(drill * 1000000) if drill else design_settings.GetCurrentViaDrill())
# Set layers
from_id = self.board.GetLayerID(from_layer)
@@ -500,9 +512,7 @@ class RoutingCommands:
# Find track by position
if position:
scale = (
1000000 if position["unit"] == "mm" else 25400000
) # mm or inch to nm
scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm
x_nm = int(position["x"] * scale)
y_nm = int(position["y"] * scale)
point = pcbnew.VECTOR2I(x_nm, y_nm)
@@ -940,9 +950,7 @@ class RoutingCommands:
else:
traces_to_copy.append(track)
filter_method = (
"net-based" if use_net_filter else "geometric (pads have no nets)"
)
filter_method = "net-based" if use_net_filter else "geometric (pads have no nets)"
logger.info(
f"copy_routing_pattern: {len(traces_to_copy)} traces, "
f"{len(vias_to_copy)} vias selected via {filter_method}"
@@ -958,9 +966,7 @@ class RoutingCommands:
# Create new track
new_track = pcbnew.PCB_TRACK(self.board)
new_track.SetStart(
pcbnew.VECTOR2I(start.x + offset_x, start.y + offset_y)
)
new_track.SetStart(pcbnew.VECTOR2I(start.x + offset_x, start.y + offset_y))
new_track.SetEnd(pcbnew.VECTOR2I(end.x + offset_x, end.y + offset_y))
new_track.SetLayer(track.GetLayer())
@@ -1320,15 +1326,11 @@ class RoutingCommands:
pos_start = pcbnew.VECTOR2I(
int(start_point.x + offset_x), int(start_point.y + offset_y)
)
pos_end = pcbnew.VECTOR2I(
int(end_point.x + offset_x), int(end_point.y + offset_y)
)
pos_end = pcbnew.VECTOR2I(int(end_point.x + offset_x), int(end_point.y + offset_y))
neg_start = pcbnew.VECTOR2I(
int(start_point.x - offset_x), int(start_point.y - offset_y)
)
neg_end = pcbnew.VECTOR2I(
int(end_point.x - offset_x), int(end_point.y - offset_y)
)
neg_end = pcbnew.VECTOR2I(int(end_point.x - offset_x), int(end_point.y - offset_y))
# Create positive trace
pos_track = pcbnew.PCB_TRACK(self.board)
@@ -1395,9 +1397,7 @@ class RoutingCommands:
return pad.GetPosition()
raise ValueError("Invalid point specification")
def _point_to_track_distance(
self, point: pcbnew.VECTOR2I, track: pcbnew.PCB_TRACK
) -> float:
def _point_to_track_distance(self, point: pcbnew.VECTOR2I, track: pcbnew.PCB_TRACK) -> float:
"""Calculate distance from point to track segment"""
start = track.GetStart()
end = track.GetEnd()

View File

@@ -1,8 +1,10 @@
from skip import Schematic
import logging
import os
import shutil
import logging
import uuid
from typing import Any, Optional
from skip import Schematic
logger = logging.getLogger("kicad_interface")
@@ -11,7 +13,7 @@ class SchematicManager:
"""Core schematic operations using kicad-skip"""
@staticmethod
def create_schematic(name, metadata=None):
def create_schematic(name: str, metadata: Optional[Any] = None) -> Any:
"""Create a new empty schematic from template"""
try:
# Determine template path (use template_with_symbols for component cloning support)
@@ -31,31 +33,28 @@ class SchematicManager:
# Regenerate UUID to ensure uniqueness for each created schematic
import re
with open(output_path, 'r', encoding='utf-8') as f:
with open(output_path, "r", encoding="utf-8") as f:
content = f.read()
new_uuid = str(uuid.uuid4())
content = re.sub(
r'\(uuid [0-9a-fA-F-]+\)',
f'(uuid {new_uuid})',
r"\(uuid [0-9a-fA-F-]+\)",
f"(uuid {new_uuid})",
content,
count=1 # Only replace first (schematic) UUID
count=1, # Only replace first (schematic) UUID
)
with open(output_path, 'w', encoding='utf-8', newline='\n') as f:
with open(output_path, "w", encoding="utf-8", newline="\n") as f:
f.write(content)
logger.info(f"Created schematic from template: {output_path}")
else:
# Fallback: create minimal schematic
logger.warning(
f"Template not found at {template_path}, creating minimal schematic"
)
logger.warning(f"Template not found at {template_path}, creating minimal schematic")
# Generate unique UUID for this schematic
schematic_uuid = str(uuid.uuid4())
# Write with explicit UTF-8 encoding and Unix line endings for cross-platform compatibility
with open(output_path, "w", encoding="utf-8", newline="\n") as f:
f.write(
'(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n'
)
f.write('(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n')
f.write(f" (uuid {schematic_uuid})\n\n")
f.write(' (paper "A4")\n\n')
f.write(" (lib_symbols\n )\n\n")
@@ -72,7 +71,7 @@ class SchematicManager:
raise
@staticmethod
def load_schematic(file_path):
def load_schematic(file_path: str) -> Optional[Any]:
"""Load an existing schematic"""
if not os.path.exists(file_path):
logger.error(f"Schematic file not found at {file_path}")
@@ -86,7 +85,7 @@ class SchematicManager:
return None
@staticmethod
def save_schematic(schematic, file_path):
def save_schematic(schematic: Any, file_path: str) -> bool:
"""Save a schematic to file"""
try:
# kicad-skip uses write method, not save
@@ -98,7 +97,7 @@ class SchematicManager:
return False
@staticmethod
def get_schematic_metadata(schematic):
def get_schematic_metadata(schematic: Any) -> dict[str, Any]:
"""Extract metadata from schematic"""
# kicad-skip doesn't expose a direct metadata object on Schematic.
# We can return basic info like version and generator.

View File

@@ -0,0 +1,976 @@
"""
Schematic Analysis Tools for KiCad Schematics
Read-only analysis tools for detecting spatial problems, querying regions,
and checking connectivity in KiCad schematic files.
"""
import logging
import math
from collections import defaultdict
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
import sexpdata
from commands.pin_locator import PinLocator
from commands.wire_connectivity import _parse_virtual_connections, _to_iu
from sexpdata import Symbol
from skip import Schematic
logger = logging.getLogger("kicad_interface")
# ---------------------------------------------------------------------------
# S-expression parsing helpers
# ---------------------------------------------------------------------------
def _load_sexp(schematic_path: Path) -> list:
"""Load schematic file and return parsed S-expression data."""
with open(schematic_path, "r", encoding="utf-8") as f:
return sexpdata.loads(f.read())
def _parse_wires(sexp_data: list) -> List[Dict[str, Any]]:
"""
Parse all wire segments from the schematic S-expression.
Returns list of dicts: {start: (x_mm, y_mm), end: (x_mm, y_mm)}
"""
wires = []
for item in sexp_data:
if not isinstance(item, list) or len(item) < 2:
continue
if item[0] != Symbol("wire"):
continue
pts = None
for sub in item:
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
pts = sub
break
if not pts:
continue
coords = []
for sub in pts:
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("xy"):
coords.append((float(sub[1]), float(sub[2])))
if len(coords) >= 2:
wires.append({"start": coords[0], "end": coords[1]})
return wires
def _parse_labels(sexp_data: list) -> List[Dict[str, Any]]:
"""
Parse all labels (label and global_label) from the schematic S-expression.
Returns list of dicts: {name, type ('label'|'global_label'), x, y}
"""
labels = []
for item in sexp_data:
if not isinstance(item, list) or len(item) < 2:
continue
tag = item[0]
if tag not in (Symbol("label"), Symbol("global_label")):
continue
name = str(item[1]).strip('"')
label_type = str(tag)
x, y = 0.0, 0.0
for sub in item:
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("at"):
x = float(sub[1])
y = float(sub[2])
break
labels.append({"name": name, "type": label_type, "x": x, "y": y})
return labels
def _parse_symbols(sexp_data: list) -> List[Dict[str, Any]]:
"""
Parse all placed symbol instances from the schematic S-expression.
Returns list of dicts: {reference, lib_id, x, y, rotation, mirror_x, mirror_y, is_power}
"""
symbols = []
for item in sexp_data:
if not isinstance(item, list) or len(item) < 2:
continue
if item[0] != Symbol("symbol"):
continue
lib_id = ""
x, y, rotation = 0.0, 0.0, 0.0
reference = ""
is_power = False
mirror_x = False
mirror_y = False
for sub in item:
if isinstance(sub, list) and len(sub) >= 2:
if sub[0] == Symbol("lib_id"):
lib_id = str(sub[1]).strip('"')
elif sub[0] == Symbol("at") and len(sub) >= 3:
x = float(sub[1])
y = float(sub[2])
if len(sub) >= 4:
rotation = float(sub[3])
elif sub[0] == Symbol("mirror"):
m = str(sub[1])
if m == "x":
mirror_x = True
elif m == "y":
mirror_y = True
elif sub[0] == Symbol("property") and len(sub) >= 3:
prop_name = str(sub[1]).strip('"')
if prop_name == "Reference":
reference = str(sub[2]).strip('"')
is_power = reference.startswith("#PWR") or reference.startswith("#FLG")
symbols.append(
{
"reference": reference,
"lib_id": lib_id,
"x": x,
"y": y,
"rotation": rotation,
"mirror_x": mirror_x,
"mirror_y": mirror_y,
"is_power": is_power,
}
)
return symbols
def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]:
"""
Parse graphical body elements from a lib_symbol definition and return
local-coordinate bounding points.
Extracts points from rectangle, polyline, circle, arc, and bezier
elements found in sub-symbols (typically the ``_0_1`` layers that
contain body shapes).
Returns a list of ``(x, y)`` points in local symbol coordinates.
"""
points: List[Tuple[float, float]] = []
def _extract_graphics_recursive(sexp: list) -> None:
if not isinstance(sexp, list) or len(sexp) == 0:
return
tag = sexp[0]
if tag == Symbol("rectangle"):
# (rectangle (start x y) (end x y) ...)
for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) >= 3:
if sub[0] in (Symbol("start"), Symbol("end")):
points.append((float(sub[1]), float(sub[2])))
elif tag == Symbol("polyline"):
# (polyline (pts (xy x y) (xy x y) ...) ...)
for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
for pt in sub[1:]:
if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"):
points.append((float(pt[1]), float(pt[2])))
elif tag == Symbol("circle"):
# (circle (center x y) (radius r) ...)
cx, cy, r = 0.0, 0.0, 0.0
for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("center"):
cx, cy = float(sub[1]), float(sub[2])
elif isinstance(sub, list) and len(sub) >= 2 and sub[0] == Symbol("radius"):
r = float(sub[1])
if r > 0:
points.extend(
[
(cx - r, cy - r),
(cx + r, cy + r),
]
)
elif tag == Symbol("arc"):
# (arc (start x y) (mid x y) (end x y) ...)
for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) >= 3:
if sub[0] in (Symbol("start"), Symbol("mid"), Symbol("end")):
points.append((float(sub[1]), float(sub[2])))
elif tag == Symbol("bezier"):
# (bezier (pts (xy x y) ...) ...)
for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
for pt in sub[1:]:
if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"):
points.append((float(pt[1]), float(pt[2])))
else:
# Recurse into sub-symbols to find graphics in nested definitions
for sub in sexp[1:]:
if isinstance(sub, list):
_extract_graphics_recursive(sub)
# Search the top-level symbol definition and its sub-symbols
for item in symbol_def[1:]:
if isinstance(item, list):
_extract_graphics_recursive(item)
return points
def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict]:
"""
Walk the lib_symbols section of already-parsed sexp_data and return
pin definitions and graphics points for every symbol definition.
Returns:
Dict mapping lib_id → {"pins": pin_defs, "graphics_points": [(x,y), ...]}.
"""
lib_symbols_section = None
for item in sexp_data:
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol("lib_symbols"):
lib_symbols_section = item
break
if not lib_symbols_section:
return {}
result: Dict[str, Dict] = {}
for item in lib_symbols_section[1:]:
if isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol"):
symbol_name = str(item[1]).strip('"')
result[symbol_name] = {
"pins": PinLocator.parse_symbol_definition(item),
"graphics_points": _parse_lib_symbol_graphics(item),
}
return result
# ---------------------------------------------------------------------------
# Geometry helpers
# ---------------------------------------------------------------------------
def compute_symbol_bbox(
schematic_path: Path,
reference: str,
locator: PinLocator,
) -> Optional[Tuple[float, float, float, float]]:
"""
Compute bounding box of a symbol from its pin positions.
Returns (min_x, min_y, max_x, max_y) in mm, or None if no pins found.
"""
pins = locator.get_all_symbol_pins(schematic_path, reference)
if not pins:
return None
xs = [p[0] for p in pins.values()]
ys = [p[1] for p in pins.values()]
return (min(xs), min(ys), max(xs), max(ys))
def _line_segment_intersects_aabb(
x1: float,
y1: float,
x2: float,
y2: float,
box_min_x: float,
box_min_y: float,
box_max_x: float,
box_max_y: float,
) -> bool:
"""
Test whether line segment (x1,y1)→(x2,y2) intersects an axis-aligned bounding box.
Uses the Liang-Barsky clipping algorithm.
"""
dx = x2 - x1
dy = y2 - y1
p = [-dx, dx, -dy, dy]
q = [x1 - box_min_x, box_max_x - x1, y1 - box_min_y, box_max_y - y1]
t_min = 0.0
t_max = 1.0
for i in range(4):
if abs(p[i]) < 1e-12:
# Parallel to this edge
if q[i] < 0:
return False
else:
t = q[i] / p[i]
if p[i] < 0:
t_min = max(t_min, t)
else:
t_max = min(t_max, t)
if t_min > t_max:
return False
return True
def _point_in_rect(
px: float,
py: float,
min_x: float,
min_y: float,
max_x: float,
max_y: float,
) -> bool:
"""Check if a point is within a rectangle."""
return min_x <= px <= max_x and min_y <= py <= max_y
def _distance(p1: Tuple[float, float], p2: Tuple[float, float]) -> float:
"""Euclidean distance between two points."""
return math.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2)
def _aabb_overlap(
a: Tuple[float, float, float, float],
b: Tuple[float, float, float, float],
) -> bool:
"""Check if two axis-aligned bounding boxes overlap.
Each bbox is (min_x, min_y, max_x, max_y).
"""
return a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3]
def _transform_local_point(
lx: float,
ly: float,
sym_x: float,
sym_y: float,
rotation: float,
mirror_x: bool,
mirror_y: bool,
) -> Tuple[float, float]:
"""
Transform a point from local symbol coordinates to absolute schematic
coordinates using KiCad's transform order:
negate-y (lib y-up → schematic y-down) → mirror → rotate → translate.
"""
# Library symbols use y-up; schematic uses y-down
ly = -ly
# Apply mirroring in local coords
if mirror_x:
ly = -ly
if mirror_y:
lx = -lx
# Apply rotation
if rotation != 0:
lx, ly = PinLocator.rotate_point(lx, ly, rotation)
return (sym_x + lx, sym_y + ly)
def _compute_symbol_bbox_direct(
sym: Dict[str, Any],
pin_defs: Dict[str, Dict],
margin: float = 0.0,
graphics_points: Optional[List[Tuple[float, float]]] = None,
) -> Optional[Tuple[float, float, float, float]]:
"""
Compute bounding box of a symbol from its graphics and pin definitions.
When graphics_points are available (from lib_symbol body shapes), uses
those for the bbox and unions with pin positions. Falls back to
pin-only estimation with degenerate expansion when no graphics data
is available.
Args:
sym: Parsed symbol dict with x, y, rotation, mirror_x, mirror_y.
pin_defs: Pin definitions from PinLocator.get_symbol_pins().
margin: Shrink bbox by this amount on each side (mm).
graphics_points: Local-coordinate points from symbol body graphics.
Returns (min_x, min_y, max_x, max_y) in mm, or None if no pins.
"""
pin_positions = _compute_pin_positions_direct(sym, pin_defs)
if not pin_positions:
return None
if graphics_points:
# Transform graphics points to absolute coordinates
sym_x, sym_y = sym["x"], sym["y"]
rotation = sym["rotation"]
mirror_x = sym.get("mirror_x", False)
mirror_y = sym.get("mirror_y", False)
abs_points = [
_transform_local_point(lx, ly, sym_x, sym_y, rotation, mirror_x, mirror_y)
for lx, ly in graphics_points
]
# Union with pin positions so pins extending beyond body are included
all_xs = [p[0] for p in abs_points] + [p[0] for p in pin_positions.values()]
all_ys = [p[1] for p in abs_points] + [p[1] for p in pin_positions.values()]
min_x, min_y = min(all_xs), min(all_ys)
max_x, max_y = max(all_xs), max(all_ys)
else:
# Fallback: pin-only estimation with degenerate expansion
xs = [p[0] for p in pin_positions.values()]
ys = [p[1] for p in pin_positions.values()]
min_x, min_y, max_x, max_y = min(xs), min(ys), max(xs), max(ys)
min_body = 1.5 # mm minimum half-extent for component body
if max_x - min_x < 2 * min_body:
cx = (min_x + max_x) / 2
min_x = cx - min_body
max_x = cx + min_body
if max_y - min_y < 2 * min_body:
cy = (min_y + max_y) / 2
min_y = cy - min_body
max_y = cy + min_body
# Shrink bbox by margin
min_x += margin
min_y += margin
max_x -= margin
max_y -= margin
# Skip degenerate bboxes
if max_x <= min_x or max_y <= min_y:
return None
return (min_x, min_y, max_x, max_y)
# ---------------------------------------------------------------------------
# Tool 3: find_overlapping_elements
# ---------------------------------------------------------------------------
def find_overlapping_elements(schematic_path: Path, tolerance: float = 0.5) -> Dict[str, Any]:
"""
Detect spatially overlapping symbols, wires, and labels.
Args:
schematic_path: Path to .kicad_sch file
tolerance: Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection.
Returns dict: {overlappingSymbols, overlappingLabels, overlappingWires, totalOverlaps}
"""
sexp_data = _load_sexp(schematic_path)
symbols = _parse_symbols(sexp_data)
wires = _parse_wires(sexp_data)
labels = _parse_labels(sexp_data)
overlapping_symbols = []
overlapping_labels = []
overlapping_wires = []
lib_defs = _extract_lib_symbols(sexp_data)
# --- Symbol-symbol overlap using bounding-box intersection (O(n²)) ---
non_template_symbols = [
s for s in symbols if not s["reference"].startswith("_TEMPLATE") and s["reference"]
]
# Pre-compute bounding boxes for all non-template symbols
symbol_bboxes = []
for sym in non_template_symbols:
lib_data = lib_defs.get(sym["lib_id"], {})
pin_defs = lib_data.get("pins", {})
graphics_points = lib_data.get("graphics_points", [])
bbox = None
if pin_defs:
bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points)
symbol_bboxes.append((sym, bbox))
for i in range(len(symbol_bboxes)):
s1, bbox1 = symbol_bboxes[i]
for j in range(i + 1, len(symbol_bboxes)):
s2, bbox2 = symbol_bboxes[j]
dist = _distance((s1["x"], s1["y"]), (s2["x"], s2["y"]))
overlap_detected = False
if bbox1 is not None and bbox2 is not None:
# Use bounding box intersection
overlap_detected = _aabb_overlap(bbox1, bbox2)
else:
# Fallback to center distance when pin data is unavailable
overlap_detected = dist < tolerance
if overlap_detected:
entry = {
"element1": {
"reference": s1["reference"],
"libId": s1["lib_id"],
"position": {"x": s1["x"], "y": s1["y"]},
},
"element2": {
"reference": s2["reference"],
"libId": s2["lib_id"],
"position": {"x": s2["x"], "y": s2["y"]},
},
"distance": round(dist, 4),
}
# Flag power symbol pairs specifically
if s1["is_power"] and s2["is_power"]:
entry["type"] = "power_symbol_overlap"
else:
entry["type"] = "symbol_overlap"
overlapping_symbols.append(entry)
# --- Label-label overlap ---
for i in range(len(labels)):
for j in range(i + 1, len(labels)):
l1 = labels[i]
l2 = labels[j]
dist = _distance((l1["x"], l1["y"]), (l2["x"], l2["y"]))
if dist < tolerance:
overlapping_labels.append(
{
"element1": {
"name": l1["name"],
"type": l1["type"],
"position": {"x": l1["x"], "y": l1["y"]},
},
"element2": {
"name": l2["name"],
"type": l2["type"],
"position": {"x": l2["x"], "y": l2["y"]},
},
"distance": round(dist, 4),
}
)
# --- Wire-wire collinear overlap ---
for i in range(len(wires)):
for j in range(i + 1, len(wires)):
w1 = wires[i]
w2 = wires[j]
overlap = _check_wire_overlap(w1, w2, tolerance)
if overlap:
overlapping_wires.append(overlap)
total = len(overlapping_symbols) + len(overlapping_labels) + len(overlapping_wires)
return {
"overlappingSymbols": overlapping_symbols,
"overlappingLabels": overlapping_labels,
"overlappingWires": overlapping_wires,
"totalOverlaps": total,
}
def _check_wire_overlap(
w1: Dict[str, Any], w2: Dict[str, Any], tolerance: float
) -> Optional[Dict[str, Any]]:
"""
Check if two wire segments are collinear and overlapping.
Works for horizontal, vertical, and diagonal wires. Uses direction
vectors, cross-product parallelism, point-to-line distance for
collinearity, and 1D projection overlap.
Returns overlap info dict or None.
"""
s1, e1 = w1["start"], w1["end"]
s2, e2 = w2["start"], w2["end"]
d1 = (e1[0] - s1[0], e1[1] - s1[1])
d2 = (e2[0] - s2[0], e2[1] - s2[1])
len1 = math.sqrt(d1[0] ** 2 + d1[1] ** 2)
len2 = math.sqrt(d2[0] ** 2 + d2[1] ** 2)
if len1 < 1e-12 or len2 < 1e-12:
return None # degenerate zero-length segment
# Cross product to check parallel
cross = d1[0] * d2[1] - d1[1] * d2[0]
if abs(cross) > tolerance * max(len1, len2):
return None # not parallel
# Point-to-line distance: s2 relative to line through s1 along d1
ds = (s2[0] - s1[0], s2[1] - s1[1])
perp_dist = abs(ds[0] * d1[1] - ds[1] * d1[0]) / len1
if perp_dist > tolerance:
return None # parallel but offset
# Project onto d1 direction for 1D overlap check
u1 = (d1[0] / len1, d1[1] / len1)
proj_s1 = s1[0] * u1[0] + s1[1] * u1[1]
proj_e1 = e1[0] * u1[0] + e1[1] * u1[1]
proj_s2 = s2[0] * u1[0] + s2[1] * u1[1]
proj_e2 = e2[0] * u1[0] + e2[1] * u1[1]
min1, max1 = min(proj_s1, proj_e1), max(proj_s1, proj_e1)
min2, max2 = min(proj_s2, proj_e2), max(proj_s2, proj_e2)
if min1 < max2 and min2 < max1:
return {
"wire1": {
"start": {"x": s1[0], "y": s1[1]},
"end": {"x": e1[0], "y": e1[1]},
},
"wire2": {
"start": {"x": s2[0], "y": s2[1]},
"end": {"x": e2[0], "y": e2[1]},
},
"type": "collinear_overlap",
}
return None
# ---------------------------------------------------------------------------
# Tool 4: get_elements_in_region
# ---------------------------------------------------------------------------
def get_elements_in_region(
schematic_path: Path,
x1: float,
y1: float,
x2: float,
y2: float,
) -> Dict[str, Any]:
"""
List all wires, labels, and symbols within a rectangular region.
Args:
schematic_path: Path to .kicad_sch file
x1, y1, x2, y2: Bounding box corners in schematic mm
Returns dict: {symbols, wires, labels, counts}
"""
min_x, max_x = min(x1, x2), max(x1, x2)
min_y, max_y = min(y1, y2), max(y1, y2)
sexp_data = _load_sexp(schematic_path)
symbols = _parse_symbols(sexp_data)
wires = _parse_wires(sexp_data)
labels = _parse_labels(sexp_data)
lib_defs = _extract_lib_symbols(sexp_data)
# Symbols: include if position is within bounds
region_symbols = []
for sym in symbols:
if not sym["reference"] or sym["reference"].startswith("_TEMPLATE"):
continue
if _point_in_rect(sym["x"], sym["y"], min_x, min_y, max_x, max_y):
entry = {
"reference": sym["reference"],
"libId": sym["lib_id"],
"position": {"x": sym["x"], "y": sym["y"]},
"isPower": sym["is_power"],
}
# Include pin positions (compute directly to handle unannotated duplicates)
lib_data = lib_defs.get(sym["lib_id"], {})
pin_defs = lib_data.get("pins", {})
if pin_defs:
pin_positions = _compute_pin_positions_direct(sym, pin_defs)
if pin_positions:
entry["pins"] = {
pn: {"x": round(pos[0], 4), "y": round(pos[1], 4)}
for pn, pos in pin_positions.items()
}
region_symbols.append(entry)
# Wires: include if any part of the wire intersects the region
region_wires = []
for w in wires:
s, e = w["start"], w["end"]
if (
_point_in_rect(s[0], s[1], min_x, min_y, max_x, max_y)
or _point_in_rect(e[0], e[1], min_x, min_y, max_x, max_y)
or _line_segment_intersects_aabb(s[0], s[1], e[0], e[1], min_x, min_y, max_x, max_y)
):
region_wires.append(
{
"start": {"x": s[0], "y": s[1]},
"end": {"x": e[0], "y": e[1]},
}
)
# Labels: include if position is within bounds
region_labels = []
for lbl in labels:
if _point_in_rect(lbl["x"], lbl["y"], min_x, min_y, max_x, max_y):
region_labels.append(
{
"name": lbl["name"],
"type": lbl["type"],
"position": {"x": lbl["x"], "y": lbl["y"]},
}
)
return {
"symbols": region_symbols,
"wires": region_wires,
"labels": region_labels,
"counts": {
"symbols": len(region_symbols),
"wires": len(region_wires),
"labels": len(region_labels),
},
}
# ---------------------------------------------------------------------------
# Tool 5: check_wire_collisions
# ---------------------------------------------------------------------------
def _compute_pin_positions_direct(
sym: Dict[str, Any], pin_defs: Dict[str, Dict]
) -> Dict[str, List[float]]:
"""
Compute absolute schematic pin positions for a symbol instance directly from
its parsed position/rotation/mirror data and pin definitions in local coords.
Unlike PinLocator.get_all_symbol_pins, this does NOT do a reference-name
lookup in the schematic, so it works correctly when multiple symbols share
the same reference designator (e.g. unannotated "Q?").
KiCad transform order: mirror (in local coords) → rotate → translate.
"""
sym_x = sym["x"]
sym_y = sym["y"]
rotation = sym["rotation"]
mirror_x = sym.get("mirror_x", False)
mirror_y = sym.get("mirror_y", False)
result: Dict[str, List[float]] = {}
for pin_num, pin_data in pin_defs.items():
rel_x = float(pin_data["x"])
rel_y = float(pin_data["y"])
# Apply mirroring in local symbol coordinates
if mirror_x:
rel_y = -rel_y
if mirror_y:
rel_x = -rel_x
# Apply symbol rotation
if rotation != 0:
rel_x, rel_y = PinLocator.rotate_point(rel_x, rel_y, rotation)
result[pin_num] = [sym_x + rel_x, sym_y + rel_y]
return result
def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]:
"""
Find all wires that cross over component symbol bodies.
Wires passing over symbols are unacceptable in schematics — they indicate
routing mistakes where a wire was drawn across a component instead of
around it.
For each non-power, non-template symbol:
1. Compute bounding box from pin positions (shrunk by margin).
2. For each wire segment, test intersection with the bbox.
3. If intersects and the wire is not simply terminating at a pin from
outside, report it as a crossing.
Returns list of crossing dicts.
"""
sexp_data = _load_sexp(schematic_path)
symbols = _parse_symbols(sexp_data)
wires = _parse_wires(sexp_data)
lib_defs = _extract_lib_symbols(sexp_data)
margin = 0.5 # mm margin to shrink bbox (avoids false positives at pin tips)
pin_tolerance = 0.05 # mm
collisions = []
# Pre-compute per-symbol data
symbol_data: List[Dict[str, Any]] = []
for sym in symbols:
ref = sym["reference"]
if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref:
continue
lib_data = lib_defs.get(sym["lib_id"], {})
pin_defs = lib_data.get("pins", {})
if not pin_defs:
continue
graphics_points = lib_data.get("graphics_points", [])
bbox = _compute_symbol_bbox_direct(
sym, pin_defs, margin=margin, graphics_points=graphics_points
)
if bbox is None:
continue
pin_positions = _compute_pin_positions_direct(sym, pin_defs)
pin_set = set()
for pos in pin_positions.values():
pin_set.add((pos[0], pos[1]))
symbol_data.append(
{
"sym": sym,
"bbox": bbox,
"pin_set": pin_set,
}
)
# Test each wire against each symbol bbox
for w in wires:
sx, sy = w["start"]
ex, ey = w["end"]
for sd in symbol_data:
bx1, by1, bx2, by2 = sd["bbox"]
if not _line_segment_intersects_aabb(sx, sy, ex, ey, bx1, by1, bx2, by2):
continue
# Check which endpoints land on a pin of this symbol
start_at_pin = any(
abs(sx - px) < pin_tolerance and abs(sy - py) < pin_tolerance
for px, py in sd["pin_set"]
)
end_at_pin = any(
abs(ex - px) < pin_tolerance and abs(ey - py) < pin_tolerance
for px, py in sd["pin_set"]
)
# When exactly one endpoint is at a pin, check whether the wire
# just terminates at the pin (valid connection) or continues through
# the component body (pass-through → collision).
# Nudge the pin endpoint slightly toward the other end; if the
# shortened segment still intersects the bbox, the wire extends
# into/through the body.
if (start_at_pin or end_at_pin) and not (start_at_pin and end_at_pin):
dx, dy = ex - sx, ey - sy
length = math.sqrt(dx * dx + dy * dy)
if length > 0:
nudge = min(0.2, length * 0.5)
ux, uy = dx / length, dy / length
if start_at_pin:
nsx, nsy = sx + ux * nudge, sy + uy * nudge
if not _line_segment_intersects_aabb(nsx, nsy, ex, ey, bx1, by1, bx2, by2):
continue # Wire terminates at pin from outside
else:
nex, ney = ex - ux * nudge, ey - uy * nudge
if not _line_segment_intersects_aabb(sx, sy, nex, ney, bx1, by1, bx2, by2):
continue # Wire terminates at pin from outside
sym = sd["sym"]
collisions.append(
{
"wire": {
"start": {"x": sx, "y": sy},
"end": {"x": ex, "y": ey},
},
"component": {
"reference": sym["reference"],
"libId": sym["lib_id"],
"position": {"x": sym["x"], "y": sym["y"]},
},
"intersectionType": "passes_through",
}
)
return collisions
def find_orphaned_wires(schematic_path: Path) -> Dict[str, Any]:
"""
Find wire segments with at least one dangling endpoint.
A wire endpoint is dangling when the IU point at that endpoint satisfies
all three conditions simultaneously:
1. No other wire shares that IU endpoint (would imply a junction / T-join)
2. No component pin is at that IU point
3. No net label or power symbol pin is at that IU point
Uses exact KiCad IU matching (10 000 IU/mm) — same strategy as
wire_connectivity.py — to avoid floating-point tolerance issues.
Returns:
{
"orphaned_wires": [
{
"start": {"x": float, "y": float},
"end": {"x": float, "y": float},
"dangling_ends": [{"x": float, "y": float}, ...]
},
...
],
"count": int
}
"""
sexp_data = _load_sexp(schematic_path)
# --- wire endpoints in mm and IU ---
wires_mm = _parse_wires(sexp_data)
wires_iu: List[Tuple[Tuple[int, int], Tuple[int, int]]] = [
(_to_iu(*w["start"]), _to_iu(*w["end"])) for w in wires_mm
]
# Count how many wires touch each IU endpoint
iu_to_count: Dict[Tuple[int, int], int] = defaultdict(int)
for s_iu, e_iu in wires_iu:
iu_to_count[s_iu] += 1
iu_to_count[e_iu] += 1
# --- anchors: component pins ---
pin_iu: Set[Tuple[int, int]] = set()
try:
locator = PinLocator()
sch = Schematic(str(schematic_path))
for symbol in sch.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"):
continue
all_pins = locator.get_all_symbol_pins(schematic_path, ref)
for coords in all_pins.values():
pin_iu.add(_to_iu(float(coords[0]), float(coords[1])))
except Exception as e:
logger.warning(f"Error reading pins for symbol: {e}")
except Exception as e:
logger.warning(f"Could not load schematic via skip for pin extraction: {e}")
sch = None
# --- anchors: net labels and global_labels ---
labels = _parse_labels(sexp_data)
label_iu: Set[Tuple[int, int]] = {_to_iu(lbl["x"], lbl["y"]) for lbl in labels}
# --- anchors: power symbol pins (VCC, GND …) ---
power_iu: Set[Tuple[int, int]] = set()
if sch is not None:
try:
point_to_label, _ = _parse_virtual_connections(sch, schematic_path)
power_iu = set(point_to_label.keys())
except Exception as e:
logger.warning(f"Could not extract power symbol anchors: {e}")
anchored_iu = pin_iu | label_iu | power_iu
# --- classify each wire ---
orphaned: List[Dict[str, Any]] = []
for i, (s_iu, e_iu) in enumerate(wires_iu):
w = wires_mm[i]
dangling_ends: List[Dict[str, float]] = []
for pt_iu, pt_mm in [(s_iu, w["start"]), (e_iu, w["end"])]:
if iu_to_count[pt_iu] > 1:
continue # shared with another wire → connected
if pt_iu in anchored_iu:
continue # touches a pin or label → connected
dangling_ends.append({"x": pt_mm[0], "y": pt_mm[1]})
if dangling_ends:
orphaned.append(
{
"start": {"x": w["start"][0], "y": w["start"][1]},
"end": {"x": w["end"][0], "y": w["end"][1]},
"dangling_ends": dangling_ends,
}
)
return {"orphaned_wires": orphaned, "count": len(orphaned)}

View File

@@ -0,0 +1,211 @@
"""
Snap-to-grid tool for KiCAD schematics.
Snaps wire endpoints, junction positions, net labels, and optionally component
positions to the nearest grid point. Modifies the schematic file in place.
The standard KiCAD schematic grid is 50 mil (1.27 mm). Component pins are
placed at multiples of 1.27 mm relative to the symbol origin, so absolute pin
coordinates end up as odd multiples of 1.27 mm (e.g. 26.67 mm = 21 × 1.27 mm).
These are valid on-grid positions that must not be moved.
The coarser 2.54 mm (100-mil) grid is a common mistake: exactly half of all
valid 1.27 mm positions are not multiples of 2.54 mm and would be displaced by
1.27 mm — moving labels or wire endpoints off their pins and breaking
connectivity.
Off-grid coordinates cause wires that appear visually connected to fail ERC
connectivity checks because KiCAD uses exact integer (IU) matching internally.
"""
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
import sexpdata
from sexpdata import Symbol
logger = logging.getLogger("kicad_interface")
_DEFAULT_GRID_MM: float = 1.27
# Element type names exposed in the public API
_VALID_ELEMENTS = frozenset({"wires", "junctions", "labels", "components"})
# Tags treated as net labels (all have (at x y angle) structure)
_LABEL_TAGS = frozenset(
{
Symbol("label"),
Symbol("global_label"),
Symbol("hierarchical_label"),
Symbol("net_tie"),
Symbol("no_connect"),
}
)
def _snap_mm(value: float, grid_mm: float) -> float:
"""Snap a single coordinate to the nearest grid multiple."""
return round(value / grid_mm) * grid_mm
def _is_on_grid(value: float, grid_mm: float, eps: float = 1e-9) -> bool:
"""Return True if *value* is already within *eps* of a grid point."""
snapped = _snap_mm(value, grid_mm)
return abs(value - snapped) < eps
def _snap_xy_pair(item: list, grid_mm: float) -> int:
"""
Snap a ``(xy x y)`` S-expression item in place.
Returns 1 if at least one coordinate changed, 0 otherwise.
"""
if not (isinstance(item, list) and len(item) >= 3 and item[0] == Symbol("xy")):
return 0
x_orig, y_orig = float(item[1]), float(item[2])
x_new = _snap_mm(x_orig, grid_mm)
y_new = _snap_mm(y_orig, grid_mm)
changed = not (_is_on_grid(x_orig, grid_mm) and _is_on_grid(y_orig, grid_mm))
item[1] = x_new
item[2] = y_new
return 1 if changed else 0
def _snap_at_xy(item: list, grid_mm: float) -> int:
"""
Snap an ``(at x y ...)`` S-expression item in place (indices 1 and 2 only).
Preserves rotation / angle at index 3+ unchanged.
Returns 1 if at least one coordinate changed, 0 otherwise.
"""
if not (isinstance(item, list) and len(item) >= 3 and item[0] == Symbol("at")):
return 0
x_orig, y_orig = float(item[1]), float(item[2])
x_new = _snap_mm(x_orig, grid_mm)
y_new = _snap_mm(y_orig, grid_mm)
changed = not (_is_on_grid(x_orig, grid_mm) and _is_on_grid(y_orig, grid_mm))
item[1] = x_new
item[2] = y_new
return 1 if changed else 0
def snap_to_grid(
schematic_path: Path,
grid_size: float = _DEFAULT_GRID_MM,
elements: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""
Snap element coordinates in a ``.kicad_sch`` file to the nearest grid point.
Modifies the file in place and returns statistics.
Args:
schematic_path: Path to the ``.kicad_sch`` file.
grid_size: Grid spacing in mm (default 1.27 mm = 50 mil).
Do NOT use 2.54 mm — half of all valid KiCAD pin
positions fall between 2.54 mm grid lines and would
be displaced 1.27 mm, breaking connectivity.
elements: List of element types to snap. Valid values:
``"wires"``, ``"junctions"``, ``"labels"``,
``"components"``. Defaults to
``["wires", "junctions", "labels"]`` when ``None``.
Returns:
``{"snapped": int, "already_on_grid": int, "grid_size": float}``
where *snapped* is the number of elements that had at least one
coordinate moved.
"""
if grid_size <= 0:
raise ValueError(f"grid_size must be positive, got {grid_size}")
if elements is None:
active: frozenset = frozenset({"wires", "junctions", "labels"})
else:
unknown = set(elements) - _VALID_ELEMENTS
if unknown:
raise ValueError(
f"Unknown element type(s): {sorted(unknown)}. "
f"Valid types: {sorted(_VALID_ELEMENTS)}"
)
active = frozenset(elements)
with open(schematic_path, "r", encoding="utf-8") as fh:
sch_data = sexpdata.loads(fh.read())
snapped = 0
already_on_grid = 0
snap_wires = "wires" in active
snap_junctions = "junctions" in active
snap_labels = "labels" in active
snap_components = "components" in active
for item in sch_data:
if not isinstance(item, list) or not item:
continue
tag = item[0]
# -----------------------------------------------------------------
# Wires: (wire (pts (xy x y) (xy x y)) ...)
# -----------------------------------------------------------------
if snap_wires and tag == Symbol("wire"):
changed = 0
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == Symbol("pts"):
for pt in sub[1:]:
changed += _snap_xy_pair(pt, grid_size)
if changed:
snapped += 1
else:
already_on_grid += 1
continue
# -----------------------------------------------------------------
# Junctions: (junction (at x y) ...)
# -----------------------------------------------------------------
if snap_junctions and tag == Symbol("junction"):
changed = 0
for sub in item[1:]:
changed += _snap_at_xy(sub, grid_size)
if changed:
snapped += 1
else:
already_on_grid += 1
continue
# -----------------------------------------------------------------
# Labels: (label|global_label|hierarchical_label|no_connect … (at x y angle) …)
# -----------------------------------------------------------------
if snap_labels and tag in _LABEL_TAGS:
changed = 0
for sub in item[1:]:
changed += _snap_at_xy(sub, grid_size)
if changed:
snapped += 1
else:
already_on_grid += 1
continue
# -----------------------------------------------------------------
# Components: (symbol (lib_id …) (at x y rotation) …)
# Snap only the top-level (at …) — not property sub-positions.
# -----------------------------------------------------------------
if snap_components and tag == Symbol("symbol"):
changed = 0
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == Symbol("at"):
changed += _snap_at_xy(sub, grid_size)
break # only the first (at …) belongs to the symbol itself
if changed:
snapped += 1
else:
already_on_grid += 1
continue
with open(schematic_path, "w", encoding="utf-8") as fh:
fh.write(sexpdata.dumps(sch_data))
return {
"snapped": snapped,
"already_on_grid": already_on_grid,
"grid_size": grid_size,
}

View File

@@ -15,13 +15,13 @@ Supported SVG elements:
SVG coordinate system: Y increases downward (same as KiCAD mm), so no Y-flip needed.
"""
import re
import math
import uuid
import os
import logging
from typing import List, Tuple, Dict, Any, Optional
import math
import os
import re
import uuid
import xml.etree.ElementTree as ET
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger("kicad_interface")
@@ -34,9 +34,7 @@ Polygon = List[Point]
# ---------------------------------------------------------------------------
# SVG path tokenizer
# ---------------------------------------------------------------------------
_TOKEN_RE = re.compile(
r"([MmZzLlHhVvCcSsQqTtAa])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)"
)
_TOKEN_RE = re.compile(r"([MmZzLlHhVvCcSsQqTtAa])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)")
def _tokenize_path(d: str) -> List[str]:
@@ -73,7 +71,9 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
i += n
return vals
def cubic_bezier_points(p0: Point, p1: Point, p2: Point, p3: Point, steps: int = 16) -> List[Point]:
def cubic_bezier_points(
p0: Point, p1: Point, p2: Point, p3: Point, steps: int = 16
) -> List[Point]:
pts = []
for k in range(1, steps + 1):
t = k / steps
@@ -93,8 +93,18 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
pts.append((x, y))
return pts
def arc_points(x1: float, y1: float, rx: float, ry: float, phi_deg: float,
large_arc: int, sweep: int, x2: float, y2: float, steps: int = 20) -> List[Point]:
def arc_points(
x1: float,
y1: float,
rx: float,
ry: float,
phi_deg: float,
large_arc: int,
sweep: int,
x2: float,
y2: float,
steps: int = 20,
) -> List[Point]:
"""Approximate SVG arc as polygon points (endpoint parameterization → centre)."""
if rx == 0 or ry == 0:
return [(x2, y2)]
@@ -119,8 +129,10 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
cx_ = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2
cy_ = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2
def angle(ux, uy, vx, vy):
a = math.acos(max(-1, min(1, (ux*vx + uy*vy) / (math.hypot(ux, uy) * math.hypot(vx, vy)))))
def angle(ux: float, uy: float, vx: float, vy: float) -> float:
a = math.acos(
max(-1, min(1, (ux * vx + uy * vy) / (math.hypot(ux, uy) * math.hypot(vx, vy))))
)
if ux * vy - uy * vx < 0:
a = -a
return a
@@ -144,8 +156,9 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
# --- main loop ---
while i < len(tokens):
tok = tokens[i]
if tok.lstrip('+-').replace('.', '', 1).replace('e', '', 1).replace('E', '', 1).lstrip('+-').isdigit() or \
re.match(r'^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$', tok):
if tok.lstrip("+-").replace(".", "", 1).replace("e", "", 1).replace("E", "", 1).lstrip(
"+-"
).isdigit() or re.match(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$", tok):
# implicit repeat of last command
pass
else:
@@ -155,7 +168,7 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
rel = cmd.islower()
if cmd in ('M', 'm'):
if cmd in ("M", "m"):
x, y = consume(2)
if rel:
cx, cy = cx + x, cy + y
@@ -166,9 +179,9 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
current = [(cx, cy)]
sx, sy = cx, cy
# subsequent coordinates are implicit L/l
cmd = 'l' if rel else 'L'
cmd = "l" if rel else "L"
elif cmd in ('L', 'l'):
elif cmd in ("L", "l"):
x, y = consume(2)
if rel:
cx, cy = cx + x, cy + y
@@ -176,36 +189,46 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
cx, cy = x, y
current.append((cx, cy))
elif cmd in ('H', 'h'):
x = float(tokens[i]); i += 1
elif cmd in ("H", "h"):
x = float(tokens[i])
i += 1
cx = cx + x if rel else x
current.append((cx, cy))
elif cmd in ('V', 'v'):
y = float(tokens[i]); i += 1
elif cmd in ("V", "v"):
y = float(tokens[i])
i += 1
cy = cy + y if rel else y
current.append((cx, cy))
elif cmd in ('Z', 'z'):
elif cmd in ("Z", "z"):
current.append((sx, sy)) # close
polygons.append(current)
current = []
cx, cy = sx, sy
elif cmd in ('C', 'c'):
elif cmd in ("C", "c"):
x1, y1, x2, y2, x, y = consume(6)
if rel:
x1 += cx; y1 += cy; x2 += cx; y2 += cy; x += cx; y += cy
x1 += cx
y1 += cy
x2 += cx
y2 += cy
x += cx
y += cy
pts = cubic_bezier_points((cx, cy), (x1, y1), (x2, y2), (x, y))
current.extend(pts)
last_ctrl = (x2, y2)
cx, cy = x, y
elif cmd in ('S', 's'):
elif cmd in ("S", "s"):
x2, y2, x, y = consume(4)
if rel:
x2 += cx; y2 += cy; x += cx; y += cy
if last_ctrl and last_cmd in ('C', 'c', 'S', 's'):
x2 += cx
y2 += cy
x += cx
y += cy
if last_ctrl and last_cmd in ("C", "c", "S", "s"):
x1 = 2 * cx - last_ctrl[0]
y1 = 2 * cy - last_ctrl[1]
else:
@@ -215,20 +238,24 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
last_ctrl = (x2, y2)
cx, cy = x, y
elif cmd in ('Q', 'q'):
elif cmd in ("Q", "q"):
x1, y1, x, y = consume(4)
if rel:
x1 += cx; y1 += cy; x += cx; y += cy
x1 += cx
y1 += cy
x += cx
y += cy
pts = quad_bezier_points((cx, cy), (x1, y1), (x, y))
current.extend(pts)
last_ctrl = (x1, y1)
cx, cy = x, y
elif cmd in ('T', 't'):
elif cmd in ("T", "t"):
x, y = consume(2)
if rel:
x += cx; y += cy
if last_ctrl and last_cmd in ('Q', 'q', 'T', 't'):
x += cx
y += cy
if last_ctrl and last_cmd in ("Q", "q", "T", "t"):
x1 = 2 * cx - last_ctrl[0]
y1 = 2 * cy - last_ctrl[1]
else:
@@ -238,11 +265,12 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
last_ctrl = (x1, y1)
cx, cy = x, y
elif cmd in ('A', 'a'):
elif cmd in ("A", "a"):
rx, ry, phi, large, sweep, x, y = consume(7)
large, sweep = int(large), int(sweep)
if rel:
x += cx; y += cy
x += cx
y += cy
pts = arc_points(cx, cy, rx, ry, phi, large, sweep, x, y)
current.extend(pts)
cx, cy = x, y
@@ -264,35 +292,32 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
# ---------------------------------------------------------------------------
def _parse_transform(transform_str: str) -> List[List[float]]:
"""Parse SVG transform attribute, return list of 3×3 matrix rows [a,b,c; d,e,f; 0,0,1]."""
def identity():
def identity() -> List[List[float]]:
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
def mat_mul(A, B):
return [
[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)]
for r in range(3)
]
def mat_mul(A: List[List[float]], B: List[List[float]]) -> List[List[float]]:
return [[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)] for r in range(3)]
result = identity()
for m in re.finditer(
r'(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)',
transform_str
r"(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)", transform_str
):
func = m.group(1)
args = [float(v) for v in re.split(r'[\s,]+', m.group(2).strip()) if v]
args = [float(v) for v in re.split(r"[\s,]+", m.group(2).strip()) if v]
mat = identity()
if func == 'matrix' and len(args) == 6:
if func == "matrix" and len(args) == 6:
a, b, c, d, e, f = args
mat = [[a, c, e], [b, d, f], [0, 0, 1]]
elif func == 'translate':
elif func == "translate":
tx = args[0]
ty = args[1] if len(args) > 1 else 0
mat = [[1, 0, tx], [0, 1, ty], [0, 0, 1]]
elif func == 'scale':
elif func == "scale":
sx = args[0]
sy = args[1] if len(args) > 1 else sx
mat = [[sx, 0, 0], [0, sy, 0], [0, 0, 1]]
elif func == 'rotate':
elif func == "rotate":
angle = math.radians(args[0])
cos, sin = math.cos(angle), math.sin(angle)
if len(args) == 3:
@@ -303,9 +328,9 @@ def _parse_transform(transform_str: str) -> List[List[float]]:
mat = mat_mul(mat_mul(t1, r), t2)
else:
mat = [[cos, -sin, 0], [sin, cos, 0], [0, 0, 1]]
elif func == 'skewX':
elif func == "skewX":
mat = [[1, math.tan(math.radians(args[0])), 0], [0, 1, 0], [0, 0, 1]]
elif func == 'skewY':
elif func == "skewY":
mat = [[1, 0, 0], [math.tan(math.radians(args[0])), 1, 0], [0, 0, 1]]
result = mat_mul(result, mat)
return result
@@ -320,44 +345,41 @@ def _apply_transform(pts: List[Point], mat: List[List[float]]) -> List[Point]:
return out
def _mat_mul(A, B):
return [
[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)]
for r in range(3)
]
def _mat_mul(A: List[List[float]], B: List[List[float]]) -> List[List[float]]:
return [[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)] for r in range(3)]
# ---------------------------------------------------------------------------
# SVG element → polygon extractor
# ---------------------------------------------------------------------------
SVG_NS = re.compile(r'\{[^}]+\}')
SVG_NS = re.compile(r"\{[^}]+\}")
def _tag(el: ET.Element) -> str:
return SVG_NS.sub('', el.tag)
return SVG_NS.sub("", el.tag)
def _get_attr(el: ET.Element, name: str, default: Optional[str] = None) -> Optional[str]:
for key in el.attrib:
if SVG_NS.sub('', key) == name:
if SVG_NS.sub("", key) == name:
return el.attrib[key]
return default
def _identity():
def _identity() -> List[List[float]]:
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
def _extract_polygons_from_element(el: ET.Element, parent_mat: List[List[float]]) -> List[Polygon]:
"""Recursively extract all polygons from an SVG element tree."""
tag = _tag(el)
display = _get_attr(el, 'display', 'inline')
visibility = _get_attr(el, 'visibility', 'visible')
if display == 'none' or visibility == 'hidden':
display = _get_attr(el, "display", "inline")
visibility = _get_attr(el, "visibility", "visible")
if display == "none" or visibility == "hidden":
return []
# Accumulate transform
transform_str = _get_attr(el, 'transform', '')
transform_str = _get_attr(el, "transform", "")
if transform_str:
local_mat = _parse_transform(transform_str)
mat = _mat_mul(parent_mat, local_mat)
@@ -366,65 +388,73 @@ def _extract_polygons_from_element(el: ET.Element, parent_mat: List[List[float]]
result: List[Polygon] = []
if tag == 'g' or tag == 'svg':
if tag == "g" or tag == "svg":
for child in el:
result.extend(_extract_polygons_from_element(child, mat))
elif tag == 'path':
d = _get_attr(el, 'd', '')
elif tag == "path":
d = _get_attr(el, "d", "")
if d:
tokens = _tokenize_path(d)
polygons = _parse_path_tokens(tokens)
for poly in polygons:
result.append(_apply_transform(poly, mat))
elif tag == 'rect':
x = float(_get_attr(el, 'x', '0') or 0)
y = float(_get_attr(el, 'y', '0') or 0)
w = float(_get_attr(el, 'width', '0') or 0)
h = float(_get_attr(el, 'height', '0') or 0)
elif tag == "rect":
x = float(_get_attr(el, "x", "0") or 0)
y = float(_get_attr(el, "y", "0") or 0)
w = float(_get_attr(el, "width", "0") or 0)
h = float(_get_attr(el, "height", "0") or 0)
if w > 0 and h > 0:
pts = [(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)]
result.append(_apply_transform(pts, mat))
elif tag == 'circle':
cx_ = float(_get_attr(el, 'cx', '0') or 0)
cy_ = float(_get_attr(el, 'cy', '0') or 0)
r = float(_get_attr(el, 'r', '0') or 0)
elif tag == "circle":
cx_ = float(_get_attr(el, "cx", "0") or 0)
cy_ = float(_get_attr(el, "cy", "0") or 0)
r = float(_get_attr(el, "r", "0") or 0)
if r > 0:
steps = 36
pts = [(cx_ + r * math.cos(2 * math.pi * k / steps),
cy_ + r * math.sin(2 * math.pi * k / steps))
for k in range(steps + 1)]
pts = [
(
cx_ + r * math.cos(2 * math.pi * k / steps),
cy_ + r * math.sin(2 * math.pi * k / steps),
)
for k in range(steps + 1)
]
result.append(_apply_transform(pts, mat))
elif tag == 'ellipse':
cx_ = float(_get_attr(el, 'cx', '0') or 0)
cy_ = float(_get_attr(el, 'cy', '0') or 0)
rx = float(_get_attr(el, 'rx', '0') or 0)
ry = float(_get_attr(el, 'ry', '0') or 0)
elif tag == "ellipse":
cx_ = float(_get_attr(el, "cx", "0") or 0)
cy_ = float(_get_attr(el, "cy", "0") or 0)
rx = float(_get_attr(el, "rx", "0") or 0)
ry = float(_get_attr(el, "ry", "0") or 0)
if rx > 0 and ry > 0:
steps = 36
pts = [(cx_ + rx * math.cos(2 * math.pi * k / steps),
cy_ + ry * math.sin(2 * math.pi * k / steps))
for k in range(steps + 1)]
pts = [
(
cx_ + rx * math.cos(2 * math.pi * k / steps),
cy_ + ry * math.sin(2 * math.pi * k / steps),
)
for k in range(steps + 1)
]
result.append(_apply_transform(pts, mat))
elif tag in ('polygon', 'polyline'):
points_str = _get_attr(el, 'points', '')
elif tag in ("polygon", "polyline"):
points_str = _get_attr(el, "points", "")
if points_str:
nums = [float(v) for v in re.split(r'[\s,]+', points_str.strip()) if v]
nums = [float(v) for v in re.split(r"[\s,]+", points_str.strip()) if v]
pts = [(nums[k], nums[k + 1]) for k in range(0, len(nums) - 1, 2)]
if tag == 'polygon' and pts:
if tag == "polygon" and pts:
pts.append(pts[0]) # close
if pts:
result.append(_apply_transform(pts, mat))
elif tag == 'line':
x1 = float(_get_attr(el, 'x1', '0') or 0)
y1 = float(_get_attr(el, 'y1', '0') or 0)
x2 = float(_get_attr(el, 'x2', '0') or 0)
y2 = float(_get_attr(el, 'y2', '0') or 0)
elif tag == "line":
x1 = float(_get_attr(el, "x1", "0") or 0)
y1 = float(_get_attr(el, "y1", "0") or 0)
x2 = float(_get_attr(el, "x2", "0") or 0)
y2 = float(_get_attr(el, "y2", "0") or 0)
pts = [(x1, y1), (x2, y2)]
result.append(_apply_transform(pts, mat))
@@ -453,10 +483,13 @@ def _build_gr_poly(points: List[Point], layer: str, stroke_width: float, filled:
row = []
fill_str = "yes" if filled else "none"
uid = str(uuid.uuid4())
lines = [
lines = (
[
"\t(gr_poly",
"\t\t(pts",
] + pts_lines + [
]
+ pts_lines
+ [
"\t\t)",
"\t\t(stroke",
f"\t\t\t(width {stroke_width:.4f})",
@@ -467,6 +500,7 @@ def _build_gr_poly(points: List[Point], layer: str, stroke_width: float, filled:
f'\t\t(uuid "{uid}")',
"\t)",
]
)
return "\n".join(lines)
@@ -510,15 +544,15 @@ def import_svg_to_pcb(
root = tree.getroot()
# Determine SVG viewport
vb = _get_attr(root, 'viewBox')
vb = _get_attr(root, "viewBox")
if vb:
parts = [float(v) for v in re.split(r'[\s,]+', vb.strip()) if v]
parts = [float(v) for v in re.split(r"[\s,]+", vb.strip()) if v]
svg_x0, svg_y0, svg_w, svg_h = parts[0], parts[1], parts[2], parts[3]
else:
w_str = _get_attr(root, 'width', '100') or '100'
h_str = _get_attr(root, 'height', '100') or '100'
svg_w = float(re.sub(r'[^\d.]', '', w_str) or 100)
svg_h = float(re.sub(r'[^\d.]', '', h_str) or 100)
w_str = _get_attr(root, "width", "100") or "100"
h_str = _get_attr(root, "height", "100") or "100"
svg_w = float(re.sub(r"[^\d.]", "", w_str) or 100)
svg_h = float(re.sub(r"[^\d.]", "", h_str) or 100)
svg_x0, svg_y0 = 0.0, 0.0
if svg_w == 0 or svg_h == 0:
@@ -569,7 +603,10 @@ def import_svg_to_pcb(
insert_block = "\n" + "\n".join(gr_lines) + "\n"
last_paren = pcb_content.rfind(")")
if last_paren == -1:
return {"success": False, "message": "PCB file format error: no closing parenthesis found"}
return {
"success": False,
"message": "PCB file format error: no closing parenthesis found",
}
new_content = pcb_content[:last_paren] + insert_block + pcb_content[last_paren:]
@@ -597,5 +634,6 @@ def import_svg_to_pcb(
except Exception as e:
logger.error(f"SVG import failed: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}

View File

@@ -12,9 +12,9 @@ KiCAD 9 .kicad_sym format:
- All coordinates in mm, 2.54mm grid typical for schematic symbols
"""
import logging
import os
import re
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -24,15 +24,31 @@ KICAD9_SYMBOL_LIB_VERSION = "20241209"
# Pin electrical types
PIN_TYPES = {
"input", "output", "bidirectional", "tri_state", "passive",
"free", "unspecified", "power_in", "power_out",
"open_collector", "open_emitter", "no_connect",
"input",
"output",
"bidirectional",
"tri_state",
"passive",
"free",
"unspecified",
"power_in",
"power_out",
"open_collector",
"open_emitter",
"no_connect",
}
# Pin graphic shapes
PIN_SHAPES = {
"line", "inverted", "clock", "inverted_clock", "input_low",
"clock_low", "output_low", "falling_edge_clock", "non_logic",
"line",
"inverted",
"clock",
"inverted_clock",
"input_low",
"clock_low",
"output_low",
"falling_edge_clock",
"non_logic",
}
@@ -125,11 +141,11 @@ class SymbolCreator:
lib_content = lib_path.read_text(encoding="utf-8")
else:
lib_content = (
f'(kicad_symbol_lib\n'
f' (version {KICAD9_SYMBOL_LIB_VERSION})\n'
f"(kicad_symbol_lib\n"
f" (version {KICAD9_SYMBOL_LIB_VERSION})\n"
f' (generator "kicad-mcp")\n'
f' (generator_version "9.0")\n'
f')\n'
f")\n"
)
# Check for duplicate
@@ -209,7 +225,7 @@ class SymbolCreator:
# Only top-level symbols (not sub-symbols like _0_1 or _1_1)
names = re.findall(r'^\s*\(symbol "([^"_][^"]*)"', content, re.MULTILINE)
# Filter out sub-symbols (contain _N_N suffix)
symbols = [n for n in names if not re.search(r'_\d+_\d+$', n)]
symbols = [n for n in names if not re.search(r"_\d+_\d+$", n)]
return {
"success": True,
"library_path": str(lib_path),
@@ -332,9 +348,9 @@ class SymbolCreator:
board_str = "yes" if on_board else "no"
lines.append(f' (symbol "{name}"')
lines.append(f' (exclude_from_sim no)')
lines.append(f' (in_bom {bom_str})')
lines.append(f' (on_board {board_str})')
lines.append(f" (exclude_from_sim no)")
lines.append(f" (in_bom {bom_str})")
lines.append(f" (on_board {board_str})")
# Properties
lines.extend(_property_block("Reference", reference_prefix, 2.54, 0, visible=True))
@@ -351,15 +367,15 @@ class SymbolCreator:
lines.extend(_rect_sym_lines(rect))
for pl in polylines:
lines.extend(_polyline_lines(pl))
lines.append(f' )')
lines.append(f" )")
# Sub-symbol _1_1: pins
lines.append(f' (symbol "{name}_1_1"')
for pin in pins:
lines.extend(_pin_lines(pin))
lines.append(f' )')
lines.append(f" )")
lines.append(f' )')
lines.append(f" )")
return "\n".join(lines)
def _remove_symbol(self, content: str, name: str) -> str:
@@ -372,8 +388,9 @@ class SymbolCreator:
for line in lines:
stripped = line.strip()
if not skip:
if re.match(rf'^\s*\(symbol "{re.escape(name)}"', line) and \
not re.search(r'_\d+_\d+"', line):
if re.match(rf'^\s*\(symbol "{re.escape(name)}"', line) and not re.search(
r'_\d+_\d+"', line
):
skip = True
depth = stripped.count("(") - stripped.count(")")
continue
@@ -390,17 +407,16 @@ class SymbolCreator:
# S-Expression helper functions #
# ------------------------------------------------------------------ #
def _property_block(
key: str, value: str, x: float, y: float, visible: bool = True
) -> List[str]:
def _property_block(key: str, value: str, x: float, y: float, visible: bool = True) -> List[str]:
hide = "" if visible else "\n (hide yes)"
return [
f' (property "{_esc(key)}" "{_esc(value)}"',
f' (at {_fmt(x)} {_fmt(y)} 0)',
f' (effects',
f' (font (size 1.27 1.27))',
f' ){hide}',
f' )',
f" (at {_fmt(x)} {_fmt(y)} 0)",
f" (effects",
f" (font (size 1.27 1.27))",
f" ){hide}",
f" )",
]
@@ -412,12 +428,12 @@ def _rect_sym_lines(rect: Dict[str, Any]) -> List[str]:
w = _fmt(rect.get("width", 0.254))
fill = rect.get("fill", "background")
return [
f' (rectangle',
f' (start {x1} {y1})',
f' (end {x2} {y2})',
f' (stroke (width {w}) (type default))',
f' (fill (type {fill}))',
f' )',
f" (rectangle",
f" (start {x1} {y1})",
f" (end {x2} {y2})",
f" (stroke (width {w}) (type default))",
f" (fill (type {fill}))",
f" )",
]
@@ -426,16 +442,16 @@ def _polyline_lines(pl: Dict[str, Any]) -> List[str]:
w = _fmt(pl.get("width", 0.254))
fill = pl.get("fill", "none")
lines = [
f' (polyline',
f' (pts',
f" (polyline",
f" (pts",
]
for pt in pts:
lines.append(f' (xy {_fmt(pt["x"])} {_fmt(pt["y"])})')
lines += [
f' )',
f' (stroke (width {w}) (type default))',
f' (fill (type {fill}))',
f' )',
f" )",
f" (stroke (width {w}) (type default))",
f" (fill (type {fill}))",
f" )",
]
return lines
@@ -452,14 +468,14 @@ def _pin_lines(pin: Dict[str, Any]) -> List[str]:
pin_number = str(pin.get("number", "1"))
return [
f' (pin {ptype} {shape}',
f' (at {x} {y} {angle})',
f' (length {length})',
f" (pin {ptype} {shape}",
f" (at {x} {y} {angle})",
f" (length {length})",
f' (name "{_esc(pin_name)}"',
f' (effects (font (size 1.27 1.27)))',
f' )',
f" (effects (font (size 1.27 1.27)))",
f" )",
f' (number "{_esc(pin_number)}"',
f' (effects (font (size 1.27 1.27)))',
f' )',
f' )',
f" (effects (font (size 1.27 1.27)))",
f" )",
f" )",
]

View File

@@ -0,0 +1,494 @@
"""
Wire Connectivity Analysis for KiCad Schematics
Traces wire networks from a point and finds connected component pins.
Uses KiCad's internal integer unit system (10,000 IU per mm) for exact
coordinate matching, mirroring KiCad's own connectivity algorithm.
"""
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from commands.pin_locator import PinLocator
logger = logging.getLogger("kicad_interface")
_IU_PER_MM = 10000 # KiCad schematic internal units per millimeter
def _to_iu(x_mm: float, y_mm: float) -> Tuple[int, int]:
"""Convert mm coordinates to KiCad internal units (integer)."""
return (round(x_mm * _IU_PER_MM), round(y_mm * _IU_PER_MM))
def _parse_wires(schematic: Any) -> List[List[Tuple[int, int]]]:
"""Extract wire endpoints from a schematic object as IU tuples."""
all_wires = []
for wire in schematic.wire:
if hasattr(wire, "pts") and hasattr(wire.pts, "xy"):
pts = []
for point in wire.pts.xy:
if hasattr(point, "value"):
pts.append(_to_iu(float(point.value[0]), float(point.value[1])))
if len(pts) >= 2:
all_wires.append(pts)
return all_wires
def _build_adjacency(
all_wires: List[List[Tuple[int, int]]],
) -> Tuple[List[Set[int]], Dict[Tuple[int, int], Set[int]]]:
"""Build wire adjacency using exact IU coordinate matching.
Wires that share an endpoint are adjacent — this naturally handles
junctions since all wires meeting at the same point get connected.
Returns a tuple of:
- adjacency: list of sets, one per wire, containing adjacent wire indices
- iu_to_wires: dict mapping each IU endpoint to the set of wire indices
that have an endpoint at that exact coordinate (used for seed queries)
"""
# Map each IU endpoint to all wire indices that touch it
iu_to_wires: Dict[Tuple[int, int], Set[int]] = {}
for i, pts in enumerate(all_wires):
for pt in pts:
iu_to_wires.setdefault(pt, set()).add(i)
# Wires that share an IU endpoint are adjacent
adjacency: List[Set[int]] = [set() for _ in range(len(all_wires))]
for wire_set in iu_to_wires.values():
wire_list = list(wire_set)
for a in wire_list:
for b in wire_list:
if a != b:
adjacency[a].add(b)
return adjacency, iu_to_wires
def _parse_virtual_connections(
schematic: Any, schematic_path: Any
) -> Tuple[Dict[Tuple[int, int], str], Dict[str, List[Tuple[int, int]]]]:
"""Return virtual connectivity from net labels and power symbols.
Returns a tuple of:
- point_to_label: Dict[Tuple[int,int], str] — IU position → label name
- label_to_points: Dict[str, List[Tuple[int,int]]] — label name → list of IU positions
"""
point_to_label: Dict[Tuple[int, int], str] = {}
label_to_points: Dict[str, List[Tuple[int, int]]] = {}
if hasattr(schematic, "label"):
for label in schematic.label:
try:
if not hasattr(label, "value"):
continue
name = label.value
if not hasattr(label, "at") or not hasattr(label.at, "value"):
continue
coords = label.at.value
pt = _to_iu(float(coords[0]), float(coords[1]))
point_to_label[pt] = name
label_to_points.setdefault(name, []).append(pt)
except Exception as e:
logger.warning(f"Error parsing net label: {e}")
if hasattr(schematic, "symbol"):
locator = PinLocator()
for symbol in schematic.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if not ref.startswith("#PWR"):
continue
if ref.startswith("_TEMPLATE"):
continue
if not hasattr(symbol.property, "Value"):
continue
name = symbol.property.Value.value
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
if not all_pins or "1" not in all_pins:
continue
pin_data = all_pins["1"]
pt = _to_iu(float(pin_data[0]), float(pin_data[1]))
point_to_label[pt] = name
label_to_points.setdefault(name, []).append(pt)
except Exception as e:
logger.warning(f"Error parsing power symbol: {e}")
return point_to_label, label_to_points
def _find_connected_wires(
x_mm: float,
y_mm: float,
all_wires: List[List[Tuple[int, int]]],
iu_to_wires: Dict[Tuple[int, int], Set[int]],
adjacency: List[Set[int]],
point_to_label: Optional[Dict[Tuple[int, int], str]] = None,
label_to_points: Optional[Dict[str, List[Tuple[int, int]]]] = None,
) -> Tuple:
"""BFS from query point. Returns (visited wire indices, net IU points) or (None, None).
Requires query point (x_mm, y_mm) to be exactly on a wire endpoint (exact IU match).
"""
query_iu = _to_iu(x_mm, y_mm)
# Find seed wires: exact IU match on the query endpoint
seed_set = iu_to_wires.get(query_iu)
if not seed_set:
return (None, None)
seed_indices: Set[int] = set(seed_set)
# BFS flood-fill using pre-compiled adjacency
visited: Set[int] = set(seed_indices)
queue = list(seed_indices)
net_points: Set[Tuple[int, int]] = set()
for i in seed_indices:
net_points.update(all_wires[i])
seen_labels: Set[str] = set()
while queue:
wire_idx = queue.pop()
for neighbor_idx in adjacency[wire_idx]:
if neighbor_idx not in visited:
visited.add(neighbor_idx)
queue.append(neighbor_idx)
net_points.update(all_wires[neighbor_idx])
if point_to_label and label_to_points:
for pt in all_wires[wire_idx]:
label_name = point_to_label.get(pt)
if label_name and label_name not in seen_labels:
seen_labels.add(label_name)
for other_pt in label_to_points.get(label_name, []):
if other_pt == pt:
continue
for idx in iu_to_wires.get(other_pt, set()):
if idx not in visited:
visited.add(idx)
queue.append(idx)
net_points.update(all_wires[idx])
return (visited, net_points)
def _find_pins_on_net(
net_points: Set[Tuple[int, int]],
schematic_path: Any,
schematic: Any,
) -> List[Dict]:
"""Find component pins that land on net points using exact IU matching.
Returns a list of {"component": ref, "pin": pin_num} dicts.
"""
def _on_net(px_mm: float, py_mm: float) -> bool:
return _to_iu(px_mm, py_mm) in net_points
locator = PinLocator()
pins = []
seen: Set[Tuple] = set()
ref = None
for symbol in schematic.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"):
continue
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
if not all_pins:
continue
for pin_num, pin_data in all_pins.items():
if _on_net(pin_data[0], pin_data[1]):
key = (ref, pin_num)
if key not in seen:
seen.add(key)
pins.append({"component": ref, "pin": pin_num})
except Exception as e:
logger.warning(
f"Error checking pins for {ref if ref is not None else '<unknown>'}: {e}"
)
return pins
def get_wire_connections(
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
) -> Optional[Dict]:
"""Find the net name and all component pins reachable from a point via connected wires.
The query point (x_mm, y_mm) must be exactly on a wire endpoint or junction (exact IU match).
Interior (mid-segment) points are not matched —
use wire endpoint coordinates obtained from the schematic data.
Net labels and power symbols are traversed: wires on the same named net are
treated as connected even when they are not geometrically adjacent.
Returns dict with keys:
- "net": str or None (net label/power name, None if unnamed)
- "pins": list of {"component": str, "pin": str}
- "wires": list of {"start": {"x", "y"}, "end": {"x", "y"}} in mm
- "query_point": {"x": float, "y": float}
Or None if no wire endpoint found within tolerance of the query point.
"""
all_wires = _parse_wires(schematic)
query_point = {"x": x_mm, "y": y_mm}
if not all_wires:
return {"net": None, "pins": [], "wires": [], "query_point": query_point}
adjacency, iu_to_wires = _build_adjacency(all_wires)
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
visited, net_points = _find_connected_wires(
x_mm,
y_mm,
all_wires,
iu_to_wires,
adjacency,
point_to_label=point_to_label,
label_to_points=label_to_points,
)
if visited is None:
return None
# Resolve net name: first label anchor that falls on this net's IU points
net: Optional[str] = None
for pt in net_points:
label = point_to_label.get(pt)
if label is not None:
net = label
break
wires_out = [
{
"start": {
"x": all_wires[i][0][0] / _IU_PER_MM,
"y": all_wires[i][0][1] / _IU_PER_MM,
},
"end": {
"x": all_wires[i][-1][0] / _IU_PER_MM,
"y": all_wires[i][-1][1] / _IU_PER_MM,
},
}
for i in visited
]
if not hasattr(schematic, "symbol"):
return {"net": net, "pins": [], "wires": wires_out, "query_point": query_point}
pins = _find_pins_on_net(net_points, schematic_path, schematic)
return {"net": net, "pins": pins, "wires": wires_out, "query_point": query_point}
def count_pins_on_net(
schematic: Any,
schematic_path: str,
net_name: str,
all_wires: List[List[Tuple[int, int]]],
iu_to_wires: Dict[Tuple[int, int], Set[int]],
adjacency: List[Set[int]],
point_to_label: Dict[Tuple[int, int], str],
label_to_points: Dict[str, List[Tuple[int, int]]],
) -> int:
"""Count the number of component pins connected to the named net.
A pin is counted if its IU coordinate falls on the wire-network reachable
from any label anchor for *net_name*, or directly on a label anchor of that
net (pin directly touching a label with no intervening wire).
Returns the count of distinct (component, pin_num) pairs on this net.
"""
label_positions = label_to_points.get(net_name, [])
if not label_positions:
return 0
# Collect the union of all net-points across all label positions for this net
all_net_points: Set[Tuple[int, int]] = set()
for lx, ly in label_positions:
# Include the label anchor itself so pins directly at the label count
all_net_points.add((lx, ly))
# Trace from this label position into the wire graph
x_mm = lx / _IU_PER_MM
y_mm = ly / _IU_PER_MM
visited, net_points = _find_connected_wires(
x_mm,
y_mm,
all_wires,
iu_to_wires,
adjacency,
point_to_label=point_to_label,
label_to_points=label_to_points,
)
if net_points:
all_net_points |= net_points
if not hasattr(schematic, "symbol"):
return 0
locator = PinLocator()
seen: Set[Tuple[str, str]] = set()
ref = None
for symbol in schematic.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"):
continue
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
if not all_pins:
continue
for pin_num, pin_data in all_pins.items():
pin_iu = _to_iu(float(pin_data[0]), float(pin_data[1]))
if pin_iu in all_net_points:
key = (ref, pin_num)
if key not in seen:
seen.add(key)
except Exception as e:
logger.warning(
f"Error checking pins for {ref if ref is not None else '<unknown>'}: {e}"
)
return len(seen)
def list_floating_labels(schematic: Any, schematic_path: str) -> List[Dict[str, Any]]:
"""Return net labels that are not connected to any component pin.
A label is "floating" when no component pin's IU coordinate falls on the
wire-network reachable from the label's anchor position. These labels are
likely placed off-grid or incorrectly positioned and will cause ERC errors.
Returns a list of dicts with keys:
- "name": str — the net label text
- "x": float — label X position in mm
- "y": float — label Y position in mm
- "type": str — "label" or "global_label"
"""
all_wires = _parse_wires(schematic)
if all_wires:
adjacency, iu_to_wires = _build_adjacency(all_wires)
else:
adjacency = []
iu_to_wires = {}
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
# Build a set of all pin IU positions for fast lookup
pin_iu_set: Set[Tuple[int, int]] = set()
if hasattr(schematic, "symbol"):
locator = PinLocator()
for symbol in schematic.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"):
continue
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
if not all_pins:
continue
for pin_data in all_pins.values():
pin_iu_set.add(_to_iu(float(pin_data[0]), float(pin_data[1])))
except Exception as e:
logger.warning(f"Error reading pins for floating-label check: {e}")
floating: List[Dict[str, Any]] = []
if not hasattr(schematic, "label"):
return floating
for label in schematic.label:
try:
if not hasattr(label, "value"):
continue
name = label.value
if not hasattr(label, "at") or not hasattr(label.at, "value"):
continue
coords = label.at.value
lx_mm = float(coords[0])
ly_mm = float(coords[1])
label_iu = _to_iu(lx_mm, ly_mm)
# Check if the label anchor itself is a pin position
if label_iu in pin_iu_set:
continue
# Trace the wire-network from this label and check for pins
if all_wires:
_, net_points = _find_connected_wires(
lx_mm,
ly_mm,
all_wires,
iu_to_wires,
adjacency,
point_to_label=point_to_label,
label_to_points=label_to_points,
)
else:
net_points = None
if net_points is not None and net_points & pin_iu_set:
continue # at least one pin on this net
floating.append({"name": name, "x": lx_mm, "y": ly_mm, "type": "label"})
except Exception as e:
logger.warning(f"Error checking label for floating status: {e}")
return floating
def get_net_at_point(
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
) -> Dict[str, Any]:
"""Return the net name at the given coordinate, or null if none found.
Checks net label positions first (exact IU match within tolerance), then
wire endpoints. Returns a dict with keys:
- "net_name": str or None
- "position": {"x": float, "y": float}
- "source": "net_label" | "wire_endpoint" | None
"""
query_iu = _to_iu(x_mm, y_mm)
position = {"x": x_mm, "y": y_mm}
# Build label map from schematic
point_to_label, _ = _parse_virtual_connections(schematic, schematic_path)
# Check if query point is exactly on a net label / power symbol position
label_name = point_to_label.get(query_iu)
if label_name is not None:
return {"net_name": label_name, "position": position, "source": "net_label"}
# Check if query point is on a wire endpoint
all_wires = _parse_wires(schematic) if hasattr(schematic, "wire") else []
if all_wires:
adjacency, iu_to_wires = _build_adjacency(all_wires)
if query_iu in iu_to_wires:
# Found a wire endpoint — trace the net to get the name
visited, net_points = _find_connected_wires(
x_mm,
y_mm,
all_wires,
iu_to_wires,
adjacency,
point_to_label=point_to_label,
label_to_points=None,
)
if visited is not None:
net: Optional[str] = None
if net_points:
for pt in net_points:
net = point_to_label.get(pt)
if net is not None:
break
return {"net_name": net, "position": position, "source": "wire_endpoint"}
return {"net_name": None, "position": position, "source": None}

View File

@@ -0,0 +1,439 @@
"""
WireDragger — drag connected wires when a schematic component is moved.
All methods operate on in-memory sexpdata lists (no disk I/O).
"""
import logging
import math
import uuid
from typing import Any, Dict, List, Optional, Tuple
import sexpdata
from sexpdata import Symbol
logger = logging.getLogger("kicad_interface")
# Module-level Symbol constants
_K = {
name: Symbol(name)
for name in [
"symbol",
"at",
"lib_id",
"mirror",
"lib_symbols",
"pts",
"xy",
"wire",
"junction",
"property",
"stroke",
"width",
"type",
"uuid",
]
}
EPS = 1e-4 # mm — coordinate match tolerance
def _rotate(x: float, y: float, angle_deg: float) -> Tuple[float, float]:
"""Rotate (x, y) around the origin by angle_deg degrees (CCW)."""
if angle_deg == 0:
return x, y
rad = math.radians(angle_deg)
c, s = math.cos(rad), math.sin(rad)
return x * c - y * s, x * s + y * c
def _coords_match(ax: float, ay: float, bx: float, by: float, eps: float = EPS) -> bool:
return abs(ax - bx) < eps and abs(ay - by) < eps
class WireDragger:
"""Pure-logic helpers for wire-endpoint dragging during component moves."""
@staticmethod
def find_symbol(sch_data: list, reference: str) -> Any:
"""
Find a placed symbol by reference designator.
Returns (symbol_item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y)
or None if the reference is not found.
mirror_x=True means the symbol has (mirror x) — flips the X local axis.
mirror_y=True means the symbol has (mirror y) — flips the Y local axis.
"""
sym_k = _K["symbol"]
prop_k = _K["property"]
at_k = _K["at"]
lib_id_k = _K["lib_id"]
mirror_k = _K["mirror"]
for item in sch_data:
if not (isinstance(item, list) and item and item[0] == sym_k):
continue
# Check Reference property
ref_val = None
for sub in item[1:]:
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k:
if str(sub[1]).strip('"') == "Reference":
ref_val = str(sub[2]).strip('"')
break
if ref_val != reference:
continue
old_x = old_y = rotation = 0.0
lib_id = ""
mirror_x = mirror_y = False
for sub in item[1:]:
if not isinstance(sub, list) or not sub:
continue
tag = sub[0]
if tag == at_k:
if len(sub) >= 3:
old_x = float(sub[1])
old_y = float(sub[2])
if len(sub) >= 4:
rotation = float(sub[3])
elif tag == lib_id_k and len(sub) >= 2:
lib_id = str(sub[1]).strip('"')
elif tag == mirror_k and len(sub) >= 2:
mv = str(sub[1])
if mv == "x":
mirror_x = True
elif mv == "y":
mirror_y = True
return item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y
return None
@staticmethod
def get_pin_defs(sch_data: list, lib_id: str) -> Dict:
"""
Get pin definitions from lib_symbols for the given lib_id.
Returns the same dict format as PinLocator.parse_symbol_definition:
{pin_num: {"x": ..., "y": ..., ...}}.
"""
from commands.pin_locator import PinLocator
lib_sym_k = _K["lib_symbols"]
symbol_k = _K["symbol"]
for item in sch_data:
if not (isinstance(item, list) and item and item[0] == lib_sym_k):
continue
for sym_def in item[1:]:
if not (isinstance(sym_def, list) and sym_def and sym_def[0] == symbol_k):
continue
if len(sym_def) < 2:
continue
name = str(sym_def[1]).strip('"')
if name == lib_id:
return PinLocator.parse_symbol_definition(sym_def)
break # only one lib_symbols section
return {}
@staticmethod
def pin_world_xy(
px: float,
py: float,
sym_x: float,
sym_y: float,
rotation: float,
mirror_x: bool,
mirror_y: bool,
) -> Tuple[float, float]:
"""
Compute the world coordinate of a pin given the symbol transform.
KiCAD applies mirror first (in local space), then rotation, then translation.
mirror_x negates the local X axis; mirror_y negates the local Y axis.
"""
lx, ly = px, py
if mirror_x:
lx = -lx
if mirror_y:
ly = -ly
rx, ry = _rotate(lx, ly, rotation)
return sym_x + rx, sym_y + ry
@staticmethod
def compute_pin_positions(
sch_data: list,
reference: str,
new_x: float,
new_y: float,
) -> Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]]:
"""
Compute world pin positions before and after a component move.
Returns {pin_num: (old_world_xy, new_world_xy)}.
old_world_xy uses the symbol's current position; new_world_xy uses (new_x, new_y).
"""
found = WireDragger.find_symbol(sch_data, reference)
if found is None:
return {}
_, old_x, old_y, rotation, lib_id, mirror_x, mirror_y = found
pins = WireDragger.get_pin_defs(sch_data, lib_id)
result: Dict[str, Tuple] = {}
for pin_num, pin in pins.items():
px, py = pin["x"], pin["y"]
old_wx, old_wy = WireDragger.pin_world_xy(
px, py, old_x, old_y, rotation, mirror_x, mirror_y
)
new_wx, new_wy = WireDragger.pin_world_xy(
px, py, new_x, new_y, rotation, mirror_x, mirror_y
)
result[pin_num] = (
(round(old_wx, 6), round(old_wy, 6)),
(round(new_wx, 6), round(new_wy, 6)),
)
return result
@staticmethod
def drag_wires(
sch_data: list,
old_to_new: Dict[Tuple[float, float], Tuple[float, float]],
eps: float = EPS,
) -> Dict:
"""
Move wire endpoints and junctions from old positions to new positions.
Removes zero-length wires that result from the move.
Modifies sch_data in place.
old_to_new: {(old_x, old_y): (new_x, new_y)}
Returns {'endpoints_moved': N, 'wires_removed': M}.
"""
wire_k = _K["wire"]
pts_k = _K["pts"]
xy_k = _K["xy"]
junction_k = _K["junction"]
at_k = _K["at"]
def find_new(x: float, y: float) -> Optional[Tuple[float, float]]:
for (ox, oy), (nx, ny) in old_to_new.items():
if _coords_match(x, y, ox, oy, eps):
return nx, ny
return None
endpoints_moved = 0
zero_length_indices = []
# First pass: update wire endpoints
for idx, item in enumerate(sch_data):
if not (isinstance(item, list) and item and item[0] == wire_k):
continue
pts_sub = None
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == pts_k:
pts_sub = sub
break
if pts_sub is None:
continue
xy_items = [
p for p in pts_sub[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == xy_k
]
for xy_item in xy_items:
nc = find_new(float(xy_item[1]), float(xy_item[2]))
if nc is not None:
xy_item[1] = nc[0]
xy_item[2] = nc[1]
endpoints_moved += 1
# Check if this wire is now zero-length
if len(xy_items) >= 2:
x1, y1 = float(xy_items[0][1]), float(xy_items[0][2])
x2, y2 = float(xy_items[-1][1]), float(xy_items[-1][2])
if _coords_match(x1, y1, x2, y2, eps):
zero_length_indices.append(idx)
# Remove zero-length wires (backwards to preserve indices)
for idx in reversed(zero_length_indices):
del sch_data[idx]
# Second pass: update junctions
for item in sch_data:
if not (isinstance(item, list) and item and item[0] == junction_k):
continue
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3:
nc = find_new(float(sub[1]), float(sub[2]))
if nc is not None:
sub[1] = nc[0]
sub[2] = nc[1]
break
return {
"endpoints_moved": endpoints_moved,
"wires_removed": len(zero_length_indices),
}
@staticmethod
def update_symbol_position(sch_data: list, reference: str, new_x: float, new_y: float) -> bool:
"""
Update the (at x y rot) of the named symbol in sch_data.
Returns True if the symbol was found and updated.
"""
found = WireDragger.find_symbol(sch_data, reference)
if found is None:
return False
item = found[0]
at_k = _K["at"]
prop_k = _K["property"]
# Find current position and compute delta
old_x = old_y = None
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3:
old_x, old_y = sub[1], sub[2]
sub[1] = new_x
sub[2] = new_y
break
if old_x is None or old_y is None:
return False
dx = new_x - old_x
dy = new_y - old_y
# Shift all property label positions by the same delta
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == prop_k:
for psub in sub[1:]:
if isinstance(psub, list) and psub and psub[0] == at_k and len(psub) >= 3:
psub[1] += dx
psub[2] += dy
break
return True
@staticmethod
def _make_wire_sexp(x1: float, y1: float, x2: float, y2: float) -> list:
"""Build a wire s-expression list in KiCAD schematic format."""
wire_uuid = str(uuid.uuid4())
return [
_K["wire"],
[_K["pts"], [_K["xy"], x1, y1], [_K["xy"], x2, y2]],
[_K["stroke"], [_K["width"], 0], [_K["type"], Symbol("default")]],
[_K["uuid"], wire_uuid],
]
@staticmethod
def get_all_stationary_pin_positions(
sch_data: list,
moved_reference: str,
) -> Dict[Tuple[float, float], str]:
"""
Return a map of {world_xy: reference} for every pin of every symbol
in sch_data *except* moved_reference.
This is used to detect pins of stationary components that coincide
with pins of the moved component (touching-pin connections).
"""
sym_k = _K["symbol"]
prop_k = _K["property"]
result: Dict[Tuple[float, float], str] = {}
for item in sch_data:
if not (isinstance(item, list) and item and item[0] == sym_k):
continue
# Determine reference
ref_val = None
for sub in item[1:]:
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k:
if str(sub[1]).strip('"') == "Reference":
ref_val = str(sub[2]).strip('"')
break
if ref_val is None or ref_val == moved_reference:
continue
# Skip template / power symbols whose references start with special chars
# but we still want to handle them — no filtering needed here.
# Find lib_id and position for this symbol
found = WireDragger.find_symbol(sch_data, ref_val)
if found is None:
continue
_, sx, sy, rotation, lib_id, mirror_x, mirror_y = found
pins = WireDragger.get_pin_defs(sch_data, lib_id)
for pin_num, pin in pins.items():
wx, wy = WireDragger.pin_world_xy(
pin["x"], pin["y"], sx, sy, rotation, mirror_x, mirror_y
)
key = (round(wx, 6), round(wy, 6))
result[key] = ref_val
return result
@staticmethod
def synthesize_touching_pin_wires(
sch_data: list,
moved_reference: str,
pin_positions: Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]],
eps: float = EPS,
) -> int:
"""
Detect touching-pin connections and synthesize wire segments to bridge gaps
created by moving a component.
For each pin of *moved_reference* whose old world position coincides with
a pin of a stationary component:
- If the pin moved (old_xy != new_xy), insert a wire from old_xy to new_xy.
- If the pin now lands on another stationary pin's position, skip (they touch again).
- If old_xy == new_xy, do nothing (no gap was created).
Modifies sch_data in place.
Returns the number of wire segments synthesized.
"""
if not pin_positions:
return 0
stationary_pins = WireDragger.get_all_stationary_pin_positions(sch_data, moved_reference)
if not stationary_pins:
return 0
synthesized = 0
for pin_num, (old_xy, new_xy) in pin_positions.items():
# Check if a stationary pin touches this pin's old position
touching = any(
_coords_match(old_xy[0], old_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins
)
if not touching:
continue
# The pin has moved — check if it actually separated
if _coords_match(old_xy[0], old_xy[1], new_xy[0], new_xy[1], eps):
# Pin didn't actually move; no gap
continue
# Check if the pin's new position happens to touch another stationary pin
# (component moved into a different touching position — no wire needed)
rejoining = any(
_coords_match(new_xy[0], new_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins
)
if rejoining:
logger.debug(
f"Pin {moved_reference}/{pin_num} moved from {old_xy} to {new_xy} "
f"and rejoins another stationary pin; no wire synthesized"
)
continue
logger.info(
f"Synthesizing wire for touching-pin connection: "
f"{moved_reference}/{pin_num} moved from {old_xy} to {new_xy}"
)
wire = WireDragger._make_wire_sexp(old_xy[0], old_xy[1], new_xy[0], new_xy[1])
# Insert before the last item (sheet_instances) to keep file tidy,
# but appending is also valid — just append.
sch_data.append(wire)
synthesized += 1
return synthesized

View File

@@ -6,17 +6,30 @@ kicad-skip's wire API doesn't support creating wires with standard parameters, s
manipulate the .kicad_sch file directly.
"""
import uuid
import logging
import math
import tempfile
import uuid
from pathlib import Path
from typing import List, Tuple, Optional, Dict
from typing import Any, List, Optional, Tuple
import sexpdata
from sexpdata import Symbol
logger = logging.getLogger("kicad_interface")
# Module-level Symbol constants — avoids repeated allocation on every call
_SYM_WIRE = Symbol("wire")
_SYM_PTS = Symbol("pts")
_SYM_XY = Symbol("xy")
_SYM_AT = Symbol("at")
_SYM_LABEL = Symbol("label")
_SYM_STROKE = Symbol("stroke")
_SYM_WIDTH = Symbol("width")
_SYM_TYPE = Symbol("type")
_SYM_UUID = Symbol("uuid")
_SYM_SHEET_INSTANCES = Symbol("sheet_instances")
class WireManager:
"""Manage wires in KiCad schematics using S-expression manipulation"""
@@ -49,31 +62,22 @@ class WireManager:
sch_data = sexpdata.loads(sch_content)
# Break any existing wire that passes through a new endpoint (T-junction support)
for pt in (start_point, end_point):
splits = WireManager._break_wires_at_point(sch_data, pt)
if splits:
logger.info(f"Broke {splits} wire(s) at new wire endpoint {pt}")
# Create wire S-expression
# Format: (wire (pts (xy x1 y1) (xy x2 y2)) (stroke (width N) (type default)) (uuid ...))
wire_sexp = [
Symbol("wire"),
[
Symbol("pts"),
[Symbol("xy"), start_point[0], start_point[1]],
[Symbol("xy"), end_point[0], end_point[1]],
],
[
Symbol("stroke"),
[Symbol("width"), stroke_width],
[Symbol("type"), Symbol(stroke_type)],
],
[Symbol("uuid"), str(uuid.uuid4())],
]
wire_sexp = WireManager._make_wire_sexp(
start_point, end_point, stroke_width, stroke_type
)
# Find insertion point (before sheet_instances)
sheet_instances_index = None
for i, item in enumerate(sch_data):
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("sheet_instances")
):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
@@ -130,31 +134,23 @@ class WireManager:
sch_data = sexpdata.loads(sch_content)
# Create pts list
pts_list = [Symbol("pts")]
for point in points:
pts_list.append([Symbol("xy"), point[0], point[1]])
# Break any existing wire at the outer endpoints of the new path
for pt in (points[0], points[-1]):
splits = WireManager._break_wires_at_point(sch_data, pt)
if splits:
logger.info(f"Broke {splits} wire(s) at new polyline endpoint {pt}")
# Create wire S-expression with multiple points
wire_sexp = [
Symbol("wire"),
pts_list,
[
Symbol("stroke"),
[Symbol("width"), stroke_width],
[Symbol("type"), Symbol(stroke_type)],
],
[Symbol("uuid"), str(uuid.uuid4())],
# KiCAD wire elements only support exactly 2 pts each.
# Split N waypoints into N-1 individual wire segments.
wire_sexps = [
WireManager._make_wire_sexp(points[i], points[i + 1], stroke_width, stroke_type)
for i in range(len(points) - 1)
]
# Find insertion point
sheet_instances_index = None
for i, item in enumerate(sch_data):
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("sheet_instances")
):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
@@ -162,9 +158,12 @@ class WireManager:
logger.error("No sheet_instances section found in schematic")
return False
# Insert wire
# Insert all segments (in reverse so order is preserved after inserts)
for wire_sexp in reversed(wire_sexps):
sch_data.insert(sheet_instances_index, wire_sexp)
logger.info(f"Injected polyline wire with {len(points)} points")
logger.info(
f"Injected {len(wire_sexps)} wire segments for {len(points)}-point polyline"
)
# Write back
with open(schematic_path, "w", encoding="utf-8") as f:
@@ -227,11 +226,7 @@ class WireManager:
# Find insertion point
sheet_instances_index = None
for i, item in enumerate(sch_data):
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("sheet_instances")
):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
@@ -259,11 +254,121 @@ class WireManager:
return False
@staticmethod
def add_junction(
schematic_path: Path, position: List[float], diameter: float = 0
def _parse_wire(
wire_item: Any,
) -> Optional[Tuple[Tuple[float, float], Tuple[float, float], float, str]]:
"""
Parse a wire S-expression item in a single pass.
Returns ((x1,y1), (x2,y2), stroke_width, stroke_type), or None if not a valid wire.
"""
if not (isinstance(wire_item, list) and len(wire_item) >= 2 and wire_item[0] == _SYM_WIRE):
return None
start = end = None
stroke_width: float = 0
stroke_type: str = "default"
for part in wire_item[1:]:
if not isinstance(part, list) or not part:
continue
tag = part[0]
if tag == _SYM_PTS:
found: List[Tuple[float, float]] = []
for p in part[1:]:
if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_XY:
found.append((float(p[1]), float(p[2])))
if len(found) == 2:
break
if len(found) == 2:
start, end = found[0], found[1]
elif tag == _SYM_STROKE:
for sp in part[1:]:
if isinstance(sp, list) and len(sp) >= 2:
if sp[0] == _SYM_WIDTH:
stroke_width = sp[1]
elif sp[0] == _SYM_TYPE:
stroke_type = str(sp[1])
if start is not None and end is not None:
return start, end, stroke_width, stroke_type
return None
@staticmethod
def _point_strictly_on_wire(
px: float,
py: float,
x1: float,
y1: float,
x2: float,
y2: float,
eps: float = 1e-6,
) -> bool:
"""
Add a junction (connection dot) to the schematic
Return True if (px, py) lies strictly between (x1,y1) and (x2,y2)
on a horizontal or vertical wire segment (not at either endpoint).
"""
if abs(y1 - y2) < eps: # horizontal wire
if abs(py - y1) > eps:
return False
lo, hi = min(x1, x2), max(x1, x2)
return lo + eps < px < hi - eps
if abs(x1 - x2) < eps: # vertical wire
if abs(px - x1) > eps:
return False
lo, hi = min(y1, y2), max(y1, y2)
return lo + eps < py < hi - eps
return False
@staticmethod
def _make_wire_sexp(
start: List[float],
end: List[float],
stroke_width: float = 0,
stroke_type: str = "default",
) -> list:
return [
_SYM_WIRE,
[_SYM_PTS, [_SYM_XY, start[0], start[1]], [_SYM_XY, end[0], end[1]]],
[_SYM_STROKE, [_SYM_WIDTH, stroke_width], [_SYM_TYPE, Symbol(stroke_type)]],
[_SYM_UUID, str(uuid.uuid4())],
]
@staticmethod
def _break_wires_at_point(sch_data: list, position: List[float]) -> int:
"""
Split any wire segment that passes through *position* as a strict
midpoint (i.e. position is not an existing endpoint). Mirrors
KiCAD's SCH_LINE_WIRE_BUS_TOOL::BreakSegments behaviour.
Returns the number of wires split.
"""
px, py = float(position[0]), float(position[1])
splits = 0
i = 0
while i < len(sch_data):
parsed = WireManager._parse_wire(sch_data[i])
if parsed is not None:
(x1, y1), (x2, y2), stroke_width, stroke_type = parsed
if WireManager._point_strictly_on_wire(px, py, x1, y1, x2, y2):
seg_a = WireManager._make_wire_sexp(
[x1, y1], [px, py], stroke_width, stroke_type
)
seg_b = WireManager._make_wire_sexp(
[px, py], [x2, y2], stroke_width, stroke_type
)
sch_data[i : i + 1] = [seg_a, seg_b]
logger.info(f"Split wire ({x1},{y1})->({x2},{y2}) at ({px},{py})")
splits += 1
i += 2 # skip the two new segments
continue
i += 1
return splits
@staticmethod
def add_junction(schematic_path: Path, position: List[float], diameter: float = 0) -> bool:
"""
Add a junction (connection dot) to the schematic.
Mirrors KiCAD's AddJunction behaviour: any wire whose interior passes
through *position* is split into two segments at that point so that
the BFS-based get_wire_connections tool can traverse the T/X branch.
Args:
schematic_path: Path to .kicad_sch file
@@ -280,6 +385,12 @@ class WireManager:
sch_data = sexpdata.loads(sch_content)
# Split any wire that passes through the junction as a midpoint
# (mirrors KiCAD's AddJunction / BreakSegments behaviour)
splits = WireManager._break_wires_at_point(sch_data, position)
if splits:
logger.info(f"Broke {splits} wire(s) at junction position {position}")
# Create junction S-expression
# Format: (junction (at x y) (diameter 0) (color 0 0 0 0) (uuid ...))
junction_sexp = [
@@ -293,11 +404,7 @@ class WireManager:
# Find insertion point
sheet_instances_index = None
for i, item in enumerate(sch_data):
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("sheet_instances")
):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
@@ -354,11 +461,7 @@ class WireManager:
# Find insertion point
sheet_instances_index = None
for i, item in enumerate(sch_data):
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("sheet_instances")
):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
@@ -414,21 +517,13 @@ class WireManager:
ex, ey = end_point
for i, item in enumerate(sch_data):
if not (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("wire")
):
if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_WIRE):
continue
# Extract pts from the wire s-expression
pts_list = None
for part in item[1:]:
if (
isinstance(part, list)
and len(part) > 0
and part[0] == Symbol("pts")
):
if isinstance(part, list) and len(part) > 0 and part[0] == _SYM_PTS:
pts_list = part
break
@@ -438,7 +533,7 @@ class WireManager:
xy_points = [
p
for p in pts_list[1:]
if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy")
if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_XY
]
if len(xy_points) < 2:
continue
@@ -502,11 +597,7 @@ class WireManager:
sch_data = sexpdata.loads(sch_content)
for i, item in enumerate(sch_data):
if not (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("label")
):
if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_LABEL):
continue
# Second element is the label text
@@ -519,9 +610,7 @@ class WireManager:
(
p
for p in item[1:]
if isinstance(p, list)
and len(p) >= 3
and p[0] == Symbol("at")
if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_AT
),
None,
)
@@ -529,8 +618,7 @@ class WireManager:
continue
lx, ly = float(at_entry[1]), float(at_entry[2])
if not (
abs(lx - position[0]) < tolerance
and abs(ly - position[1]) < tolerance
abs(lx - position[0]) < tolerance and abs(ly - position[1]) < tolerance
):
continue
@@ -584,12 +672,11 @@ class WireManager:
if __name__ == "__main__":
# Test wire creation
import sys
sys.path.insert(0, "/home/chris/MCP/KiCAD-MCP-Server/python")
from pathlib import Path
import shutil
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
print("=" * 80)
print("WIRE MANAGER TEST")
@@ -597,9 +684,7 @@ if __name__ == "__main__":
# Create test schematic (cross-platform temp directory)
test_path = Path(tempfile.gettempdir()) / "test_wire_manager.kicad_sch"
template_path = Path(
"/home/chris/MCP/KiCAD-MCP-Server/python/templates/empty.kicad_sch"
)
template_path = Path(__file__).parent.parent / "templates" / "empty.kicad_sch"
shutil.copy(template_path, test_path)
print(f"\n✓ Created test schematic: {test_path}")

View File

@@ -20,8 +20,8 @@ Usage:
board.set_size(100, 80)
"""
from kicad_api.factory import create_backend
from kicad_api.base import KiCADBackend
from kicad_api.factory import create_backend
__all__ = ['create_backend', 'KiCADBackend']
__version__ = '2.0.0-alpha.1'
__all__ = ["create_backend", "KiCADBackend"]
__version__ = "2.0.0-alpha.1"

View File

@@ -3,10 +3,11 @@ Abstract base class for KiCAD API backends
Defines the interface that all KiCAD backends must implement.
"""
import logging
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Optional, Dict, Any, List
import logging
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@@ -97,7 +98,7 @@ class KiCADBackend(ABC):
# Board Operations
@abstractmethod
def get_board(self) -> 'BoardAPI':
def get_board(self) -> "BoardAPI":
"""
Get board API for current project
@@ -126,7 +127,7 @@ class BoardAPI(ABC):
pass
@abstractmethod
def get_size(self) -> Dict[str, float]:
def get_size(self) -> Dict[str, Any]:
"""
Get current board size
@@ -167,7 +168,8 @@ class BoardAPI(ABC):
x: float,
y: float,
rotation: float = 0,
layer: str = "F.Cu"
layer: str = "F.Cu",
value: str = "",
) -> bool:
"""
Place a component on the board
@@ -194,7 +196,7 @@ class BoardAPI(ABC):
end_y: float,
width: float = 0.25,
layer: str = "F.Cu",
net_name: Optional[str] = None
net_name: Optional[str] = None,
) -> bool:
"""
Add a track (trace) to the board
@@ -220,7 +222,7 @@ class BoardAPI(ABC):
diameter: float = 0.8,
drill: float = 0.4,
net_name: Optional[str] = None,
via_type: str = "through"
via_type: str = "through",
) -> bool:
"""
Add a via to the board
@@ -275,14 +277,17 @@ class BoardAPI(ABC):
class BackendError(Exception):
"""Base exception for backend errors"""
pass
class ConnectionError(BackendError):
"""Raised when connection to KiCAD fails"""
pass
class APINotAvailableError(BackendError):
"""Raised when required API is not available"""
pass

View File

@@ -3,12 +3,13 @@ Backend factory for creating appropriate KiCAD API backend
Auto-detects available backends and provides fallback mechanism.
"""
import os
import logging
from typing import Optional
from pathlib import Path
from kicad_api.base import KiCADBackend, APINotAvailableError
import logging
import os
from pathlib import Path
from typing import Optional
from kicad_api.base import APINotAvailableError, KiCADBackend
logger = logging.getLogger(__name__)
@@ -34,16 +35,16 @@ def create_backend(backend_type: Optional[str] = None) -> KiCADBackend:
"""
# Check environment variable override
if backend_type is None:
backend_type = os.environ.get('KICAD_BACKEND', 'auto').lower()
backend_type = os.environ.get("KICAD_BACKEND", "auto").lower()
logger.info(f"Requested backend: {backend_type}")
# Try specific backend if requested
if backend_type == 'ipc':
if backend_type == "ipc":
return _create_ipc_backend()
elif backend_type == 'swig':
elif backend_type == "swig":
return _create_swig_backend()
elif backend_type == 'auto':
elif backend_type == "auto":
return _auto_detect_backend()
else:
raise ValueError(f"Unknown backend type: {backend_type}")
@@ -61,13 +62,13 @@ def _create_ipc_backend() -> KiCADBackend:
"""
try:
from kicad_api.ipc_backend import IPCBackend
logger.info("Creating IPC backend")
return IPCBackend()
except ImportError as e:
logger.error(f"IPC backend not available: {e}")
raise APINotAvailableError(
"IPC backend requires 'kicad-python' package. "
"Install with: pip install kicad-python"
"IPC backend requires 'kicad-python' package. " "Install with: pip install kicad-python"
) from e
@@ -83,6 +84,7 @@ def _create_swig_backend() -> KiCADBackend:
"""
try:
from kicad_api.swig_backend import SWIGBackend
logger.info("Creating SWIG backend")
logger.warning(
"SWIG backend is DEPRECATED and will be removed in KiCAD 10.0. "
@@ -92,8 +94,7 @@ def _create_swig_backend() -> KiCADBackend:
except ImportError as e:
logger.error(f"SWIG backend not available: {e}")
raise APINotAvailableError(
"SWIG backend requires 'pcbnew' module. "
"Ensure KiCAD Python module is in PYTHONPATH."
"SWIG backend requires 'pcbnew' module. " "Ensure KiCAD Python module is in PYTHONPATH."
) from e
@@ -129,8 +130,7 @@ def _auto_detect_backend() -> KiCADBackend:
try:
backend = _create_swig_backend()
logger.warning(
"Using deprecated SWIG backend. "
"For best results, use IPC API with KiCAD running."
"Using deprecated SWIG backend. " "For best results, use IPC API with KiCAD running."
)
return backend
except (ImportError, APINotAvailableError) as e:
@@ -160,22 +160,18 @@ def get_available_backends() -> dict:
# Check IPC (kicad-python uses 'kipy' module name)
try:
import kipy
results['ipc'] = {
'available': True,
'version': getattr(kipy, '__version__', 'unknown')
}
results["ipc"] = {"available": True, "version": getattr(kipy, "__version__", "unknown")}
except ImportError:
results['ipc'] = {'available': False, 'version': None}
results["ipc"] = {"available": False, "version": None}
# Check SWIG
try:
import pcbnew
results['swig'] = {
'available': True,
'version': pcbnew.GetBuildVersion()
}
results["swig"] = {"available": True, "version": pcbnew.GetBuildVersion()}
except ImportError:
results['swig'] = {'available': False, 'version': None}
results["swig"] = {"available": False, "version": None}
return results
@@ -183,6 +179,7 @@ def get_available_backends() -> dict:
if __name__ == "__main__":
# Quick diagnostic
import json
print("KiCAD Backend Availability:")
print(json.dumps(get_available_backends(), indent=2))

View File

@@ -13,18 +13,14 @@ Key Benefits over SWIG:
- Stable API that won't break between versions
- Multi-language support
"""
import logging
import os
import platform
from pathlib import Path
from typing import Optional, Dict, Any, List, Callable
from typing import Any, Callable, Dict, List, Optional
from kicad_api.base import (
KiCADBackend,
BoardAPI,
ConnectionError,
APINotAvailableError
)
from kicad_api.base import APINotAvailableError, BoardAPI, ConnectionError, KiCADBackend
logger = logging.getLogger(__name__)
@@ -44,10 +40,10 @@ class IPCBackend(KiCADBackend):
without requiring manual reload.
"""
def __init__(self):
def __init__(self) -> None:
self._kicad = None
self._connected = False
self._version = None
self._version: Optional[str] = None
self._on_change_callbacks: List[Callable] = []
def connect(self, socket_path: Optional[str] = None) -> bool:
@@ -78,10 +74,10 @@ class IPCBackend(KiCADBackend):
# Common socket locations (Unix-like systems only)
# Windows uses named pipes, handled by auto-detect
if platform.system() != "Windows":
socket_paths_to_try.append('ipc:///tmp/kicad/api.sock') # Linux default
socket_paths_to_try.append("ipc:///tmp/kicad/api.sock") # Linux default
# XDG runtime directory (requires getuid, Unix only)
if hasattr(os, 'getuid'):
socket_paths_to_try.append(f'ipc:///run/user/{os.getuid()}/kicad/api.sock')
if hasattr(os, "getuid"):
socket_paths_to_try.append(f"ipc:///run/user/{os.getuid()}/kicad/api.sock")
# Auto-detect for all platforms (Windows uses named pipes, Unix uses sockets)
socket_paths_to_try.append(None)
@@ -117,8 +113,7 @@ class IPCBackend(KiCADBackend):
except ImportError as e:
logger.error("kicad-python library not found")
raise APINotAvailableError(
"IPC backend requires kicad-python. "
"Install with: pip install kicad-python"
"IPC backend requires kicad-python. " "Install with: pip install kicad-python"
) from e
except Exception as e:
logger.error(f"Failed to connect via IPC: {e}")
@@ -190,7 +185,7 @@ class IPCBackend(KiCADBackend):
return {
"success": False,
"message": "Direct project creation not supported via IPC",
"suggestion": "Open KiCAD and create a new project, or use SWIG backend"
"suggestion": "Open KiCAD and create a new project, or use SWIG backend",
}
def open_project(self, path: Path) -> Dict[str, Any]:
@@ -209,22 +204,18 @@ class IPCBackend(KiCADBackend):
return {
"success": True,
"message": f"Project already open: {path}",
"path": str(path)
"path": str(path),
}
return {
"success": False,
"message": "Project not currently open in KiCAD",
"suggestion": "Open the project in KiCAD first, then connect via IPC"
"suggestion": "Open the project in KiCAD first, then connect via IPC",
}
except Exception as e:
logger.error(f"Failed to check project: {e}")
return {
"success": False,
"message": "Failed to check project",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to check project", "errorDetails": str(e)}
def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]:
"""Save current project via IPC."""
@@ -240,17 +231,10 @@ class IPCBackend(KiCADBackend):
self._notify_change("save", {"path": str(path) if path else "current"})
return {
"success": True,
"message": "Project saved successfully"
}
return {"success": True, "message": "Project saved successfully"}
except Exception as e:
logger.error(f"Failed to save project: {e}")
return {
"success": False,
"message": "Failed to save project",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to save project", "errorDetails": str(e)}
def close_project(self) -> None:
"""Close current project (not supported via IPC)."""
@@ -273,13 +257,13 @@ class IPCBoardAPI(BoardAPI):
Uses transactions for proper undo/redo support.
"""
def __init__(self, kicad_instance, notify_callback: Callable):
def __init__(self, kicad_instance: Any, notify_callback: Callable) -> None:
self._kicad = kicad_instance
self._board = None
self._notify = notify_callback
self._current_commit = None
def _get_board(self):
def _get_board(self) -> Any:
"""Get board instance, connecting if needed."""
if self._board is None:
try:
@@ -333,8 +317,8 @@ class IPCBoardAPI(BoardAPI):
try:
from kipy.board_types import BoardRectangle
from kipy.geometry import Vector2
from kipy.util.units import from_mm
from kipy.proto.board.board_types_pb2 import BoardLayer
from kipy.util.units import from_mm
board = self._get_board()
@@ -366,7 +350,7 @@ class IPCBoardAPI(BoardAPI):
logger.error(f"Failed to set board size: {e}")
return False
def get_size(self) -> Dict[str, float]:
def get_size(self) -> Dict[str, Any]:
"""Get current board size from bounding box."""
try:
board = self._get_board()
@@ -380,8 +364,8 @@ class IPCBoardAPI(BoardAPI):
# Find bounding box of edge cuts
from kipy.util.units import to_mm
min_x = min_y = float('inf')
max_x = max_y = float('-inf')
min_x = min_y = float("inf")
max_x = max_y = float("-inf")
for shape in shapes:
# Check if on Edge.Cuts layer
@@ -392,14 +376,10 @@ class IPCBoardAPI(BoardAPI):
max_x = max(max_x, bbox.max.x)
max_y = max(max_y, bbox.max.y)
if min_x == float('inf'):
if min_x == float("inf"):
return {"width": 0, "height": 0, "unit": "mm"}
return {
"width": to_mm(max_x - min_x),
"height": to_mm(max_y - min_y),
"unit": "mm"
}
return {"width": to_mm(max_x - min_x), "height": to_mm(max_y - min_y), "unit": "mm"}
except Exception as e:
logger.error(f"Failed to get board size: {e}")
@@ -432,19 +412,23 @@ class IPCBoardAPI(BoardAPI):
for fp in footprints:
try:
pos = fp.position
components.append({
"reference": fp.reference_field.text.value if fp.reference_field else "",
components.append(
{
"reference": (
fp.reference_field.text.value if fp.reference_field else ""
),
"value": fp.value_field.text.value if fp.value_field else "",
"footprint": str(fp.definition.library_link) if fp.definition else "",
"position": {
"x": to_mm(pos.x) if pos else 0,
"y": to_mm(pos.y) if pos else 0,
"unit": "mm"
"unit": "mm",
},
"rotation": fp.orientation.degrees if fp.orientation else 0,
"layer": str(fp.layer) if hasattr(fp, 'layer') else "F.Cu",
"id": str(fp.id) if hasattr(fp, 'id') else ""
})
"layer": str(fp.layer) if hasattr(fp, "layer") else "F.Cu",
"id": str(fp.id) if hasattr(fp, "id") else "",
}
)
except Exception as e:
logger.warning(f"Error processing footprint: {e}")
continue
@@ -463,7 +447,7 @@ class IPCBoardAPI(BoardAPI):
y: float,
rotation: float = 0,
layer: str = "F.Cu",
value: str = ""
value: str = "",
) -> bool:
"""
Place a component on the board.
@@ -495,7 +479,9 @@ class IPCBoardAPI(BoardAPI):
)
else:
# Fallback: Create a basic placeholder footprint via IPC
logger.warning(f"Could not load footprint '{footprint}' from library, creating placeholder")
logger.warning(
f"Could not load footprint '{footprint}' from library, creating placeholder"
)
return self._place_placeholder_footprint(
reference, footprint, x, y, rotation, layer, value
)
@@ -504,7 +490,7 @@ class IPCBoardAPI(BoardAPI):
logger.error(f"Failed to place component: {e}")
return False
def _load_footprint_from_library(self, footprint_path: str):
def _load_footprint_from_library(self, footprint_path: str) -> Any:
"""
Load a footprint from the library using pcbnew SWIG API.
@@ -518,8 +504,8 @@ class IPCBoardAPI(BoardAPI):
import pcbnew
# Parse library and footprint name
if ':' in footprint_path:
lib_name, fp_name = footprint_path.split(':', 1)
if ":" in footprint_path:
lib_name, fp_name = footprint_path.split(":", 1)
else:
# Try to find the footprint in all libraries
lib_name = None
@@ -561,13 +547,13 @@ class IPCBoardAPI(BoardAPI):
def _place_loaded_footprint(
self,
loaded_fp,
loaded_fp: Any,
reference: str,
x: float,
y: float,
rotation: float,
layer: str,
value: str
value: str,
) -> bool:
"""
Place a loaded pcbnew footprint onto the board.
@@ -589,7 +575,7 @@ class IPCBoardAPI(BoardAPI):
try:
docs = self._kicad.get_open_documents()
for doc in docs:
if hasattr(doc, 'path') and str(doc.path).endswith('.kicad_pcb'):
if hasattr(doc, "path") and str(doc.path).endswith(".kicad_pcb"):
board_path = str(doc.path)
break
except Exception as e:
@@ -637,24 +623,27 @@ class IPCBoardAPI(BoardAPI):
except Exception as e:
logger.debug(f"Could not refresh IPC board: {e}")
self._notify("component_placed", {
self._notify(
"component_placed",
{
"reference": reference,
"footprint": loaded_fp.GetFPIDAsString(),
"position": {"x": x, "y": y},
"rotation": rotation,
"layer": layer,
"loaded_from_library": True
})
"loaded_from_library": True,
},
)
logger.info(f"Placed component {reference} ({loaded_fp.GetFPIDAsString()}) at ({x}, {y}) mm")
logger.info(
f"Placed component {reference} ({loaded_fp.GetFPIDAsString()}) at ({x}, {y}) mm"
)
return True
except Exception as e:
logger.error(f"Error placing loaded footprint: {e}")
# Fall back to placeholder
return self._place_placeholder_footprint(
reference, "", x, y, rotation, layer, value
)
return self._place_placeholder_footprint(reference, "", x, y, rotation, layer, value)
def _place_placeholder_footprint(
self,
@@ -664,7 +653,7 @@ class IPCBoardAPI(BoardAPI):
y: float,
rotation: float,
layer: str,
value: str
value: str,
) -> bool:
"""
Place a placeholder footprint when library loading fails.
@@ -673,9 +662,9 @@ class IPCBoardAPI(BoardAPI):
"""
try:
from kipy.board_types import Footprint
from kipy.geometry import Vector2, Angle
from kipy.util.units import from_mm
from kipy.geometry import Angle, Vector2
from kipy.proto.board.board_types_pb2 import BoardLayer
from kipy.util.units import from_mm
board = self._get_board()
@@ -701,15 +690,18 @@ class IPCBoardAPI(BoardAPI):
board.create_items(fp)
board.push_commit(commit, f"Placed component {reference}")
self._notify("component_placed", {
self._notify(
"component_placed",
{
"reference": reference,
"footprint": footprint,
"position": {"x": x, "y": y},
"rotation": rotation,
"layer": layer,
"loaded_from_library": False,
"is_placeholder": True
})
"is_placeholder": True,
},
)
logger.info(f"Placed placeholder component {reference} at ({x}, {y}) mm")
return True
@@ -718,10 +710,12 @@ class IPCBoardAPI(BoardAPI):
logger.error(f"Failed to place placeholder component: {e}")
return False
def move_component(self, reference: str, x: float, y: float, rotation: Optional[float] = None) -> bool:
def move_component(
self, reference: str, x: float, y: float, rotation: Optional[float] = None
) -> bool:
"""Move a component to a new position (updates UI immediately)."""
try:
from kipy.geometry import Vector2, Angle
from kipy.geometry import Angle, Vector2
from kipy.util.units import from_mm
board = self._get_board()
@@ -749,11 +743,10 @@ class IPCBoardAPI(BoardAPI):
board.update_items([target_fp])
board.push_commit(commit, f"Moved component {reference}")
self._notify("component_moved", {
"reference": reference,
"position": {"x": x, "y": y},
"rotation": rotation
})
self._notify(
"component_moved",
{"reference": reference, "position": {"x": x, "y": y}, "rotation": rotation},
)
return True
@@ -799,7 +792,7 @@ class IPCBoardAPI(BoardAPI):
end_y: float,
width: float = 0.25,
layer: str = "F.Cu",
net_name: Optional[str] = None
net_name: Optional[str] = None,
) -> bool:
"""
Add a track (trace) to the board.
@@ -809,8 +802,8 @@ class IPCBoardAPI(BoardAPI):
try:
from kipy.board_types import Track
from kipy.geometry import Vector2
from kipy.util.units import from_mm
from kipy.proto.board.board_types_pb2 import BoardLayer
from kipy.util.units import from_mm
board = self._get_board()
@@ -842,13 +835,16 @@ class IPCBoardAPI(BoardAPI):
board.create_items(track)
board.push_commit(commit, "Added track")
self._notify("track_added", {
self._notify(
"track_added",
{
"start": {"x": start_x, "y": start_y},
"end": {"x": end_x, "y": end_y},
"width": width,
"layer": layer,
"net": net_name
})
"net": net_name,
},
)
logger.info(f"Added track from ({start_x}, {start_y}) to ({end_x}, {end_y}) mm")
return True
@@ -864,7 +860,7 @@ class IPCBoardAPI(BoardAPI):
diameter: float = 0.8,
drill: float = 0.4,
net_name: Optional[str] = None,
via_type: str = "through"
via_type: str = "through",
) -> bool:
"""
Add a via to the board.
@@ -874,8 +870,8 @@ class IPCBoardAPI(BoardAPI):
try:
from kipy.board_types import Via
from kipy.geometry import Vector2
from kipy.util.units import from_mm
from kipy.proto.board.board_types_pb2 import ViaType
from kipy.util.units import from_mm
board = self._get_board()
@@ -906,13 +902,16 @@ class IPCBoardAPI(BoardAPI):
board.create_items(via)
board.push_commit(commit, "Added via")
self._notify("via_added", {
self._notify(
"via_added",
{
"position": {"x": x, "y": y},
"diameter": diameter,
"drill": drill,
"net": net_name,
"type": via_type
})
"type": via_type,
},
)
logger.info(f"Added via at ({x}, {y}) mm")
return True
@@ -928,14 +927,14 @@ class IPCBoardAPI(BoardAPI):
y: float,
layer: str = "F.SilkS",
size: float = 1.0,
rotation: float = 0
rotation: float = 0,
) -> bool:
"""Add text to the board."""
try:
from kipy.board_types import BoardText
from kipy.geometry import Vector2, Angle
from kipy.util.units import from_mm
from kipy.geometry import Angle, Vector2
from kipy.proto.board.board_types_pb2 import BoardLayer
from kipy.util.units import from_mm
board = self._get_board()
@@ -959,11 +958,7 @@ class IPCBoardAPI(BoardAPI):
board.create_items(board_text)
board.push_commit(commit, f"Added text: {text}")
self._notify("text_added", {
"text": text,
"position": {"x": x, "y": y},
"layer": layer
})
self._notify("text_added", {"text": text, "position": {"x": x, "y": y}, "layer": layer})
return True
@@ -982,20 +977,16 @@ class IPCBoardAPI(BoardAPI):
result = []
for track in tracks:
try:
result.append({
"start": {
"x": to_mm(track.start.x),
"y": to_mm(track.start.y)
},
"end": {
"x": to_mm(track.end.x),
"y": to_mm(track.end.y)
},
result.append(
{
"start": {"x": to_mm(track.start.x), "y": to_mm(track.start.y)},
"end": {"x": to_mm(track.end.x), "y": to_mm(track.end.y)},
"width": to_mm(track.width),
"layer": str(track.layer),
"net": track.net.name if track.net else "",
"id": str(track.id) if hasattr(track, 'id') else ""
})
"id": str(track.id) if hasattr(track, "id") else "",
}
)
except Exception as e:
logger.warning(f"Error processing track: {e}")
continue
@@ -1017,17 +1008,16 @@ class IPCBoardAPI(BoardAPI):
result = []
for via in vias:
try:
result.append({
"position": {
"x": to_mm(via.position.x),
"y": to_mm(via.position.y)
},
result.append(
{
"position": {"x": to_mm(via.position.x), "y": to_mm(via.position.y)},
"diameter": to_mm(via.diameter),
"drill": to_mm(via.drill_diameter),
"net": via.net.name if via.net else "",
"type": str(via.type),
"id": str(via.id) if hasattr(via, 'id') else ""
})
"id": str(via.id) if hasattr(via, "id") else "",
}
)
except Exception as e:
logger.warning(f"Error processing via: {e}")
continue
@@ -1047,10 +1037,9 @@ class IPCBoardAPI(BoardAPI):
result = []
for net in nets:
try:
result.append({
"name": net.name,
"code": net.code if hasattr(net, 'code') else 0
})
result.append(
{"name": net.name, "code": net.code if hasattr(net, "code") else 0}
)
except Exception as e:
logger.warning(f"Error processing net: {e}")
continue
@@ -1070,7 +1059,7 @@ class IPCBoardAPI(BoardAPI):
min_thickness: float = 0.25,
priority: int = 0,
fill_mode: str = "solid",
name: str = ""
name: str = "",
) -> bool:
"""
Add a copper pour zone to the board.
@@ -1090,8 +1079,8 @@ class IPCBoardAPI(BoardAPI):
try:
from kipy.board_types import Zone, ZoneFillMode, ZoneType
from kipy.geometry import PolyLine, PolyLineNode, Vector2
from kipy.util.units import from_mm
from kipy.proto.board.board_types_pb2 import BoardLayer
from kipy.util.units import from_mm
board = self._get_board()
@@ -1157,12 +1146,10 @@ class IPCBoardAPI(BoardAPI):
board.create_items(zone)
board.push_commit(commit, f"Added copper zone on {layer}")
self._notify("zone_added", {
"layer": layer,
"net": net_name,
"points": len(points),
"priority": priority
})
self._notify(
"zone_added",
{"layer": layer, "net": net_name, "points": len(points), "priority": priority},
)
logger.info(f"Added zone on {layer} with {len(points)} points")
return True
@@ -1182,14 +1169,18 @@ class IPCBoardAPI(BoardAPI):
result = []
for zone in zones:
try:
result.append({
"name": zone.name if hasattr(zone, 'name') else "",
result.append(
{
"name": zone.name if hasattr(zone, "name") else "",
"net": zone.net.name if zone.net else "",
"priority": zone.priority if hasattr(zone, 'priority') else 0,
"layers": [str(l) for l in zone.layers] if hasattr(zone, 'layers') else [],
"filled": zone.filled if hasattr(zone, 'filled') else False,
"id": str(zone.id) if hasattr(zone, 'id') else ""
})
"priority": zone.priority if hasattr(zone, "priority") else 0,
"layers": (
[str(l) for l in zone.layers] if hasattr(zone, "layers") else []
),
"filled": zone.filled if hasattr(zone, "filled") else False,
"id": str(zone.id) if hasattr(zone, "id") else "",
}
)
except Exception as e:
logger.warning(f"Error processing zone: {e}")
continue
@@ -1219,10 +1210,9 @@ class IPCBoardAPI(BoardAPI):
result = []
for item in selection:
result.append({
"type": type(item).__name__,
"id": str(item.id) if hasattr(item, 'id') else ""
})
result.append(
{"type": type(item).__name__, "id": str(item.id) if hasattr(item, "id") else ""}
)
return result
except Exception as e:
@@ -1241,4 +1231,4 @@ class IPCBoardAPI(BoardAPI):
# Export for factory
__all__ = ['IPCBackend', 'IPCBoardAPI']
__all__ = ["IPCBackend", "IPCBoardAPI"]

View File

@@ -8,16 +8,12 @@ WARNING: SWIG bindings are deprecated as of KiCAD 9.0
and will be removed in KiCAD 10.0.
Please migrate to IPC backend.
"""
import logging
from pathlib import Path
from typing import Optional, Dict, Any, List
from typing import Any, Dict, List, Optional
from kicad_api.base import (
KiCADBackend,
BoardAPI,
ConnectionError,
APINotAvailableError
)
from kicad_api.base import APINotAvailableError, BoardAPI, ConnectionError, KiCADBackend
logger = logging.getLogger(__name__)
@@ -30,7 +26,7 @@ class SWIGBackend(KiCADBackend):
for compatibility during migration period.
"""
def __init__(self):
def __init__(self) -> None:
self._connected = False
self._pcbnew = None
logger.warning(
@@ -48,6 +44,7 @@ class SWIGBackend(KiCADBackend):
"""
try:
import pcbnew
self._pcbnew = pcbnew
version = pcbnew.GetBuildVersion()
logger.info(f"✓ Connected to pcbnew (SWIG): {version}")
@@ -101,7 +98,7 @@ class SWIGBackend(KiCADBackend):
from commands.project import ProjectCommands
try:
result = ProjectCommands.open_project(str(path))
result = ProjectCommands().open_project({"filename": str(path)})
return result
except Exception as e:
logger.error(f"Failed to open project: {e}")
@@ -115,8 +112,10 @@ class SWIGBackend(KiCADBackend):
from commands.project import ProjectCommands
try:
path_str = str(path) if path else None
result = ProjectCommands.save_project(path_str)
params: Dict[str, Any] = {}
if path:
params["filename"] = str(path)
result = ProjectCommands().save_project(params)
return result
except Exception as e:
logger.error(f"Failed to save project: {e}")
@@ -140,7 +139,7 @@ class SWIGBackend(KiCADBackend):
class SWIGBoardAPI(BoardAPI):
"""Board API implementation wrapping SWIG/pcbnew"""
def __init__(self, pcbnew_module):
def __init__(self, pcbnew_module: Any) -> None:
self.pcbnew = pcbnew_module
self._board = None
@@ -149,13 +148,15 @@ class SWIGBoardAPI(BoardAPI):
from commands.board import BoardCommands
try:
result = BoardCommands.set_board_size(width, height, unit)
result = BoardCommands(board=self._board).set_board_size(
{"width": width, "height": height, "unit": unit}
)
return result.get("success", False)
except Exception as e:
logger.error(f"Failed to set board size: {e}")
return False
def get_size(self) -> Dict[str, float]:
def get_size(self) -> Dict[str, Any]:
"""Get board size"""
# TODO: Implement using existing SWIG code
raise NotImplementedError("get_size not yet wrapped")
@@ -176,7 +177,7 @@ class SWIGBoardAPI(BoardAPI):
from commands.component import ComponentCommands
try:
result = ComponentCommands.get_component_list()
result = ComponentCommands(board=self._board).get_component_list({})
if result.get("success"):
return result.get("components", [])
return []
@@ -191,18 +192,21 @@ class SWIGBoardAPI(BoardAPI):
x: float,
y: float,
rotation: float = 0,
layer: str = "F.Cu"
layer: str = "F.Cu",
value: str = "",
) -> bool:
"""Place component using existing implementation"""
from commands.component import ComponentCommands
try:
result = ComponentCommands.place_component(
component_id=footprint,
position={"x": x, "y": y, "unit": "mm"},
reference=reference,
rotation=rotation,
layer=layer
result = ComponentCommands(board=self._board).place_component(
{
"componentId": footprint,
"position": {"x": x, "y": y, "unit": "mm"},
"reference": reference,
"rotation": rotation,
"layer": layer,
}
)
return result.get("success", False)
except Exception as e:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
# parsers package

View File

@@ -0,0 +1,250 @@
"""
Parser for KiCad .kicad_mod footprint files.
Extracts the fields that the MCP get_footprint_info tool exposes to clients:
name footprint name (str)
library library nickname, injected by caller (str)
description (descr "") token (str | None)
keywords (tags "") token (str | None)
pads list of pad objects: [{number, type, shape}, …] (list[dict])
layers sorted unique list of canonical layer names used (list[str])
courtyard {"width": float, "height": float} from F.CrtYd geometry (dict | None)
attributes {"type": str, "board_only": bool, …} (dict | None)
KiCad S-expression file format reference:
https://dev-docs.kicad.org/en/file-formats/sexpr-intro/index.html#_footprint
"""
import logging
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger("kicad_interface")
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]:
"""
Parse a .kicad_mod file and return a dict whose keys match the fields
expected by the TypeScript MCP tool handler (src/tools/library.ts).
Returns None if the file does not exist or cannot be read.
"""
path = Path(file_path)
if not path.exists():
logger.debug(f"parse_kicad_mod: file not found: {file_path}")
return None
try:
content = path.read_text(encoding="utf-8")
except OSError as e:
logger.warning(f"parse_kicad_mod: cannot read {file_path}: {e}")
return None
logger.debug(f"parse_kicad_mod: parsing {path.name} ({len(content)} chars)")
result: Dict[str, Any] = {}
# ------------------------------------------------------------------
# Footprint name: (footprint "NAME" …
# Per spec, in a library file the name is the ENTRY_NAME only (no lib prefix).
# ------------------------------------------------------------------
m = re.search(r'^\s*\(footprint\s+"((?:[^"\\]|\\.)*)"', content, re.MULTILINE)
if not m:
# Older / unquoted format
m = re.search(r"^\s*\(footprint\s+(\S+)", content, re.MULTILINE)
result["name"] = _unescape(m.group(1)) if m else path.stem
logger.debug(f"parse_kicad_mod: name={result['name']!r}")
# ------------------------------------------------------------------
# Description: (descr "…")
# ------------------------------------------------------------------
m = re.search(r'\(descr\s+"((?:[^"\\]|\\.)*)"\)', content)
result["description"] = _unescape(m.group(1)) if m else None
logger.debug(f"parse_kicad_mod: description={result['description']!r}")
# ------------------------------------------------------------------
# Keywords / tags: (tags "…")
# ------------------------------------------------------------------
m = re.search(r'\(tags\s+"((?:[^"\\]|\\.)*)"\)', content)
result["keywords"] = _unescape(m.group(1)) if m else None
logger.debug(f"parse_kicad_mod: keywords={result['keywords']!r}")
# ------------------------------------------------------------------
# Attributes: (attr TYPE [board_only] [exclude_from_pos_files] [exclude_from_bom])
# TYPE is smd | through_hole (no quotes)
# ------------------------------------------------------------------
m = re.search(r"\(attr\s+([^)]+)\)", content)
if m:
tokens = m.group(1).split()
result["attributes"] = {
"type": tokens[0] if tokens else "unspecified",
"board_only": "board_only" in tokens,
"exclude_from_pos_files": "exclude_from_pos_files" in tokens,
"exclude_from_bom": "exclude_from_bom" in tokens,
}
else:
result["attributes"] = None
logger.debug(f"parse_kicad_mod: attributes={result['attributes']!r}")
# ------------------------------------------------------------------
# Pads: (pad "NUMBER" TYPE SHAPE …)
# Return each pad as an object; deduplicate by number (first wins).
# ------------------------------------------------------------------
result["pads"] = _extract_pads(content)
logger.debug(f"parse_kicad_mod: pads count={len(result['pads'])}, pads={result['pads']}")
# ------------------------------------------------------------------
# Layers: all unique canonical layer names across the whole file.
# Sources:
# (layer "NAME") single-layer items (fp_line, fp_text, …)
# (layers "A" "B" …) pad layer lists
# ------------------------------------------------------------------
layers: set = set()
for m in re.finditer(r'\(layer\s+"([^"]+)"\)', content):
layers.add(m.group(1))
for m in re.finditer(r"\(layers\s+([^)]+)\)", content):
for lyr in re.findall(r'"([^"]+)"', m.group(1)):
layers.add(lyr)
result["layers"] = sorted(layers)
logger.debug(f"parse_kicad_mod: layers={result['layers']}")
# ------------------------------------------------------------------
# Courtyard: derive bounding box from F.CrtYd geometry.
# Prefer fp_rect (most common for standard footprints), fall back to
# fp_line segments.
# ------------------------------------------------------------------
result["courtyard"] = _extract_courtyard(content)
logger.debug(f"parse_kicad_mod: courtyard={result['courtyard']!r}")
return result
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _extract_pads(content: str) -> List[Dict[str, Any]]:
"""
Parse all (pad …) blocks and return a list of pad objects.
Each object has:
number pad number string, e.g. "1", "A1", "GND"
type thru_hole | smd | np_thru_hole | connect
shape rect | circle | oval | roundrect | trapezoid | custom
Pads are deduplicated by number (first occurrence wins) so that the
list represents the logical pads of the footprint, not duplicated
copper entries.
"""
pads: List[Dict[str, Any]] = []
seen_numbers: dict = {}
# KiCad 6+ quoted format: (pad "NUMBER" TYPE SHAPE …)
quoted_pattern = re.compile(
r'\(pad\s+"([^"]*)"\s+'
r"(thru_hole|smd|np_thru_hole|connect)\s+"
r"(rect|circle|oval|roundrect|trapezoid|custom)\b"
)
for m in quoted_pattern.finditer(content):
number, ptype, shape = m.group(1), m.group(2), m.group(3)
if number not in seen_numbers:
seen_numbers[number] = True
pads.append({"number": number, "type": ptype, "shape": shape})
if not pads:
# Older / unquoted format: (pad NUMBER TYPE SHAPE …)
unquoted_pattern = re.compile(
r"\(pad\s+(\S+)\s+"
r"(thru_hole|smd|np_thru_hole|connect)\s+"
r"(rect|circle|oval|roundrect|trapezoid|custom)\b"
)
for m in unquoted_pattern.finditer(content):
number, ptype, shape = m.group(1), m.group(2), m.group(3)
if number not in seen_numbers:
seen_numbers[number] = True
pads.append({"number": number, "type": ptype, "shape": shape})
return pads
def _unescape(s: str) -> str:
"""Reverse KiCad S-expression string escaping."""
return s.replace('\\"', '"').replace("\\\\", "\\")
def _extract_blocks(content: str, token: str) -> List[str]:
"""
Return all S-expression blocks that start with `(token ` by tracking
parenthesis depth. This correctly handles nested parens inside blocks.
"""
blocks: List[str] = []
pattern = re.compile(r"\(" + re.escape(token) + r"\b")
for match in pattern.finditer(content):
start = match.start()
depth = 0
i = start
while i < len(content):
ch = content[i]
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
blocks.append(content[start : i + 1])
break
i += 1
return blocks
def _extract_courtyard(content: str) -> Optional[Dict[str, float]]:
"""
Compute the courtyard bounding box from F.CrtYd geometry.
Strategy:
1. Try fp_rect blocks on F.CrtYd — derive width/height from start/end.
2. Fall back to fp_line segments on F.CrtYd — compute bounding box of
all endpoints.
"""
xs: List[float] = []
ys: List[float] = []
# --- fp_rect pass ---
for block in _extract_blocks(content, "fp_rect"):
if "F.CrtYd" not in block:
continue
s = re.search(r"\(start\s+([-\d.]+)\s+([-\d.]+)\)", block)
e = re.search(r"\(end\s+([-\d.]+)\s+([-\d.]+)\)", block)
if s and e:
xs += [float(s.group(1)), float(e.group(1))]
ys += [float(s.group(2)), float(e.group(2))]
logger.debug(
f"_extract_courtyard: fp_rect F.CrtYd "
f"start=({s.group(1)},{s.group(2)}) end=({e.group(1)},{e.group(2)})"
)
# --- fp_line pass (only if fp_rect found nothing) ---
if not xs:
for block in _extract_blocks(content, "fp_line"):
if "F.CrtYd" not in block:
continue
for m in re.finditer(r"\((?:start|end)\s+([-\d.]+)\s+([-\d.]+)\)", block):
xs.append(float(m.group(1)))
ys.append(float(m.group(2)))
if not xs:
logger.debug("_extract_courtyard: no F.CrtYd geometry found")
return None
width = round(abs(max(xs) - min(xs)), 6)
height = round(abs(max(ys) - min(ys)), 6)
logger.debug(f"_extract_courtyard: result width={width} height={height}")
return {"width": width, "height": height}

View File

@@ -4,4 +4,4 @@ Resource definitions for KiCAD MCP Server
from .resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read
__all__ = ['RESOURCE_DEFINITIONS', 'handle_resource_read']
__all__ = ["RESOURCE_DEFINITIONS", "handle_resource_read"]

View File

@@ -5,12 +5,12 @@ Resources follow the MCP 2025-06-18 specification, providing
read-only access to project data for LLM context.
"""
import json
import base64
from typing import Dict, Any, List, Optional
import json
import logging
from typing import Any, Dict, List, Optional
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
# =============================================================================
# RESOURCE DEFINITIONS
@@ -21,57 +21,58 @@ RESOURCE_DEFINITIONS = [
"uri": "kicad://project/current/info",
"name": "Current Project Information",
"description": "Metadata about the currently open KiCAD project including paths, name, and status",
"mimeType": "application/json"
"mimeType": "application/json",
},
{
"uri": "kicad://project/current/board",
"name": "Board Properties",
"description": "Comprehensive board information including dimensions, layer count, and design rules",
"mimeType": "application/json"
"mimeType": "application/json",
},
{
"uri": "kicad://project/current/components",
"name": "Component List",
"description": "List of all components on the board with references, footprints, values, and positions",
"mimeType": "application/json"
"mimeType": "application/json",
},
{
"uri": "kicad://project/current/nets",
"name": "Electrical Nets",
"description": "List of all electrical nets and their connections",
"mimeType": "application/json"
"mimeType": "application/json",
},
{
"uri": "kicad://project/current/layers",
"name": "Layer Stack",
"description": "Board layer configuration and properties",
"mimeType": "application/json"
"mimeType": "application/json",
},
{
"uri": "kicad://project/current/design-rules",
"name": "Design Rules",
"description": "Current design rule settings for clearances, track widths, and constraints",
"mimeType": "application/json"
"mimeType": "application/json",
},
{
"uri": "kicad://project/current/drc-report",
"name": "DRC Violations",
"description": "Design Rule Check violations and warnings from last DRC run",
"mimeType": "application/json"
"mimeType": "application/json",
},
{
"uri": "kicad://board/preview.png",
"name": "Board Preview Image",
"description": "2D rendering of the current board state",
"mimeType": "image/png"
}
"mimeType": "image/png",
},
]
# =============================================================================
# RESOURCE READ HANDLERS
# =============================================================================
def handle_resource_read(uri: str, interface) -> Dict[str, Any]:
def handle_resource_read(uri: str, interface: Any) -> Dict[str, Any]:
"""
Handle reading a resource by URI
@@ -92,7 +93,7 @@ def handle_resource_read(uri: str, interface) -> Dict[str, Any]:
"kicad://project/current/layers": _get_layers,
"kicad://project/current/design-rules": _get_design_rules,
"kicad://project/current/drc-report": _get_drc_report,
"kicad://board/preview.png": _get_board_preview
"kicad://board/preview.png": _get_board_preview,
}
handler = handlers.get(uri)
@@ -102,193 +103,217 @@ def handle_resource_read(uri: str, interface) -> Dict[str, Any]:
except Exception as e:
logger.error(f"Error reading resource {uri}: {str(e)}")
return {
"contents": [{
"uri": uri,
"mimeType": "text/plain",
"text": f"Error: {str(e)}"
}]
"contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Error: {str(e)}"}]
}
else:
return {
"contents": [{
"uri": uri,
"mimeType": "text/plain",
"text": f"Unknown resource: {uri}"
}]
"contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Unknown resource: {uri}"}]
}
# =============================================================================
# INDIVIDUAL RESOURCE HANDLERS
# =============================================================================
def _get_project_info(interface) -> Dict[str, Any]:
def _get_project_info(interface: Any) -> Dict[str, Any]:
"""Get current project information"""
result = interface.project_commands.get_project_info({})
if result.get("success"):
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/info",
"mimeType": "application/json",
"text": json.dumps(result.get("project", {}), indent=2)
}]
"text": json.dumps(result.get("project", {}), indent=2),
}
]
}
else:
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/info",
"mimeType": "text/plain",
"text": "No project currently open"
}]
"text": "No project currently open",
}
]
}
def _get_board_info(interface) -> Dict[str, Any]:
def _get_board_info(interface: Any) -> Dict[str, Any]:
"""Get board properties and metadata"""
result = interface.board_commands.get_board_info({})
if result.get("success"):
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/board",
"mimeType": "application/json",
"text": json.dumps(result.get("board", {}), indent=2)
}]
"text": json.dumps(result.get("board", {}), indent=2),
}
]
}
else:
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/board",
"mimeType": "text/plain",
"text": "No board currently loaded"
}]
"text": "No board currently loaded",
}
]
}
def _get_components(interface) -> Dict[str, Any]:
def _get_components(interface: Any) -> Dict[str, Any]:
"""Get list of all components"""
result = interface.component_commands.get_component_list({})
if result.get("success"):
components = result.get("components", [])
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/components",
"mimeType": "application/json",
"text": json.dumps({
"count": len(components),
"components": components
}, indent=2)
}]
"text": json.dumps(
{"count": len(components), "components": components}, indent=2
),
}
]
}
else:
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/components",
"mimeType": "application/json",
"text": json.dumps({"count": 0, "components": []}, indent=2)
}]
"text": json.dumps({"count": 0, "components": []}, indent=2),
}
]
}
def _get_nets(interface) -> Dict[str, Any]:
def _get_nets(interface: Any) -> Dict[str, Any]:
"""Get list of electrical nets"""
result = interface.routing_commands.get_nets_list({})
if result.get("success"):
nets = result.get("nets", [])
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/nets",
"mimeType": "application/json",
"text": json.dumps({
"count": len(nets),
"nets": nets
}, indent=2)
}]
"text": json.dumps({"count": len(nets), "nets": nets}, indent=2),
}
]
}
else:
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/nets",
"mimeType": "application/json",
"text": json.dumps({"count": 0, "nets": []}, indent=2)
}]
"text": json.dumps({"count": 0, "nets": []}, indent=2),
}
]
}
def _get_layers(interface) -> Dict[str, Any]:
def _get_layers(interface: Any) -> Dict[str, Any]:
"""Get layer stack information"""
result = interface.board_commands.get_layer_list({})
if result.get("success"):
layers = result.get("layers", [])
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/layers",
"mimeType": "application/json",
"text": json.dumps({
"count": len(layers),
"layers": layers
}, indent=2)
}]
"text": json.dumps({"count": len(layers), "layers": layers}, indent=2),
}
]
}
else:
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/layers",
"mimeType": "application/json",
"text": json.dumps({"count": 0, "layers": []}, indent=2)
}]
"text": json.dumps({"count": 0, "layers": []}, indent=2),
}
]
}
def _get_design_rules(interface) -> Dict[str, Any]:
def _get_design_rules(interface: Any) -> Dict[str, Any]:
"""Get design rule settings"""
result = interface.design_rule_commands.get_design_rules({})
if result.get("success"):
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/design-rules",
"mimeType": "application/json",
"text": json.dumps(result.get("rules", {}), indent=2)
}]
"text": json.dumps(result.get("rules", {}), indent=2),
}
]
}
else:
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/design-rules",
"mimeType": "text/plain",
"text": "Design rules not available"
}]
"text": "Design rules not available",
}
]
}
def _get_drc_report(interface) -> Dict[str, Any]:
def _get_drc_report(interface: Any) -> Dict[str, Any]:
"""Get DRC violations"""
result = interface.design_rule_commands.get_drc_violations({})
if result.get("success"):
violations = result.get("violations", [])
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/drc-report",
"mimeType": "application/json",
"text": json.dumps({
"count": len(violations),
"violations": violations
}, indent=2)
}]
"text": json.dumps(
{"count": len(violations), "violations": violations}, indent=2
),
}
]
}
else:
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/drc-report",
"mimeType": "application/json",
"text": json.dumps({
"text": json.dumps(
{
"count": 0,
"violations": [],
"message": "Run DRC first to get violations"
}, indent=2)
}]
"message": "Run DRC first to get violations",
},
indent=2,
),
}
]
}
def _get_board_preview(interface) -> Dict[str, Any]:
def _get_board_preview(interface: Any) -> Dict[str, Any]:
"""Get board preview as PNG image"""
result = interface.board_commands.get_board_2d_view({"width": 800, "height": 600})
@@ -296,18 +321,22 @@ def _get_board_preview(interface) -> Dict[str, Any]:
# Image data should already be base64 encoded
image_data = result.get("imageData", "")
return {
"contents": [{
"contents": [
{
"uri": "kicad://board/preview.png",
"mimeType": "image/png",
"blob": image_data # Base64 encoded PNG
}]
"blob": image_data, # Base64 encoded PNG
}
]
}
else:
# Return a placeholder message
return {
"contents": [{
"contents": [
{
"uri": "kicad://board/preview.png",
"mimeType": "text/plain",
"text": "Board preview not available"
}]
"text": "Board preview not available",
}
]
}

View File

@@ -4,4 +4,4 @@ Tool schema definitions for KiCAD MCP Server
from .tool_schemas import TOOL_SCHEMAS
__all__ = ['TOOL_SCHEMAS']
__all__ = ["TOOL_SCHEMAS"]

File diff suppressed because it is too large Load Diff

View File

@@ -13,8 +13,10 @@ Prerequisites:
Usage:
./venv/bin/python python/test_ipc_backend.py
"""
import sys
import os
import sys
from typing import Any, Optional
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -23,13 +25,12 @@ import logging
# Set up logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
def test_connection():
def test_connection() -> Optional[Any]:
"""Test basic IPC connection to KiCAD."""
print("\n" + "=" * 60)
print("TEST 1: IPC Connection")
@@ -62,7 +63,7 @@ def test_connection():
return None
def test_board_access(backend):
def test_board_access(backend: Any) -> Optional[Any]:
"""Test board access and component listing."""
print("\n" + "=" * 60)
print("TEST 2: Board Access")
@@ -79,11 +80,11 @@ def test_board_access(backend):
if components:
print("\n First 5 components:")
for comp in components[:5]:
ref = comp.get('reference', 'N/A')
val = comp.get('value', 'N/A')
pos = comp.get('position', {})
x = pos.get('x', 0)
y = pos.get('y', 0)
ref = comp.get("reference", "N/A")
val = comp.get("value", "N/A")
pos = comp.get("position", {})
x = pos.get("x", 0)
y = pos.get("y", 0)
print(f" - {ref}: {val} @ ({x:.2f}, {y:.2f}) mm")
return board_api
@@ -93,7 +94,7 @@ def test_board_access(backend):
return None
def test_board_info(board_api):
def test_board_info(board_api: Any) -> bool:
"""Test getting board information."""
print("\n" + "=" * 60)
print("TEST 3: Board Information")
@@ -134,7 +135,7 @@ def test_board_info(board_api):
return False
def test_realtime_track(board_api, interactive=False):
def test_realtime_track(board_api: Any, interactive: bool = False) -> bool:
"""Test adding a track in real-time (appears immediately in KiCAD UI)."""
print("\n" + "=" * 60)
print("TEST 4: Real-time Track Addition")
@@ -145,19 +146,14 @@ def test_realtime_track(board_api, interactive=False):
if interactive:
response = input("\nProceed with adding a test track? [y/N]: ").strip().lower()
if response != 'y':
if response != "y":
print("Skipped track test")
return False
try:
# Add a track
success = board_api.add_track(
start_x=100.0,
start_y=100.0,
end_x=120.0,
end_y=100.0,
width=0.25,
layer="F.Cu"
start_x=100.0, start_y=100.0, end_x=120.0, end_y=100.0, width=0.25, layer="F.Cu"
)
if success:
@@ -173,7 +169,7 @@ def test_realtime_track(board_api, interactive=False):
return False
def test_realtime_via(board_api, interactive=False):
def test_realtime_via(board_api: Any, interactive: bool = False) -> bool:
"""Test adding a via in real-time (appears immediately in KiCAD UI)."""
print("\n" + "=" * 60)
print("TEST 5: Real-time Via Addition")
@@ -184,19 +180,13 @@ def test_realtime_via(board_api, interactive=False):
if interactive:
response = input("\nProceed with adding a test via? [y/N]: ").strip().lower()
if response != 'y':
if response != "y":
print("Skipped via test")
return False
try:
# Add a via
success = board_api.add_via(
x=120.0,
y=100.0,
diameter=0.8,
drill=0.4,
via_type="through"
)
success = board_api.add_via(x=120.0, y=100.0, diameter=0.8, drill=0.4, via_type="through")
if success:
print("✓ Via added! Check the KiCAD window - it should appear at (120, 100) mm")
@@ -211,7 +201,7 @@ def test_realtime_via(board_api, interactive=False):
return False
def test_realtime_text(board_api, interactive=False):
def test_realtime_text(board_api: Any, interactive: bool = False) -> bool:
"""Test adding text in real-time."""
print("\n" + "=" * 60)
print("TEST 6: Real-time Text Addition")
@@ -221,18 +211,12 @@ def test_realtime_text(board_api, interactive=False):
if interactive:
response = input("\nProceed with adding test text? [y/N]: ").strip().lower()
if response != 'y':
if response != "y":
print("Skipped text test")
return False
try:
success = board_api.add_text(
text="MCP Test",
x=100.0,
y=95.0,
layer="F.SilkS",
size=1.0
)
success = board_api.add_text(text="MCP Test", x=100.0, y=95.0, layer="F.SilkS", size=1.0)
if success:
print("✓ Text added! Check the KiCAD window - should show 'MCP Test' at (100, 95) mm")
@@ -246,7 +230,7 @@ def test_realtime_text(board_api, interactive=False):
return False
def test_selection(board_api, interactive=False):
def test_selection(board_api: Any, interactive: bool = False) -> bool:
"""Test getting the current selection from KiCAD UI."""
print("\n" + "=" * 60)
print("TEST 7: UI Selection")
@@ -272,7 +256,7 @@ def test_selection(board_api, interactive=False):
return False
def run_all_tests(interactive=False):
def run_all_tests(interactive: bool = False) -> bool:
"""Run all IPC backend tests."""
print("\n" + "=" * 60)
print("KiCAD IPC Backend Test Suite")
@@ -321,9 +305,14 @@ def run_all_tests(interactive=False):
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description='Test KiCAD IPC Backend')
parser.add_argument('-i', '--interactive', action='store_true',
help='Run in interactive mode (prompts before modifications)')
parser = argparse.ArgumentParser(description="Test KiCAD IPC Backend")
parser.add_argument(
"-i",
"--interactive",
action="store_true",
help="Run in interactive mode (prompts before modifications)",
)
args = parser.parse_args()
success = run_all_tests(interactive=args.interactive)

Some files were not shown because too many files have changed in this diff Show More