From f047a4e4d1734faa66ee6c62949fdc55c1e1b6d5 Mon Sep 17 00:00:00 2001 From: KiCAD MCP Bot Date: Sat, 21 Mar 2026 01:31:44 -0400 Subject: [PATCH 01/74] docs: comprehensive documentation overhaul for v2.2.3 Major documentation update bringing all docs current with the 122-tool, 16-category state of the project (previously frozen at v2.1.0-alpha/59 tools). New documentation (9 files): - FREEROUTING_GUIDE.md - autorouter setup, Docker/Podman, all 4 tools - SCHEMATIC_TOOLS_REFERENCE.md - all 27 schematic tools with parameters - ROUTING_TOOLS_REFERENCE.md - all 13 routing tools with examples - FOOTPRINT_SYMBOL_CREATOR_GUIDE.md - 8 creator tools with examples - SVG_IMPORT_GUIDE.md - SVG logo import tool - DATASHEET_TOOLS_GUIDE.md - datasheet enrichment tools - PCB_DESIGN_WORKFLOW.md - end-to-end design guide - ARCHITECTURE.md - system architecture for contributors - INDEX.md - documentation table of contents Updated documentation (12 files): - README.md - tool count 64->122, feature list, contributor credits - TOOL_INVENTORY.md - complete rebuild with all 122 tools - STATUS_SUMMARY.md - updated to v2.2.3 feature matrix - ROADMAP.md - marked completed milestones, added v2.3+ vision - KNOWN_ISSUES.md - removed resolved issues, added v2.2.x fixes - CLIENT_CONFIGURATION.md - added KICAD_MCP_DEV, FREEROUTING_JAR env vars - LIBRARY_INTEGRATION.md - added symbol and project-local library support - ROUTER_ARCHITECTURE.md, ROUTER_QUICK_START.md - updated tool counts - IPC_BACKEND_STATUS.md - updated dates - JLCPCB_USAGE_GUIDE.md - added cross-reference note - CONTRIBUTING.md - added ARCHITECTURE.md reference, updated tool count Archived 10 completed planning docs to docs/archive/. Co-Authored-By: Claude Opus 4.6 (1M context) --- CONTRIBUTING.md | 17 + README.md | 280 +++++---- docs/ARCHITECTURE.md | 314 +++++++++++ docs/CLIENT_CONFIGURATION.md | 6 +- docs/DATASHEET_TOOLS_GUIDE.md | 102 ++++ docs/FOOTPRINT_SYMBOL_CREATOR_GUIDE.md | 411 ++++++++++++++ docs/FREEROUTING_GUIDE.md | 226 ++++++++ docs/INDEX.md | 89 +++ docs/IPC_BACKEND_STATUS.md | 8 +- docs/JLCPCB_USAGE_GUIDE.md | 2 + docs/KNOWN_ISSUES.md | 376 ++++++------- docs/LIBRARY_INTEGRATION.md | 28 +- docs/PCB_DESIGN_WORKFLOW.md | 304 ++++++++++ docs/ROADMAP.md | 428 ++++---------- docs/ROUTER_ARCHITECTURE.md | 14 +- docs/ROUTER_QUICK_START.md | 19 +- docs/ROUTING_TOOLS_REFERENCE.md | 530 ++++++++++++++++++ docs/SCHEMATIC_TOOLS_REFERENCE.md | 303 ++++++++++ docs/STATUS_SUMMARY.md | 445 ++++++--------- docs/SVG_IMPORT_GUIDE.md | 113 ++++ docs/TOOL_INVENTORY.md | 458 +++++++++++---- docs/{ => archive}/BUILD_AND_TEST_SESSION.md | 0 .../DYNAMIC_LIBRARY_LOADING_PLAN.md | 0 docs/{ => archive}/DYNAMIC_LOADING_STATUS.md | 0 docs/{ => archive}/IPC_API_MIGRATION_PLAN.md | 0 docs/{ => archive}/JLCPCB_INTEGRATION_PLAN.md | 0 docs/archive/README.md | 18 + .../ROUTER_IMPLEMENTATION_STATUS.md | 0 docs/{ => archive}/SCHEMATIC_WIRING_PLAN.md | 0 docs/{ => archive}/SCHEMATIC_WORKFLOW_FIX.md | 0 docs/{ => archive}/WEEK1_SESSION1_SUMMARY.md | 0 docs/{ => archive}/WEEK1_SESSION2_SUMMARY.md | 0 32 files changed, 3421 insertions(+), 1070 deletions(-) create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DATASHEET_TOOLS_GUIDE.md create mode 100644 docs/FOOTPRINT_SYMBOL_CREATOR_GUIDE.md create mode 100644 docs/FREEROUTING_GUIDE.md create mode 100644 docs/INDEX.md create mode 100644 docs/PCB_DESIGN_WORKFLOW.md create mode 100644 docs/ROUTING_TOOLS_REFERENCE.md create mode 100644 docs/SCHEMATIC_TOOLS_REFERENCE.md create mode 100644 docs/SVG_IMPORT_GUIDE.md rename docs/{ => archive}/BUILD_AND_TEST_SESSION.md (100%) rename docs/{ => archive}/DYNAMIC_LIBRARY_LOADING_PLAN.md (100%) rename docs/{ => archive}/DYNAMIC_LOADING_STATUS.md (100%) rename docs/{ => archive}/IPC_API_MIGRATION_PLAN.md (100%) rename docs/{ => archive}/JLCPCB_INTEGRATION_PLAN.md (100%) create mode 100644 docs/archive/README.md rename docs/{ => archive}/ROUTER_IMPLEMENTATION_STATUS.md (100%) rename docs/{ => archive}/SCHEMATIC_WIRING_PLAN.md (100%) rename docs/{ => archive}/SCHEMATIC_WORKFLOW_FIX.md (100%) rename docs/{ => archive}/WEEK1_SESSION1_SUMMARY.md (100%) rename docs/{ => archive}/WEEK1_SESSION2_SUMMARY.md (100%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e219e36..8f4f204 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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) @@ -144,6 +145,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 diff --git a/README.md b/README.md index 6c5fb35..1b35efd 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,14 @@ 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) @@ -62,7 +66,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.** @@ -150,7 +154,7 @@ 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: @@ -163,15 +167,16 @@ Note: IPC features are under active development and testing. Enable IPC in KiCAD ### 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. @@ -225,106 +230,148 @@ 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. -### Project Management (4 tools) +For the complete tool reference with access types (direct/routed/additional), see [Tool Inventory](docs/TOOL_INVENTORY.md). + +### Project Management (5 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 (9 tools) +### Board Operations (12 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 Placement (10 tools) +### Component Management (16 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 +- `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 -### 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 +### Freerouting Autorouter (4 tools) +- `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 -### 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 - -### Schematic Design (9 tools) -**Now fully functional with DYNAMIC SYMBOL LOADING + INTELLIGENT WIRING!** (Fixed in v2.1.0 - see Issue #26) - -**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 @@ -705,8 +752,8 @@ How many Basic parts are available? ### 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/`) @@ -842,62 +889,46 @@ 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. -**Under Active Development (IPC Backend):** +**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):** - 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 +955,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,7 +980,7 @@ 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} } ``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..d29e15c --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,314 @@ +# 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 | diff --git a/docs/CLIENT_CONFIGURATION.md b/docs/CLIENT_CONFIGURATION.md index 430a1ea..5532a62 100644 --- a/docs/CLIENT_CONFIGURATION.md +++ b/docs/CLIENT_CONFIGURATION.md @@ -220,6 +220,8 @@ For any MCP-compatible client that supports STDIO transport: | `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 | --- @@ -537,5 +539,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+ diff --git a/docs/DATASHEET_TOOLS_GUIDE.md b/docs/DATASHEET_TOOLS_GUIDE.md new file mode 100644 index 0000000..c161d35 --- /dev/null +++ b/docs/DATASHEET_TOOLS_GUIDE.md @@ -0,0 +1,102 @@ +# 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/.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` diff --git a/docs/FOOTPRINT_SYMBOL_CREATOR_GUIDE.md b/docs/FOOTPRINT_SYMBOL_CREATOR_GUIDE.md new file mode 100644 index 0000000..2c0e285 --- /dev/null +++ b/docs/FOOTPRINT_SYMBOL_CREATOR_GUIDE.md @@ -0,0 +1,411 @@ +# 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` diff --git a/docs/FREEROUTING_GUIDE.md b/docs/FREEROUTING_GUIDE.md new file mode 100644 index 0000000..18dc73c --- /dev/null +++ b/docs/FREEROUTING_GUIDE.md @@ -0,0 +1,226 @@ +# 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` diff --git a/docs/INDEX.md b/docs/INDEX.md new file mode 100644 index 0000000..9e16e64 --- /dev/null +++ b/docs/INDEX.md @@ -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). diff --git a/docs/IPC_BACKEND_STATUS.md b/docs/IPC_BACKEND_STATUS.md index 2de8fd9..fd0ed1e 100644 --- a/docs/IPC_BACKEND_STATUS.md +++ b/docs/IPC_BACKEND_STATUS.md @@ -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+ --- @@ -209,4 +209,4 @@ python/ --- -**Last Updated:** 2025-12-02 +**Last Updated:** 2026-03-21 diff --git a/docs/JLCPCB_USAGE_GUIDE.md b/docs/JLCPCB_USAGE_GUIDE.md index 55bb8ac..db4118d 100644 --- a/docs/JLCPCB_USAGE_GUIDE.md +++ b/docs/JLCPCB_USAGE_GUIDE.md @@ -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) diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index 0eaf965..615920f 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -1,203 +1,173 @@ -# Known Issues & Workarounds - -**Last Updated:** 2025-12-02 -**Version:** 2.1.0-alpha - -This document tracks known issues and provides workarounds where available. - ---- - -## Current Issues - -### 1. `get_board_info` KiCAD 9.0 API Issue - -**Status:** KNOWN - Non-critical - -**Symptoms:** -``` -AttributeError: 'BOARD' object has no attribute 'LT_USER' -``` - -**Root Cause:** KiCAD 9.0 changed layer enumeration constants - -**Workaround:** Use `get_project_info` instead for basic project details - -**Impact:** Low - informational command only - ---- - -### 2. Zone Filling via SWIG Causes Segfault - -**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 - -**Impact:** Medium - affects copper pour visualization until opened in KiCAD - ---- - -### 3. UI Manual Reload Required (SWIG Backend) - -**Status:** BY DESIGN - Fixed by IPC - -**Symptoms:** -- MCP makes changes via SWIG backend -- KiCAD doesn't 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 requires file I/O, can't push changes to running UI - -**Fix:** Use IPC backend for real-time updates (requires KiCAD to be running with IPC enabled) - -**Workaround:** Click reload prompt or use File > Revert - ---- - -### 4. IPC Backend Experimental - -**Status:** UNDER DEVELOPMENT - -**Description:** -The IPC backend is currently being implemented and tested. Some commands may not work as expected in all scenarios. - -**Known IPC Limitations:** -- KiCAD must be running with IPC enabled -- 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 - ---- - ---- - -## Recently Fixed - -### Schematic Component Corruption (Fixed 2026-02-26) - -**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 - -### DRC Violations API KiCAD 9.0 (Fixed 2026-02-26) - -**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 - -### Component Library Integration (Fixed 2025-11-01) - -**Was:** Could not find footprint libraries -**Now:** Auto-discovers 153 KiCAD footprint libraries, search and list working - -### Routing Operations KiCAD 9.0 (Fixed 2025-11-01) - -**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` - -### KiCAD Process Detection (Fixed 2025-10-26) - -**Was:** `check_kicad_ui` detected MCP server's own processes -**Now:** Properly filters to only detect actual KiCAD binaries - -### set_board_size KiCAD 9.0 (Fixed 2025-10-26) - -**Was:** Failed with `BOX2I_SetSize` type error -**Now:** Works with KiCAD 9.0 API - -### add_board_text KiCAD 9.0 (Fixed 2025-10-26) - -**Was:** Failed with `EDA_ANGLE` type error -**Now:** Works with KiCAD 9.0 API - -### Schematic Parameter Mismatch (Fixed 2025-12-02) - -**Was:** `create_schematic` failed due to parameter name differences between TypeScript and Python -**Now:** Accepts multiple parameter naming conventions (`name`, `projectName`, `title`, `filename`) - ---- - -## Reporting New Issues - -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 - - 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 | - ---- - -## General Workarounds - -### Server Won't Start -```bash -# Check Python can import pcbnew -python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())" - -# Check paths -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) -``` -# File > Revert (or click reload prompt) -# Or: Close and reopen file in KiCAD -# Or: Use IPC backend for automatic updates -``` - -### IPC Not Connecting -``` -# Ensure KiCAD is running -# Enable IPC: Preferences > Plugins > Enable IPC API Server -# Have a board open in PCB editor -# Check socket exists: ls /tmp/kicad/api.sock -``` - ---- - -**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 +# Known Issues & Workarounds + +**Last Updated:** 2026-03-21 +**Version:** 2.2.3 + +This document tracks known issues and provides workarounds where available. + +--- + +## Current Issues + +### 1. `get_board_info` KiCAD 9.0 API Issue + +**Status:** KNOWN - Non-critical + +**Symptoms:** +``` +AttributeError: 'BOARD' object has no attribute 'LT_USER' +``` + +**Root Cause:** KiCAD 9.0 changed layer enumeration constants + +**Workaround:** Use `get_project_info` instead for basic project details + +**Impact:** Low - informational command only + +--- + +### 2. Zone Filling via SWIG Causes Segfault + +**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 +3. Use `refill_zones` tool (may still segfault in some configurations) + +**Impact:** Medium - affects copper pour visualization until opened in KiCAD + +--- + +### 3. UI Manual Reload Required (SWIG Backend) + +**Status:** BY DESIGN + +**Symptoms:** +- MCP makes changes via SWIG backend +- KiCAD does not show changes until file is reloaded + +**Why:** SWIG-based backend modifies files directly and cannot push changes to a running UI + +**Fix:** Use IPC backend for real-time updates (requires KiCAD running with IPC enabled) + +**Workaround:** Click the reload prompt in KiCAD or use File > Revert + +--- + +### 4. IPC Backend Limitations + +**Status:** EXPERIMENTAL + +**Known Limitations:** +- 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) + +**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 (v2.2.0 - v2.2.3) + +### B.Cu Footprint Routing (Fixed v2.2.3) +- `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()` + +### B.Cu Placement Hang (Fixed v2.2.3) +- Placing footprints on B.Cu no longer causes ~30s freeze +- Fix: call `board.Add()` before `Flip()` + +### Board Outline Rounded Corners (Fixed v2.2.3) +- `add_board_outline` now correctly applies cornerRadius for rounded_rectangle shape + +### Project-Local Library Resolution (Fixed v2.2.2) +- `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 + +### Template File Corruption (Fixed v2.2.2) +- Removed invalid `;;` comment lines from template schematics +- Restored KiCAD 9 format version (20250114) in templates + +### copy_routing_pattern Empty Results (Fixed v2.2.2) +- Added geometric fallback when pads have no net assignments + +### Schematic Component Corruption (Fixed v2.2.1) +- `add_schematic_component` no longer corrupts .kicad_sch files +- Rewritten to use text manipulation instead of sexpdata formatting + +### SWIG/UUID Comparison Bugs (Fixed v2.2.0) +- Fixed SwigPyObject UUID comparison +- Fixed SWIG iterator invalidation after board.Remove() +- Added board.SetModified() to prevent dangling pointer crashes + +--- + +## Reporting New Issues + +If you encounter an issue not listed here: + +1. **Check MCP logs:** `~/.kicad-mcp/logs/kicad_interface.log` +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 and OS + - MCP session log (from `logs/` folder if dev mode is enabled) + +--- + +## General Workarounds + +### Server Will Not Start +```bash +# Check Python can import pcbnew +python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())" + +# Check paths +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 Does Not Show Changes (SWIG Mode) +``` +# File > Revert (or click reload prompt) +# Or: Close and reopen file in KiCAD +# Or: Use IPC backend for automatic updates +``` + +### IPC Not Connecting +``` +# Ensure KiCAD is running +# Enable IPC: Preferences > Plugins > Enable IPC API Server +# Have a board open in PCB editor +# Check socket exists: ls /tmp/kicad/api.sock +``` + +--- + +**Need Help?** +- Check [IPC_BACKEND_STATUS.md](IPC_BACKEND_STATUS.md) for IPC details +- Check logs: `~/.kicad-mcp/logs/kicad_interface.log` +- Open an issue on GitHub diff --git a/docs/LIBRARY_INTEGRATION.md b/docs/LIBRARY_INTEGRATION.md index d378de3..c23b8b5 100644 --- a/docs/LIBRARY_INTEGRATION.md +++ b/docs/LIBRARY_INTEGRATION.md @@ -1,27 +1,37 @@ -# 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 @@ -337,7 +347,9 @@ 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 diff --git a/docs/PCB_DESIGN_WORKFLOW.md b/docs/PCB_DESIGN_WORKFLOW.md new file mode 100644 index 0000000..05424b3 --- /dev/null +++ b/docs/PCB_DESIGN_WORKFLOW.md @@ -0,0 +1,304 @@ +# 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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 994c71c..a1f6347 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,314 +1,114 @@ -# KiCAD MCP Roadmap - -**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 - ---- - -## Week 2: Component Integration & Routing - -**Goal:** Make the MCP server useful for real PCB design -**Status:** 80% Complete (2025-11-01) - -### High Priority - -**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 - -**Deliverable:** ✅ Place components with actual footprints from libraries (153 libraries discovered!) - -**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 - -**Deliverable:** ✅ Successfully route a simple board (tested with nets, traces, vias, copper pours) - -**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 - -**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) - ---- - -## Week 3: IPC Backend & Real-time Updates - -**Goal:** Eliminate manual reload - see changes instantly -**Status:** 🟢 **IMPLEMENTED** (2025-11-30) - -### High Priority - -**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 - -**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 - ---- - -## Week 4-5: Smart BOM & Supplier Integration - -**Goal:** Optimize component selection for cost and availability - -**1. Digikey Integration** -- [ ] API authentication -- [ ] Part search by specs -- [ ] Price/stock checking -- [ ] Parametric search (e.g., "10k resistor, 0603, 1%") - -**2. Smart BOM Management** -- [ ] Auto-suggest component substitutions -- [ ] Calculate total board cost -- [ ] Check component availability -- [ ] Generate purchase links - -**3. Cost Optimization** -- [ ] Suggest JLCPCB basic parts (free assembly) -- [ ] Warn about expensive/obsolete parts -- [ ] Batch component suggestions - -**Deliverable:** "Design a low-cost LED driver under $5 BOM" - ---- - -## Week 6-7: Design Patterns & Templates - -**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 -- [ ] 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? - -**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 - -Check [CONTRIBUTING.md](../CONTRIBUTING.md) for details. - ---- - -**Last Updated:** 2025-11-30 -**Maintained by:** KiCAD MCP Team +# KiCAD MCP Roadmap + +**Vision:** Enable anyone to design professional PCBs through natural conversation with AI + +**Current Version:** 2.2.3 +**Last Updated:** 2026-03-21 + +--- + +## Completed Milestones + +### v1.0.0 - Core Foundation (October 2025) +- [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 + +### v2.0.0-alpha - Router and IPC (November-December 2025) +- [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 + +### v2.1.0-alpha - Schematics and JLCPCB (January 2026) +- [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) + +### v2.2.0 through v2.2.3 - Routing, Creators, Autorouting (February-March 2026) +- [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) + +--- + +## Current Focus: v2.3+ + +### Documentation Overhaul (In Progress) +- [ ] Per-feature documentation for all 122 tools +- [ ] Architecture guide for contributors +- [ ] End-to-end PCB design workflow guide +- [ ] Documentation index + +### Quality and Stability +- [ ] Expand test coverage across all tool categories +- [ ] Performance profiling for large boards +- [ ] Update package.json version to match CHANGELOG + +--- + +## Planned Features + +### Supplier Integration +- [ ] Digikey API integration +- [ ] Mouser API integration +- [ ] Smart BOM management with real-time pricing +- [ ] Cost optimization across suppliers + +### Design Patterns and Templates +- [ ] 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 + +### Advanced Capabilities +- [ ] Panelization support +- [ ] Multi-board project management +- [ ] High-speed design helpers (length matching, via stitching) +- [ ] SPICE simulation integration + +### Community and Education +- [ ] Example project gallery with tutorials +- [ ] Video walkthrough series +- [ ] Interactive beginner tutorials +- [ ] Plugin system for custom tools + +--- + +## How to Contribute + +See the roadmap items above and want to help? High-value contributions: + +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. + +--- + +*Maintained by: KiCAD MCP Team and community contributors* diff --git a/docs/ROUTER_ARCHITECTURE.md b/docs/ROUTER_ARCHITECTURE.md index fef3fff..ba98dac 100644 --- a/docs/ROUTER_ARCHITECTURE.md +++ b/docs/ROUTER_ARCHITECTURE.md @@ -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,7 +68,7 @@ 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. diff --git a/docs/ROUTER_QUICK_START.md b/docs/ROUTER_QUICK_START.md index 64656be..db5e6b1 100644 --- a/docs/ROUTER_QUICK_START.md +++ b/docs/ROUTER_QUICK_START.md @@ -2,7 +2,7 @@ ## 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 @@ -20,7 +20,7 @@ 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 @@ -154,15 +154,12 @@ For implementation details, see: ## Token Savings **Before Router:** -- 59 tools × ~700 tokens each = ~42K tokens per conversation +- 122 tools × ~700 tokens each = ~85K tokens per conversation -**After Router (Current - Phase 1):** +**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. diff --git a/docs/ROUTING_TOOLS_REFERENCE.md b/docs/ROUTING_TOOLS_REFERENCE.md new file mode 100644 index 0000000..b332295 --- /dev/null +++ b/docs/ROUTING_TOOLS_REFERENCE.md @@ -0,0 +1,530 @@ +# 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` diff --git a/docs/SCHEMATIC_TOOLS_REFERENCE.md b/docs/SCHEMATIC_TOOLS_REFERENCE.md new file mode 100644 index 0000000..54f0dad --- /dev/null +++ b/docs/SCHEMATIC_TOOLS_REFERENCE.md @@ -0,0 +1,303 @@ +# 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. + +| 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 | Yes | Position [x, y] for the label | + +### connect_to_net +Connect a component pin to a named net. + +| 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 | + +**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.54mm (0.1 inch, standard grid spacing). + +### 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. Use this before add_schematic_net_label to place labels correctly on pin endpoints. + +| 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 (4 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 | + +## Schematic Creation and Export (5 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 +Generate a netlist from the schematic. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| schematicPath | string | Yes | Path to the schematic file | + +**Usage Notes:** Returns a complete netlist with component information (reference, value, footprint) and net connections (net name with all connected component/pin pairs). + +## Validation and Synchronization (3 tools) + +### 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) diff --git a/docs/STATUS_SUMMARY.md b/docs/STATUS_SUMMARY.md index 91e4450..b46e076 100644 --- a/docs/STATUS_SUMMARY.md +++ b/docs/STATUS_SUMMARY.md @@ -1,287 +1,158 @@ -# KiCAD MCP - Current Status Summary - -**Date:** 2025-12-02 -**Version:** 2.1.0-alpha -**Phase:** IPC Backend Implementation and Testing - ---- - -## 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 - ---- - -## 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 | - ---- - -## Developer Setup Status - -### 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 - -### Windows - Supported -- Automated setup script (`setup-windows.ps1`) -- Process detection implemented -- Library paths auto-detected -- Comprehensive error diagnostics -- Startup validation with helpful errors -- Troubleshooting guide (WINDOWS_TROUBLESHOOTING.md) - -### macOS - Untested -- Configuration provided -- Process detection implemented -- Library paths configured -- Needs community testing - ---- - -## Documentation Status - -### 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 - -### Needed -- [ ] EXAMPLE_PROJECTS.md -- [ ] CONTRIBUTING.md -- [ ] API_REFERENCE.md - ---- - -## What's Next? - -### 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) - ---- - -## 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 - -**Issues:** -- Open an issue on GitHub with OS, KiCAD version, and error details - ---- - -*Last Updated: 2025-12-02* -*Maintained by: KiCAD MCP Team* +# KiCAD MCP - Current Status Summary + +**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 | +|--------|-------| +| 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 | 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 | + +--- + +## 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 +- KiCAD 9.0 detection: Working +- Process management: Working +- Library discovery: Working (153+ libraries) +- IPC backend: Working + +### Windows -- Fully Supported +- Automated setup script (setup-windows.ps1) +- Process detection via Toolhelp32 API +- Library paths auto-detected +- Troubleshooting guide available (WINDOWS_TROUBLESHOOTING.md) + +### macOS -- Community Supported +- Configuration provided +- Process detection implemented +- Library paths configured +- Needs community testing + +--- + +## Recent Development Highlights + +### v2.2.3 (2026-03-11) +- 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) + +--- + +## Community Contributors + +| 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 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: 2026-03-21* diff --git a/docs/SVG_IMPORT_GUIDE.md b/docs/SVG_IMPORT_GUIDE.md new file mode 100644 index 0000000..5752776 --- /dev/null +++ b/docs/SVG_IMPORT_GUIDE.md @@ -0,0 +1,113 @@ +# 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` diff --git a/docs/TOOL_INVENTORY.md b/docs/TOOL_INVENTORY.md index fa34f38..f71a7e3 100644 --- a/docs/TOOL_INVENTORY.md +++ b/docs/TOOL_INVENTORY.md @@ -1,115 +1,343 @@ -# KiCAD MCP Server - Tool Inventory - -**Total Tools: 59** -**Token Impact: ~40K+ tokens before any user interaction** - -## Current Tool Categories - -### 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 - -### 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 - -### 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) - -### 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 - -### 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 - -### 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 - -### UI Management (2 tools) -- `check_kicad_ui` - Check if KiCAD UI is running -- `launch_kicad_ui` - Launch KiCAD UI - -## Router Implementation Plan - -### 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` - -### Routed Tools (Hidden) - 47 tools -Organized into categories for discovery. - -## Expected Impact -**Before Router**: 59 tools = ~40K+ tokens -**After Router**: 16 tools (12 direct + 4 router) = ~12K tokens -**Savings**: ~28K tokens (70% reduction) +# KiCAD MCP Server - Complete Tool Inventory + +**Version:** 2.2.3 +**Total Tools:** 122 (18 direct + 65 routed + 4 router + 35 additional) +**Last Updated:** 2026-03-21 + +## How Tools Are Organized + +The server uses a **router pattern** to reduce AI context usage. Tools fall into three groups: + +- **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. + +--- + +## Project Management (5 tools) + +*Source: `src/tools/project.ts`* + +| 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 | + +--- + +## Board Management (12 tools) + +*Source: `src/tools/board.ts`* + +| 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 | + +--- + +## Component Management (16 tools) + +*Source: `src/tools/component.ts`* + +| 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 diff --git a/docs/BUILD_AND_TEST_SESSION.md b/docs/archive/BUILD_AND_TEST_SESSION.md similarity index 100% rename from docs/BUILD_AND_TEST_SESSION.md rename to docs/archive/BUILD_AND_TEST_SESSION.md diff --git a/docs/DYNAMIC_LIBRARY_LOADING_PLAN.md b/docs/archive/DYNAMIC_LIBRARY_LOADING_PLAN.md similarity index 100% rename from docs/DYNAMIC_LIBRARY_LOADING_PLAN.md rename to docs/archive/DYNAMIC_LIBRARY_LOADING_PLAN.md diff --git a/docs/DYNAMIC_LOADING_STATUS.md b/docs/archive/DYNAMIC_LOADING_STATUS.md similarity index 100% rename from docs/DYNAMIC_LOADING_STATUS.md rename to docs/archive/DYNAMIC_LOADING_STATUS.md diff --git a/docs/IPC_API_MIGRATION_PLAN.md b/docs/archive/IPC_API_MIGRATION_PLAN.md similarity index 100% rename from docs/IPC_API_MIGRATION_PLAN.md rename to docs/archive/IPC_API_MIGRATION_PLAN.md diff --git a/docs/JLCPCB_INTEGRATION_PLAN.md b/docs/archive/JLCPCB_INTEGRATION_PLAN.md similarity index 100% rename from docs/JLCPCB_INTEGRATION_PLAN.md rename to docs/archive/JLCPCB_INTEGRATION_PLAN.md diff --git a/docs/archive/README.md b/docs/archive/README.md new file mode 100644 index 0000000..bddc8da --- /dev/null +++ b/docs/archive/README.md @@ -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 diff --git a/docs/ROUTER_IMPLEMENTATION_STATUS.md b/docs/archive/ROUTER_IMPLEMENTATION_STATUS.md similarity index 100% rename from docs/ROUTER_IMPLEMENTATION_STATUS.md rename to docs/archive/ROUTER_IMPLEMENTATION_STATUS.md diff --git a/docs/SCHEMATIC_WIRING_PLAN.md b/docs/archive/SCHEMATIC_WIRING_PLAN.md similarity index 100% rename from docs/SCHEMATIC_WIRING_PLAN.md rename to docs/archive/SCHEMATIC_WIRING_PLAN.md diff --git a/docs/SCHEMATIC_WORKFLOW_FIX.md b/docs/archive/SCHEMATIC_WORKFLOW_FIX.md similarity index 100% rename from docs/SCHEMATIC_WORKFLOW_FIX.md rename to docs/archive/SCHEMATIC_WORKFLOW_FIX.md diff --git a/docs/WEEK1_SESSION1_SUMMARY.md b/docs/archive/WEEK1_SESSION1_SUMMARY.md similarity index 100% rename from docs/WEEK1_SESSION1_SUMMARY.md rename to docs/archive/WEEK1_SESSION1_SUMMARY.md diff --git a/docs/WEEK1_SESSION2_SUMMARY.md b/docs/archive/WEEK1_SESSION2_SUMMARY.md similarity index 100% rename from docs/WEEK1_SESSION2_SUMMARY.md rename to docs/archive/WEEK1_SESSION2_SUMMARY.md From d3ef768ad091393f2c088429a1d1aa57fedace0f Mon Sep 17 00:00:00 2001 From: Cabbache Date: Sat, 21 Mar 2026 23:18:30 +0100 Subject: [PATCH 02/74] KiCAD 10 support --- python/commands/dynamic_symbol_loader.py | 9 ++++++++- python/commands/library.py | 19 +++++++++++++++++-- python/commands/library_symbol.py | 22 ++++++++++++++++++++-- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/python/commands/dynamic_symbol_loader.py b/python/commands/dynamic_symbol_loader.py index 96ede68..96f0df2 100644 --- a/python/commands/dynamic_symbol_loader.py +++ b/python/commands/dynamic_symbol_loader.py @@ -41,10 +41,12 @@ 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])) @@ -97,6 +99,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", diff --git a/python/commands/library.py b/python/commands/library.py index abf30d0..ef50aee 100644 --- a/python/commands/library.py +++ b/python/commands/library.py @@ -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", ] @@ -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) @@ -126,10 +139,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(), } diff --git a/python/commands/library_symbol.py b/python/commands/library_symbol.py index 82a7c7e..4de88f8 100644 --- a/python/commands/library_symbol.py +++ b/python/commands/library_symbol.py @@ -77,13 +77,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", ] @@ -109,11 +112,21 @@ class SymbolLibraryManager: # 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,9 +155,11 @@ class SymbolLibraryManager: # Common KiCAD environment variables env_vars = { + '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(), @@ -198,11 +213,14 @@ 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 '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: From 764b8db3d396e54ebae2c58c5da561ee7c07a251 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sat, 14 Mar 2026 20:39:49 +0000 Subject: [PATCH 03/74] feat: add schematic analysis tools (read-only) Add five new read-only schematic analysis MCP tools: - get_schematic_view_region: export cropped schematic region as PNG/SVG - find_unconnected_pins: list pins with no wire/label/power connection - find_overlapping_elements: detect duplicate symbols, stacked labels, collinear wire overlaps - get_elements_in_region: list all symbols/wires/labels in a bounding box - check_wire_collisions: detect wires passing through component bodies Includes Python handler dispatch, tool schemas, TypeScript server bindings, the schematic_analysis command module, and a full test suite (28 tests passing). Co-Authored-By: Claude Sonnet 4.6 --- python/commands/schematic_analysis.py | 623 ++++++++++++++++++++++++ python/kicad_interface.py | 205 ++++++++ python/schemas/tool_schemas.py | 125 +++++ python/tests/__init__.py | 0 python/tests/conftest.py | 67 ++- python/tests/test_schematic_analysis.py | 398 +++++++++++++++ src/tools/schematic.ts | 183 +++++++ 7 files changed, 1575 insertions(+), 26 deletions(-) create mode 100644 python/commands/schematic_analysis.py create mode 100644 python/tests/__init__.py create mode 100644 python/tests/test_schematic_analysis.py diff --git a/python/commands/schematic_analysis.py b/python/commands/schematic_analysis.py new file mode 100644 index 0000000..8fa58e5 --- /dev/null +++ b/python/commands/schematic_analysis.py @@ -0,0 +1,623 @@ +""" +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 pathlib import Path +from typing import Dict, List, Tuple, Optional, Any, Set + +import sexpdata +from sexpdata import Symbol + +from commands.pin_locator import PinLocator + +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, 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 + + 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("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, + "is_power": is_power, + }) + return symbols + + +def _parse_no_connects(sexp_data: list) -> Set[Tuple[float, float]]: + """Parse all no_connect elements and return their positions as (x, y) tuples in mm.""" + positions: Set[Tuple[float, float]] = set() + for item in sexp_data: + if not isinstance(item, list) or len(item) < 2: + continue + if item[0] != Symbol("no_connect"): + continue + for sub in item: + if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("at"): + positions.add((float(sub[1]), float(sub[2]))) + break + return positions + + +# --------------------------------------------------------------------------- +# 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) + + +# --------------------------------------------------------------------------- +# Tool 2: find_unconnected_pins +# --------------------------------------------------------------------------- + +def find_unconnected_pins(schematic_path: Path) -> List[Dict[str, Any]]: + """ + Find all component pins with no wire, label, or power symbol touching them. + + Returns list of dicts: {reference, libId, pinNumber, pinName, position: {x, y}} + """ + sexp_data = _load_sexp(schematic_path) + symbols = _parse_symbols(sexp_data) + wires = _parse_wires(sexp_data) + labels = _parse_labels(sexp_data) + no_connects = _parse_no_connects(sexp_data) + + # Build set of "connected" positions in mm + connected: Set[Tuple[float, float]] = set() + + # Wire endpoints + for w in wires: + connected.add(w["start"]) + connected.add(w["end"]) + + # Label positions + for lbl in labels: + connected.add((lbl["x"], lbl["y"])) + + # Power symbol positions (they implicitly connect) + for sym in symbols: + if sym["is_power"]: + connected.add((sym["x"], sym["y"])) + + tolerance = 0.05 # mm + + def _snap(v: float) -> int: + """Snap coordinate to grid for O(1) set lookup.""" + return round(v / tolerance) + + connected_grid: set = set() + for pos in connected: + connected_grid.add((_snap(pos[0]), _snap(pos[1]))) + + no_connect_grid: set = set() + for pos in no_connects: + no_connect_grid.add((_snap(pos[0]), _snap(pos[1]))) + + def is_connected(px: float, py: float) -> bool: + sx, sy = _snap(px), _snap(py) + # Check the snapped cell and immediate neighbors to handle edge cases + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + if (sx + dx, sy + dy) in connected_grid: + return True + return False + + def is_no_connect(px: float, py: float) -> bool: + sx, sy = _snap(px), _snap(py) + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + if (sx + dx, sy + dy) in no_connect_grid: + return True + return False + + locator = PinLocator() + unconnected = [] + + for sym in symbols: + ref = sym["reference"] + # Skip power symbols, templates, and empty references + if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref: + continue + + pin_positions = locator.get_all_symbol_pins(schematic_path, ref) + if not pin_positions: + continue + + pin_defs = None + + for pin_num, pos in pin_positions.items(): + px, py = pos[0], pos[1] + + if is_no_connect(px, py): + continue + if is_connected(px, py): + continue + + if pin_defs is None: + pin_defs = locator.get_symbol_pins(schematic_path, sym["lib_id"]) + + pin_name = pin_defs.get(pin_num, {}).get("name", pin_num) + unconnected.append({ + "reference": ref, + "libId": sym["lib_id"], + "pinNumber": pin_num, + "pinName": pin_name, + "position": {"x": round(px, 4), "y": round(py, 4)}, + }) + + return unconnected + + +# --------------------------------------------------------------------------- +# 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 in mm below which elements are considered overlapping + + 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 = [] + + # --- Symbol-symbol overlap (O(n²)) --- + non_template_symbols = [s for s in symbols if not s["reference"].startswith("_TEMPLATE") and s["reference"]] + for i in range(len(non_template_symbols)): + for j in range(i + 1, len(non_template_symbols)): + s1 = non_template_symbols[i] + s2 = non_template_symbols[j] + dist = _distance((s1["x"], s1["y"]), (s2["x"], s2["y"])) + if dist < tolerance: + 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. + + Returns overlap info dict or None. + """ + s1, e1 = w1["start"], w1["end"] + s2, e2 = w2["start"], w2["end"] + + # Check horizontal collinearity + if abs(s1[1] - e1[1]) < tolerance and abs(s2[1] - e2[1]) < tolerance: + if abs(s1[1] - s2[1]) < tolerance: + # Both horizontal, same Y + min1, max1 = min(s1[0], e1[0]), max(s1[0], e1[0]) + min2, max2 = min(s2[0], e2[0]), max(s2[0], e2[0]) + 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", + } + + # Check vertical collinearity + if abs(s1[0] - e1[0]) < tolerance and abs(s2[0] - e2[0]) < tolerance: + if abs(s1[0] - s2[0]) < tolerance: + # Both vertical, same X + min1, max1 = min(s1[1], e1[1]), max(s1[1], e1[1]) + min2, max2 = min(s2[1], e2[1]), max(s2[1], e2[1]) + 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) + + locator = PinLocator() + + # 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 + pin_positions = locator.get_all_symbol_pins(schematic_path, sym["reference"]) + 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 endpoint is within bounds + 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)): + 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 check_wire_collisions(schematic_path: Path) -> List[Dict[str, Any]]: + """ + Detect wires passing through component bodies without connecting to their pins. + + 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 but no wire endpoint matches a pin → collision. + + Returns list of collision dicts. + """ + sexp_data = _load_sexp(schematic_path) + symbols = _parse_symbols(sexp_data) + wires = _parse_wires(sexp_data) + + locator = PinLocator() + 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 = [] + for sym in symbols: + ref = sym["reference"] + if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref: + continue + + bbox = compute_symbol_bbox(schematic_path, ref, locator) + if bbox is None: + continue + + min_x, min_y, max_x, max_y = bbox + + # Expand degenerate dimensions (pins in a line) to approximate body size + 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 (single-pin or very small after shrink) + if max_x <= min_x or max_y <= min_y: + continue + + pin_positions = locator.get_all_symbol_pins(schematic_path, ref) + pin_set = set() + for pos in pin_positions.values(): + pin_set.add((pos[0], pos[1])) + + symbol_data.append({ + "sym": sym, + "bbox": (min_x, min_y, max_x, max_y), + "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 if either wire endpoint matches a pin of this symbol + endpoint_matches_pin = False + for px, py in sd["pin_set"]: + if (abs(sx - px) < pin_tolerance and abs(sy - py) < pin_tolerance): + endpoint_matches_pin = True + break + if (abs(ex - px) < pin_tolerance and abs(ey - py) < pin_tolerance): + endpoint_matches_pin = True + break + + if not endpoint_matches_pin: + 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 diff --git a/python/kicad_interface.py b/python/kicad_interface.py index e99654b..5166e8d 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -398,6 +398,12 @@ class KiCADInterface: "delete_schematic_net_label": self._handle_delete_schematic_net_label, "export_schematic_pdf": self._handle_export_schematic_pdf, "export_schematic_svg": self._handle_export_schematic_svg, + # Schematic analysis tools (read-only) + "get_schematic_view_region": self._handle_get_schematic_view_region, + "find_unconnected_pins": self._handle_find_unconnected_pins, + "find_overlapping_elements": self._handle_find_overlapping_elements, + "get_elements_in_region": self._handle_get_elements_in_region, + "check_wire_collisions": self._handle_check_wire_collisions, "import_svg_logo": self._handle_import_svg_logo, # UI/Process management commands "check_kicad_ui": self._handle_check_kicad_ui, @@ -2557,6 +2563,205 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} + # =================================================================== + # Schematic analysis tools (read-only) + # =================================================================== + + def _handle_get_schematic_view_region(self, params): + """Export a cropped region of the schematic as an image""" + logger.info("Exporting schematic view region") + import subprocess + import tempfile + import os + import base64 + + try: + schematic_path = params.get("schematicPath") + if not schematic_path or not os.path.exists(schematic_path): + return {"success": False, "message": "Schematic file not found"} + + x1 = float(params.get("x1", 0)) + y1 = float(params.get("y1", 0)) + x2 = float(params.get("x2", 297)) + y2 = float(params.get("y2", 210)) + out_format = params.get("format", "png") + width = int(params.get("width", 800)) + height = int(params.get("height", 600)) + + kicad_cli = self.design_rule_commands._find_kicad_cli() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found"} + + with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as tmp: + svg_output = tmp.name + + try: + cmd = [kicad_cli, "sch", "export", "svg", "--output", svg_output, schematic_path] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + + if result.returncode != 0: + return {"success": False, "message": f"SVG export failed: {result.stderr}"} + + import xml.etree.ElementTree as ET + tree = ET.parse(svg_output) + root = tree.getroot() + + # Read original viewBox to determine SVG coordinate mapping + ns = {"svg": "http://www.w3.org/2000/svg"} + vb = root.get("viewBox", "") + if vb: + parts = vb.split() + if len(parts) == 4: + # KiCad SVG viewBox is in mils (1 mil = 0.0254 mm) + # or internal units. Detect scale from original viewBox. + orig_vb_x = float(parts[0]) + orig_vb_y = float(parts[1]) + orig_vb_w = float(parts[2]) + orig_vb_h = float(parts[3]) + + # KiCad schematic SVGs use mils (1/1000 inch) as user units + # 1 mm = 39.3701 mils + mils_per_mm = 39.3701 + + new_x = orig_vb_x + x1 * mils_per_mm + new_y = orig_vb_y + y1 * mils_per_mm + new_w = (x2 - x1) * mils_per_mm + new_h = (y2 - y1) * mils_per_mm + + root.set("viewBox", f"{new_x} {new_y} {new_w} {new_h}") + root.set("width", str(width)) + root.set("height", str(height)) + + # Write modified SVG + cropped_svg_path = svg_output + ".cropped.svg" + tree.write(cropped_svg_path, xml_declaration=True, encoding="utf-8") + + if out_format == "svg": + with open(cropped_svg_path, "r", encoding="utf-8") as f: + svg_data = f.read() + return {"success": True, "imageData": svg_data, "format": "svg"} + else: + try: + from cairosvg import svg2png + except ImportError: + return {"success": False, "message": "PNG export requires the 'cairosvg' package. Install it with: pip install cairosvg"} + png_data = svg2png(url=cropped_svg_path, output_width=width, output_height=height) + return { + "success": True, + "imageData": base64.b64encode(png_data).decode("utf-8"), + "format": "png", + } + finally: + for f in [svg_output, svg_output + ".cropped.svg"]: + if os.path.exists(f): + os.unlink(f) + + except Exception as e: + logger.error(f"Error in get_schematic_view_region: {e}") + import traceback + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_find_unconnected_pins(self, params): + """Find all component pins with no wire, label, or power symbol touching them""" + logger.info("Finding unconnected pins in schematic") + try: + from pathlib import Path + from commands.schematic_analysis import find_unconnected_pins + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + + result = find_unconnected_pins(Path(schematic_path)) + return { + "success": True, + "unconnectedPins": result, + "count": len(result), + "message": f"Found {len(result)} unconnected pin(s)", + } + except Exception as e: + logger.error(f"Error finding unconnected pins: {e}") + import traceback + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_find_overlapping_elements(self, params): + """Detect spatially overlapping symbols, wires, and labels""" + logger.info("Finding overlapping elements in schematic") + try: + from pathlib import Path + from commands.schematic_analysis import find_overlapping_elements + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + + tolerance = float(params.get("tolerance", 0.5)) + result = find_overlapping_elements(Path(schematic_path), tolerance) + return { + "success": True, + **result, + "message": f"Found {result['totalOverlaps']} overlap(s)", + } + except Exception as e: + logger.error(f"Error finding overlapping elements: {e}") + import traceback + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_get_elements_in_region(self, params): + """List all wires, labels, and symbols within a rectangular region""" + logger.info("Getting elements in schematic region") + try: + from pathlib import Path + from commands.schematic_analysis import get_elements_in_region + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + + x1 = float(params.get("x1", 0)) + y1 = float(params.get("y1", 0)) + x2 = float(params.get("x2", 0)) + y2 = float(params.get("y2", 0)) + + result = get_elements_in_region(Path(schematic_path), x1, y1, x2, y2) + return { + "success": True, + **result, + "message": f"Found {result['counts']['symbols']} symbols, {result['counts']['wires']} wires, {result['counts']['labels']} labels in region", + } + except Exception as e: + logger.error(f"Error getting elements in region: {e}") + import traceback + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_check_wire_collisions(self, params): + """Detect wires passing through component bodies without connecting to their pins""" + logger.info("Checking wire collisions in schematic") + try: + from pathlib import Path + from commands.schematic_analysis import check_wire_collisions + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + + result = check_wire_collisions(Path(schematic_path)) + return { + "success": True, + "collisions": result, + "count": len(result), + "message": f"Found {len(result)} wire collision(s)", + } + except Exception as e: + logger.error(f"Error checking wire collisions: {e}") + import traceback + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + def _handle_import_svg_logo(self, params): """Import an SVG file as PCB graphic polygons on the silkscreen""" logger.info("Importing SVG logo into PCB") diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index dffe3d7..4e6290c 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1680,6 +1680,131 @@ SCHEMATIC_TOOLS = [ }, "required": ["schematicPath", "outputPath"] } + }, + # --- Schematic Analysis Tools (read-only) --- + { + "name": "get_schematic_view_region", + "title": "Get Schematic View Region", + "description": "Exports a cropped region of the schematic as an image (PNG or SVG). Specify a bounding box in schematic mm coordinates to zoom into a specific area.", + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the .kicad_sch schematic file" + }, + "x1": { + "type": "number", + "description": "Left X coordinate of the region in mm" + }, + "y1": { + "type": "number", + "description": "Top Y coordinate of the region in mm" + }, + "x2": { + "type": "number", + "description": "Right X coordinate of the region in mm" + }, + "y2": { + "type": "number", + "description": "Bottom Y coordinate of the region in mm" + }, + "format": { + "type": "string", + "enum": ["png", "svg"], + "description": "Output image format (default: png)" + }, + "width": { + "type": "integer", + "description": "Output image width in pixels (default: 800)" + }, + "height": { + "type": "integer", + "description": "Output image height in pixels (default: 600)" + } + }, + "required": ["schematicPath", "x1", "y1", "x2", "y2"] + } + }, + { + "name": "find_unconnected_pins", + "title": "Find Unconnected Pins", + "description": "Lists all component pins in the schematic that have no wire, label, or power symbol touching them. Useful for checking connectivity before running ERC. Skips power symbols, template symbols, and pins with no_connect flags.", + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the .kicad_sch schematic file" + } + }, + "required": ["schematicPath"] + } + }, + { + "name": "find_overlapping_elements", + "title": "Find Overlapping Elements", + "description": "Detects spatially overlapping symbols, wires, and labels in the schematic. Finds: duplicate power symbols at the same position, collinear overlapping wire segments, and labels stacked on top of each other.", + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the .kicad_sch schematic file" + }, + "tolerance": { + "type": "number", + "description": "Distance in mm below which elements are considered overlapping (default: 0.5)" + } + }, + "required": ["schematicPath"] + } + }, + { + "name": "get_elements_in_region", + "title": "Get Elements in Region", + "description": "Lists all symbols, wires, and labels within a rectangular region of the schematic. Useful for understanding what is in a specific area before modifying it.", + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the .kicad_sch schematic file" + }, + "x1": { + "type": "number", + "description": "Left X coordinate of the region in mm" + }, + "y1": { + "type": "number", + "description": "Top Y coordinate of the region in mm" + }, + "x2": { + "type": "number", + "description": "Right X coordinate of the region in mm" + }, + "y2": { + "type": "number", + "description": "Bottom Y coordinate of the region in mm" + } + }, + "required": ["schematicPath", "x1", "y1", "x2", "y2"] + } + }, + { + "name": "check_wire_collisions", + "title": "Check Wire Collisions", + "description": "Detects wires that pass through component bodies without connecting to their pins. These are usually routing mistakes where a wire crosses over a symbol instead of connecting to it. Uses pin-based bounding boxes (approximate but effective for 80/20 detection).", + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the .kicad_sch schematic file" + } + }, + "required": ["schematicPath"] + } } ] diff --git a/python/tests/__init__.py b/python/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/tests/conftest.py b/python/tests/conftest.py index a6c8380..81d3b93 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -1,26 +1,41 @@ -""" -Pytest configuration for python/tests. - -Sets up sys.path so that the python/ package root is importable without -installing the project, and provides shared fixtures. -""" -import sys -from pathlib import Path - -# Make the python/ package root importable -PYTHON_ROOT = Path(__file__).parent.parent -if str(PYTHON_ROOT) not in sys.path: - sys.path.insert(0, str(PYTHON_ROOT)) - -# Stub out heavy KiCAD C-extension modules so tests can run without a real -# KiCAD installation. Extend this list whenever a new import fails. -import types -from unittest.mock import MagicMock - -# Use MagicMock so any attribute access (e.g. pcbnew.BOARD, pcbnew.LoadBoard) -# returns another MagicMock rather than raising AttributeError. -for _stub_name in ("pcbnew", "skip"): - if _stub_name not in sys.modules: - _m = MagicMock(spec_set=None) - _m.__name__ = _stub_name - sys.modules[_stub_name] = _m +""" +Test configuration for python/tests. + +Sets up sys.modules stubs for heavy KiCAD modules (pcbnew, skip) before any +test module can trigger their import, preventing crashes on systems where the +real KiCAD environment is not fully initialised for testing. +""" + +import sys +import types +from unittest.mock import MagicMock + +# --------------------------------------------------------------------------- +# pcbnew stub — kicad_interface.py accesses pcbnew.__file__ and +# pcbnew.GetBuildVersion() at module level. Use MagicMock so that any +# attribute access (pcbnew.BOARD, pcbnew.PCB_TRACK, …) returns a mock +# rather than raising AttributeError. +# --------------------------------------------------------------------------- +_pcbnew = MagicMock(name="pcbnew") +_pcbnew.__file__ = "/fake/pcbnew.cpython-313-x86_64-linux-gnu.so" +_pcbnew.__name__ = "pcbnew" +_pcbnew.__spec__ = None +_pcbnew.GetBuildVersion.return_value = "9.0.0-stub" +sys.modules["pcbnew"] = _pcbnew + +# --------------------------------------------------------------------------- +# Stub: skip (kicad-skip — use real module if available, stub otherwise) +# --------------------------------------------------------------------------- +try: + import skip as _skip_test # noqa: F401 — try importing real skip +except ImportError: + skip_mod = types.ModuleType("skip") + + class _FakeSchematic: + """Minimal stand-in for skip.Schematic used in PinLocator cache.""" + def __init__(self, path: str): + self.path = path + self.symbol = [] + + skip_mod.Schematic = _FakeSchematic # type: ignore[attr-defined] + sys.modules["skip"] = skip_mod diff --git a/python/tests/test_schematic_analysis.py b/python/tests/test_schematic_analysis.py new file mode 100644 index 0000000..95d89e7 --- /dev/null +++ b/python/tests/test_schematic_analysis.py @@ -0,0 +1,398 @@ +""" +Tests for schematic analysis tools (Tools 2–5). + +Unit tests use mock data / synthetic S-expressions. +Integration tests parse real .kicad_sch files via sexpdata. +""" + +import os +import sys +import shutil +import tempfile +from pathlib import Path +from unittest.mock import patch, MagicMock + +import pytest +import sexpdata +from sexpdata import Symbol + +# Ensure the python/ package is importable +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from commands.schematic_analysis import ( + _parse_wires, + _parse_labels, + _parse_symbols, + _parse_no_connects, + _load_sexp, + _line_segment_intersects_aabb, + _point_in_rect, + _distance, + compute_symbol_bbox, + find_unconnected_pins, + find_overlapping_elements, + get_elements_in_region, + check_wire_collisions, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "empty.kicad_sch" + + +def _make_temp_schematic(extra_sexp: str = "") -> Path: + """Copy empty.kicad_sch to a temp file and optionally append S-expression content.""" + tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" + shutil.copy(TEMPLATE_PATH, tmp) + if extra_sexp: + content = tmp.read_text(encoding="utf-8") + # Insert before the final closing paren + idx = content.rfind(")") + content = content[:idx] + "\n" + extra_sexp + "\n)" + tmp.write_text(content, encoding="utf-8") + return tmp + + +import uuid as _uuid + + +def _make_resistor_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str: + """Generate a proper Device:R symbol S-expression that skip can parse.""" + u = str(_uuid.uuid4()) + return f""" + (symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1) + (in_bom yes) (on_board yes) (dnp no) + (uuid "{u}") + (property "Reference" "{ref}" (at {x + 2.032} {y} 90) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "10k" (at {x} {y} 90) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at {x - 1.778} {y} 90) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at {x} {y} 0) + (effects (font (size 1.27 1.27)) hide) + ) + (pin "1" (uuid "{_uuid.uuid4()}")) + (pin "2" (uuid "{_uuid.uuid4()}")) + (instances + (project "test" + (path "/" (reference "{ref}") (unit 1)) + ) + ) + ) +""" + + +def _make_led_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str: + """Generate a proper Device:LED symbol S-expression (horizontal pin spread).""" + u = str(_uuid.uuid4()) + return f""" + (symbol (lib_id "Device:LED") (at {x} {y} {rotation}) (unit 1) + (in_bom yes) (on_board yes) (dnp no) + (uuid "{u}") + (property "Reference" "{ref}" (at {x} {y - 2.54} 0) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "LED" (at {x} {y + 2.54} 0) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at {x} {y} 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at {x} {y} 0) + (effects (font (size 1.27 1.27)) hide) + ) + (pin "1" (uuid "{_uuid.uuid4()}")) + (pin "2" (uuid "{_uuid.uuid4()}")) + (instances + (project "test" + (path "/" (reference "{ref}") (unit 1)) + ) + ) + ) +""" + + +# =================================================================== +# Unit tests — geometry helpers +# =================================================================== + +class TestGeometryHelpers: + """Test low-level geometry utilities.""" + + def test_point_in_rect_inside(self): + assert _point_in_rect(5, 5, 0, 0, 10, 10) is True + + def test_point_in_rect_outside(self): + assert _point_in_rect(15, 5, 0, 0, 10, 10) is False + + def test_point_in_rect_boundary(self): + assert _point_in_rect(0, 0, 0, 0, 10, 10) is True + + def test_distance_zero(self): + assert _distance((0, 0), (0, 0)) == 0 + + def test_distance_unit(self): + assert abs(_distance((0, 0), (3, 4)) - 5.0) < 1e-9 + + def test_aabb_intersection_crossing(self): + # Line from (0,5) to (10,5) should intersect box (2,2)-(8,8) + assert _line_segment_intersects_aabb(0, 5, 10, 5, 2, 2, 8, 8) is True + + def test_aabb_intersection_miss(self): + # Line from (0,0) to (10,0) should miss box (2,2)-(8,8) + assert _line_segment_intersects_aabb(0, 0, 10, 0, 2, 2, 8, 8) is False + + def test_aabb_intersection_inside(self): + # Line entirely inside the box + assert _line_segment_intersects_aabb(3, 3, 7, 7, 2, 2, 8, 8) is True + + def test_aabb_intersection_diagonal(self): + # Diagonal line crossing through box + assert _line_segment_intersects_aabb(0, 0, 10, 10, 2, 2, 8, 8) is True + + def test_aabb_intersection_parallel_outside(self): + # Horizontal line above the box + assert _line_segment_intersects_aabb(0, 9, 10, 9, 2, 2, 8, 8) is False + + def test_aabb_intersection_touching_edge(self): + # Line ending exactly at box edge + assert _line_segment_intersects_aabb(0, 2, 2, 2, 2, 2, 8, 8) is True + + +# =================================================================== +# Unit tests — S-expression parsers +# =================================================================== + +class TestSexpParsers: + """Test S-expression parsing functions with synthetic data.""" + + def test_parse_wires_basic(self): + sexp = sexpdata.loads("""(kicad_sch + (wire (pts (xy 10 20) (xy 30 40)) + (stroke (width 0) (type default)) + (uuid "abc")) + )""") + wires = _parse_wires(sexp) + assert len(wires) == 1 + assert wires[0]["start"] == (10.0, 20.0) + assert wires[0]["end"] == (30.0, 40.0) + + def test_parse_wires_empty(self): + sexp = sexpdata.loads("(kicad_sch)") + assert _parse_wires(sexp) == [] + + def test_parse_labels_both_types(self): + sexp = sexpdata.loads("""(kicad_sch + (label "VCC" (at 10 20 0)) + (global_label "GND" (at 30 40 0)) + )""") + labels = _parse_labels(sexp) + assert len(labels) == 2 + assert labels[0]["name"] == "VCC" + assert labels[0]["type"] == "label" + assert labels[1]["name"] == "GND" + assert labels[1]["type"] == "global_label" + + def test_parse_symbols(self): + sexp = sexpdata.loads("""(kicad_sch + (symbol (lib_id "Device:R") (at 100 100 0) + (property "Reference" "R1" (at 0 0 0))) + (symbol (lib_id "power:VCC") (at 50 50 0) + (property "Reference" "#PWR01" (at 0 0 0))) + )""") + symbols = _parse_symbols(sexp) + assert len(symbols) == 2 + assert symbols[0]["reference"] == "R1" + assert symbols[0]["is_power"] is False + assert symbols[1]["reference"] == "#PWR01" + assert symbols[1]["is_power"] is True + + def test_parse_no_connects(self): + sexp = sexpdata.loads("""(kicad_sch + (no_connect (at 10 20) (uuid "x")) + (no_connect (at 30 40) (uuid "y")) + )""") + nc = _parse_no_connects(sexp) + assert (10.0, 20.0) in nc + assert (30.0, 40.0) in nc + assert len(nc) == 2 + + +# =================================================================== +# Unit tests — analysis functions with mocked PinLocator +# =================================================================== + +class TestFindOverlappingElements: + """Test overlapping detection logic.""" + + def test_no_overlaps_in_empty_schematic(self): + tmp = _make_temp_schematic() + result = find_overlapping_elements(tmp, tolerance=0.5) + assert result["totalOverlaps"] == 0 + + def test_overlapping_symbols_detected(self): + # Two symbols at nearly the same position + extra = """ + (symbol (lib_id "Device:R") (at 100 100 0) + (property "Reference" "R1" (at 0 0 0)) + (property "Value" "10k" (at 0 0 0))) + (symbol (lib_id "Device:R") (at 100.1 100 0) + (property "Reference" "R2" (at 0 0 0)) + (property "Value" "10k" (at 0 0 0))) + """ + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert result["totalOverlaps"] >= 1 + assert len(result["overlappingSymbols"]) >= 1 + + def test_well_separated_symbols_not_flagged(self): + extra = """ + (symbol (lib_id "Device:R") (at 100 100 0) + (property "Reference" "R1" (at 0 0 0)) + (property "Value" "10k" (at 0 0 0))) + (symbol (lib_id "Device:R") (at 200 200 0) + (property "Reference" "R2" (at 0 0 0)) + (property "Value" "10k" (at 0 0 0))) + """ + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert result["totalOverlaps"] == 0 + + def test_collinear_wire_overlap(self): + extra = """ + (wire (pts (xy 10 50) (xy 30 50)) + (stroke (width 0) (type default)) + (uuid "w1")) + (wire (pts (xy 20 50) (xy 40 50)) + (stroke (width 0) (type default)) + (uuid "w2")) + """ + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert len(result["overlappingWires"]) >= 1 + + +class TestGetElementsInRegion: + """Test region query logic.""" + + def test_elements_inside_region_found(self): + extra = """ + (symbol (lib_id "Device:R") (at 50 50 0) + (property "Reference" "R1" (at 0 0 0)) + (property "Value" "10k" (at 0 0 0))) + (wire (pts (xy 45 50) (xy 55 50)) + (stroke (width 0) (type default)) + (uuid "w1")) + (label "NET1" (at 50 50 0)) + """ + tmp = _make_temp_schematic(extra) + result = get_elements_in_region(tmp, 40, 40, 60, 60) + assert result["counts"]["symbols"] >= 1 + assert result["counts"]["wires"] >= 1 + assert result["counts"]["labels"] >= 1 + + def test_elements_outside_region_excluded(self): + extra = """ + (symbol (lib_id "Device:R") (at 200 200 0) + (property "Reference" "R1" (at 0 0 0)) + (property "Value" "10k" (at 0 0 0))) + """ + tmp = _make_temp_schematic(extra) + result = get_elements_in_region(tmp, 0, 0, 50, 50) + assert result["counts"]["symbols"] == 0 + + +class TestComputeSymbolBbox: + """Test bounding box computation.""" + + def test_returns_none_for_unknown_symbol(self): + tmp = _make_temp_schematic() + from commands.pin_locator import PinLocator + locator = PinLocator() + result = compute_symbol_bbox(tmp, "NONEXISTENT", locator) + assert result is None + + +# =================================================================== +# Integration tests — full schematic parsing +# =================================================================== + +@pytest.mark.integration +class TestIntegrationFindUnconnectedPins: + """Integration test using real schematic files.""" + + def test_component_with_no_wires_has_unconnected_pins(self): + """A resistor placed with no wires should have 2 unconnected pins.""" + extra = _make_resistor_sexp("R1", 100, 100) + tmp = _make_temp_schematic(extra) + result = find_unconnected_pins(tmp) + r1_pins = [p for p in result if p["reference"] == "R1"] + assert len(r1_pins) == 2 + + def test_pin_with_wire_is_connected(self): + """A wire endpoint exactly at a pin position should mark it connected.""" + # R1 at (100,100), rotation 0 → pin 1 at (100, 103.81), pin 2 at (100, 96.19) + extra = _make_resistor_sexp("R1", 100, 100) + """ + (wire (pts (xy 100 103.81) (xy 100 120)) + (stroke (width 0) (type default)) + (uuid "w1")) + """ + tmp = _make_temp_schematic(extra) + result = find_unconnected_pins(tmp) + r1_pins = [p for p in result if p["reference"] == "R1"] + # Pin 1 should be connected (wire at 100, 103.81), pin 2 still unconnected + assert len(r1_pins) == 1 + assert r1_pins[0]["pinNumber"] == "2" + + def test_no_connect_suppresses_pin(self): + """A no_connect at a pin position should not report it as unconnected.""" + extra = _make_resistor_sexp("R1", 100, 100) + """ + (no_connect (at 100 96.19) (uuid "nc1")) + (no_connect (at 100 103.81) (uuid "nc2")) + """ + tmp = _make_temp_schematic(extra) + result = find_unconnected_pins(tmp) + r1_pins = [p for p in result if p["reference"] == "R1"] + assert len(r1_pins) == 0 + + +@pytest.mark.integration +class TestIntegrationCheckWireCollisions: + """Integration test for wire collision detection.""" + + def test_wire_not_touching_pins_is_collision(self): + """A wire passing through a component bbox without pin contact → collision.""" + # LED D1 at (100,100) → pin 1 at (96.19, 100), pin 2 at (103.81, 100) + # Vertical wire from (100, 95) to (100, 105) crosses through the body + # without touching either horizontal pin + extra = _make_led_sexp("D1", 100, 100) + """ + (wire (pts (xy 100 95) (xy 100 105)) + (stroke (width 0) (type default)) + (uuid "w1")) + """ + tmp = _make_temp_schematic(extra) + result = check_wire_collisions(tmp) + d1_collisions = [c for c in result if c["component"]["reference"] == "D1"] + assert len(d1_collisions) >= 1 + + +@pytest.mark.integration +class TestIntegrationGetElementsInRegion: + """Integration test for region query.""" + + def test_region_returns_pin_data(self): + """Symbols in region should include pin position data.""" + extra = _make_resistor_sexp("R1", 100, 100) + tmp = _make_temp_schematic(extra) + result = get_elements_in_region(tmp, 90, 90, 110, 110) + assert result["counts"]["symbols"] == 1 + sym = result["symbols"][0] + assert "pins" in sym + assert len(sym["pins"]) == 2 # Resistor has 2 pins diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index 92c3d4a..28f5b7e 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -1092,4 +1092,187 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }; }, ); + + // ============================================================ + // Schematic Analysis Tools (read-only) + // ============================================================ + + // Get a zoomed view of a schematic region + server.tool( + "get_schematic_view_region", + "Export a cropped region of the schematic as an image (PNG or SVG). Specify bounding box coordinates in schematic mm. Useful for zooming into a specific area to inspect wiring or layout.", + { + schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), + x1: z.number().describe("Left X coordinate of the region in mm"), + y1: z.number().describe("Top Y coordinate of the region in mm"), + x2: z.number().describe("Right X coordinate of the region in mm"), + y2: z.number().describe("Bottom Y coordinate of the region in mm"), + format: z.enum(["png", "svg"]).optional().describe("Output image format (default: png)"), + width: z.number().optional().describe("Output image width in pixels (default: 800)"), + height: z.number().optional().describe("Output image height in pixels (default: 600)"), + }, + async (args: { + schematicPath: string; + x1: number; y1: number; x2: number; y2: number; + format?: string; width?: number; height?: number; + }) => { + const result = await callKicadScript("get_schematic_view_region", args); + if (result.success && result.imageData) { + if (result.format === "svg") { + return { content: [{ type: "text", text: result.imageData }] }; + } + return { + content: [{ + type: "image", + data: result.imageData, + mimeType: "image/png", + }], + }; + } + return { + content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }], + }; + }, + ); + + // Find unconnected pins + server.tool( + "find_unconnected_pins", + "List all component pins in the schematic that have no wire, label, or power symbol touching them. Useful for checking connectivity before running ERC.", + { + schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), + }, + async (args: { schematicPath: string }) => { + const result = await callKicadScript("find_unconnected_pins", args); + if (result.success) { + const pins: any[] = result.unconnectedPins || []; + const lines = [`Found ${pins.length} unconnected pin(s):`]; + pins.slice(0, 50).forEach((p: any) => { + lines.push(` ${p.reference} pin ${p.pinNumber} (${p.pinName}) @ (${p.position.x}, ${p.position.y})`); + }); + if (pins.length > 50) lines.push(` ... and ${pins.length - 50} more`); + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + return { + content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }], + }; + }, + ); + + // Find overlapping elements + server.tool( + "find_overlapping_elements", + "Detect spatially overlapping symbols, wires, and labels in the schematic. Finds duplicate power symbols at the same position, collinear overlapping wires, and labels stacked on top of each other.", + { + schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), + tolerance: z.number().optional().describe("Distance in mm below which elements are considered overlapping (default: 0.5)"), + }, + async (args: { schematicPath: string; tolerance?: number }) => { + const result = await callKicadScript("find_overlapping_elements", args); + if (result.success) { + const lines = [`Found ${result.totalOverlaps} overlap(s):`]; + const syms: any[] = result.overlappingSymbols || []; + const lbls: any[] = result.overlappingLabels || []; + const wires: any[] = result.overlappingWires || []; + if (syms.length) { + lines.push(`\nOverlapping symbols (${syms.length}):`); + syms.slice(0, 20).forEach((o: any) => { + lines.push(` ${o.element1.reference} ↔ ${o.element2.reference} (${o.distance}mm) [${o.type}]`); + }); + } + if (lbls.length) { + lines.push(`\nOverlapping labels (${lbls.length}):`); + lbls.slice(0, 20).forEach((o: any) => { + lines.push(` "${o.element1.name}" ↔ "${o.element2.name}" (${o.distance}mm)`); + }); + } + if (wires.length) { + lines.push(`\nOverlapping wires (${wires.length}):`); + wires.slice(0, 20).forEach((o: any) => { + lines.push(` wire @ (${o.wire1.start.x},${o.wire1.start.y})→(${o.wire1.end.x},${o.wire1.end.y}) overlaps with another`); + }); + } + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + return { + content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }], + }; + }, + ); + + // Get elements in a region + server.tool( + "get_elements_in_region", + "List all symbols, wires, and labels within a rectangular region of the schematic. Useful for understanding what is in a specific area before modifying it.", + { + schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), + x1: z.number().describe("Left X coordinate of the region in mm"), + y1: z.number().describe("Top Y coordinate of the region in mm"), + x2: z.number().describe("Right X coordinate of the region in mm"), + y2: z.number().describe("Bottom Y coordinate of the region in mm"), + }, + async (args: { + schematicPath: string; + x1: number; y1: number; x2: number; y2: number; + }) => { + const result = await callKicadScript("get_elements_in_region", args); + if (result.success) { + const c = result.counts; + const lines = [`Region (${args.x1},${args.y1})→(${args.x2},${args.y2}): ${c.symbols} symbols, ${c.wires} wires, ${c.labels} labels`]; + const syms: any[] = result.symbols || []; + if (syms.length) { + lines.push("\nSymbols:"); + syms.forEach((s: any) => { + const pinCount = s.pins ? Object.keys(s.pins).length : 0; + lines.push(` ${s.reference} (${s.libId}) @ (${s.position.x}, ${s.position.y}) [${pinCount} pins]`); + }); + } + const wires: any[] = result.wires || []; + if (wires.length) { + lines.push(`\nWires (${wires.length}):`); + wires.slice(0, 30).forEach((w: any) => { + lines.push(` (${w.start.x},${w.start.y}) → (${w.end.x},${w.end.y})`); + }); + if (wires.length > 30) lines.push(` ... and ${wires.length - 30} more`); + } + const labels: any[] = result.labels || []; + if (labels.length) { + lines.push(`\nLabels (${labels.length}):`); + labels.forEach((l: any) => { + lines.push(` "${l.name}" [${l.type}] @ (${l.position.x}, ${l.position.y})`); + }); + } + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + return { + content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }], + }; + }, + ); + + // Check wire collisions + server.tool( + "check_wire_collisions", + "Detect wires that pass through component bodies without connecting to their pins. These are usually routing mistakes where a wire crosses over a symbol instead of connecting to it.", + { + schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), + }, + async (args: { schematicPath: string }) => { + const result = await callKicadScript("check_wire_collisions", args); + if (result.success) { + const collisions: any[] = result.collisions || []; + const lines = [`Found ${collisions.length} wire collision(s):`]; + collisions.slice(0, 30).forEach((c: any, i: number) => { + lines.push( + ` ${i + 1}. Wire (${c.wire.start.x},${c.wire.start.y})→(${c.wire.end.x},${c.wire.end.y}) passes through ${c.component.reference} (${c.component.libId})` + ); + }); + if (collisions.length > 30) lines.push(` ... and ${collisions.length - 30} more`); + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + return { + content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }], + }; + }, + ); } From 9141e33b70f35550f0186dd935c99a9454742962 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sat, 14 Mar 2026 20:55:14 +0000 Subject: [PATCH 04/74] fix: get_schematic_view_region SVG export and coordinate system - Use a temp directory (not file) for kicad-cli svg output, which expects a directory path - Clean up temp dir with shutil.rmtree instead of individual file unlinks - Fix viewBox cropping: KiCad schematic SVGs use mm directly, not mils (removed erroneous 39.3701 multiplier) Co-Authored-By: Claude Sonnet 4.6 --- python/kicad_interface.py | 38 +++++++++++++++++--------------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 5166e8d..723b00c 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -2592,48 +2592,45 @@ class KiCADInterface: if not kicad_cli: return {"success": False, "message": "kicad-cli not found"} - with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as tmp: - svg_output = tmp.name + tmp_dir = tempfile.mkdtemp() + svg_output = None try: - cmd = [kicad_cli, "sch", "export", "svg", "--output", svg_output, schematic_path] + cmd = [kicad_cli, "sch", "export", "svg", "--output", tmp_dir, schematic_path] result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if result.returncode != 0: return {"success": False, "message": f"SVG export failed: {result.stderr}"} + # kicad-cli names the file after the schematic + svg_files = [f for f in os.listdir(tmp_dir) if f.endswith(".svg")] + if not svg_files: + return {"success": False, "message": "kicad-cli produced no SVG output"} + svg_output = os.path.join(tmp_dir, svg_files[0]) + import xml.etree.ElementTree as ET tree = ET.parse(svg_output) root = tree.getroot() - # Read original viewBox to determine SVG coordinate mapping - ns = {"svg": "http://www.w3.org/2000/svg"} + # KiCad schematic SVGs use mm as viewBox units directly vb = root.get("viewBox", "") if vb: parts = vb.split() if len(parts) == 4: - # KiCad SVG viewBox is in mils (1 mil = 0.0254 mm) - # or internal units. Detect scale from original viewBox. orig_vb_x = float(parts[0]) orig_vb_y = float(parts[1]) - orig_vb_w = float(parts[2]) - orig_vb_h = float(parts[3]) - # KiCad schematic SVGs use mils (1/1000 inch) as user units - # 1 mm = 39.3701 mils - mils_per_mm = 39.3701 - - new_x = orig_vb_x + x1 * mils_per_mm - new_y = orig_vb_y + y1 * mils_per_mm - new_w = (x2 - x1) * mils_per_mm - new_h = (y2 - y1) * mils_per_mm + new_x = orig_vb_x + x1 + new_y = orig_vb_y + y1 + new_w = x2 - x1 + new_h = y2 - y1 root.set("viewBox", f"{new_x} {new_y} {new_w} {new_h}") root.set("width", str(width)) root.set("height", str(height)) # Write modified SVG - cropped_svg_path = svg_output + ".cropped.svg" + cropped_svg_path = os.path.join(tmp_dir, "cropped.svg") tree.write(cropped_svg_path, xml_declaration=True, encoding="utf-8") if out_format == "svg": @@ -2652,9 +2649,8 @@ class KiCADInterface: "format": "png", } finally: - for f in [svg_output, svg_output + ".cropped.svg"]: - if os.path.exists(f): - os.unlink(f) + import shutil + shutil.rmtree(tmp_dir, ignore_errors=True) except Exception as e: logger.error(f"Error in get_schematic_view_region: {e}") From 53564cbc58dd54f43dbb4a0f5af744c33d1576ff Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 15 Mar 2026 10:55:11 +0000 Subject: [PATCH 05/74] fix: check_wire_collisions reports false positives for unannotated components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PinLocator.get_all_symbol_pins resolves symbols by reference designator, so when multiple components share the same unannotated reference (e.g. "Q?"), it always returned the first match's pin positions. Every duplicate then got an identical bounding box, causing a single wire to be flagged against all N instances instead of only the ones it actually crosses. Fix: add _compute_pin_positions_direct() that computes absolute pin positions directly from each symbol's own (at x y rotation) and (mirror ...) data plus pin definitions fetched by lib_id — no reference-name lookup involved. Also extend _parse_symbols to capture mirror_x/mirror_y flags. Add regression test: two "R?" at different positions, wire crossing only one → must produce 0 collisions against the far-away component. Co-Authored-By: Claude Sonnet 4.6 --- python/commands/schematic_analysis.py | 68 +++++++++++++++++++++++-- python/tests/test_schematic_analysis.py | 33 ++++++++++++ 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/python/commands/schematic_analysis.py b/python/commands/schematic_analysis.py index 8fa58e5..9d91730 100644 --- a/python/commands/schematic_analysis.py +++ b/python/commands/schematic_analysis.py @@ -85,7 +85,7 @@ 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, is_power} + Returns list of dicts: {reference, lib_id, x, y, rotation, mirror_x, mirror_y, is_power} """ symbols = [] for item in sexp_data: @@ -98,6 +98,8 @@ def _parse_symbols(sexp_data: list) -> List[Dict[str, Any]]: 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: @@ -108,6 +110,12 @@ def _parse_symbols(sexp_data: list) -> List[Dict[str, Any]]: 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": @@ -120,6 +128,8 @@ def _parse_symbols(sexp_data: list) -> List[Dict[str, Any]]: "x": x, "y": y, "rotation": rotation, + "mirror_x": mirror_x, + "mirror_y": mirror_y, "is_power": is_power, }) return symbols @@ -518,6 +528,44 @@ def get_elements_in_region( # 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 check_wire_collisions(schematic_path: Path) -> List[Dict[str, Any]]: """ Detect wires passing through component bodies without connecting to their pins. @@ -546,11 +594,22 @@ def check_wire_collisions(schematic_path: Path) -> List[Dict[str, Any]]: if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref: continue - bbox = compute_symbol_bbox(schematic_path, ref, locator) - if bbox is None: + # Get pin definitions by lib_id (works regardless of reference designator, + # so unannotated components with duplicate "Q?" references are handled correctly). + pin_defs = locator.get_symbol_pins(schematic_path, sym["lib_id"]) + if not pin_defs: continue - min_x, min_y, max_x, max_y = bbox + # Compute absolute pin positions directly from this symbol's own position/rotation, + # bypassing the reference-name lookup in PinLocator (which always finds the first + # symbol with a given reference, breaking for unannotated duplicates like "Q?"). + pin_positions = _compute_pin_positions_direct(sym, pin_defs) + if not pin_positions: + continue + + 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) # Expand degenerate dimensions (pins in a line) to approximate body size min_body = 1.5 # mm minimum half-extent for component body @@ -573,7 +632,6 @@ def check_wire_collisions(schematic_path: Path) -> List[Dict[str, Any]]: if max_x <= min_x or max_y <= min_y: continue - pin_positions = locator.get_all_symbol_pins(schematic_path, ref) pin_set = set() for pos in pin_positions.values(): pin_set.add((pos[0], pos[1])) diff --git a/python/tests/test_schematic_analysis.py b/python/tests/test_schematic_analysis.py index 95d89e7..35b6d1e 100644 --- a/python/tests/test_schematic_analysis.py +++ b/python/tests/test_schematic_analysis.py @@ -382,6 +382,39 @@ class TestIntegrationCheckWireCollisions: d1_collisions = [c for c in result if c["component"]["reference"] == "D1"] assert len(d1_collisions) >= 1 + def test_unannotated_duplicates_not_over_reported(self): + """ + Regression: two components with the same unannotated reference ("R?") at + different positions should each produce independent bounding boxes. + A wire crossing only one of them must produce exactly 1 collision, not 2. + + Before the fix, PinLocator.get_all_symbol_pins always resolved "R?" to + the first match, so both symbols got identical bboxes and the same wire + was counted against both. + """ + # R? at (100, 100): Device:R pins are at (100, 96.19) and (100, 103.81). + # Effective bbox (after expansion + margin) ≈ x=[99,101], y=[96.69,103.31]. + # R? at (200, 100): identical type but far away → no intersection with wire. + r_at_100 = _make_resistor_sexp("R?", 100, 100) + r_at_200 = _make_resistor_sexp("R?", 200, 100) + # Horizontal wire crossing the body of the first R? only + wire = """ + (wire (pts (xy 95 100) (xy 105 100)) + (stroke (width 0) (type default)) + (uuid "w-collision")) + """ + tmp = _make_temp_schematic(r_at_100 + r_at_200 + wire) + result = check_wire_collisions(tmp) + # The wire must not be reported against the far-away R? at (200, 100) + collisions_at_200 = [ + c for c in result + if abs(c["component"]["position"]["x"] - 200) < 0.5 + ] + assert len(collisions_at_200) == 0, ( + "Wire at x≈100 must not be flagged against the R? at x=200; " + "likely caused by reference-lookup always returning the first 'R?'" + ) + @pytest.mark.integration class TestIntegrationGetElementsInRegion: From 1bfa60872976735066ef07dfa44c378d72b21d9f Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 15 Mar 2026 11:06:08 +0000 Subject: [PATCH 06/74] fix: apply unannotated-reference fix to find_unconnected_pins and get_elements_in_region The same reference-lookup bug fixed in check_wire_collisions (where PinLocator.get_all_symbol_pins always resolves to the first symbol with a given reference) also affected: - find_unconnected_pins: would check wrong pin positions for all unannotated components after the first, producing false connected/ unconnected reports. - get_elements_in_region: would return wrong pin coordinates for unannotated components in the queried region. Both now use _compute_pin_positions_direct (fetching pin defs by lib_id and applying each symbol's own position/rotation/mirror), matching the fix already applied to check_wire_collisions. Co-Authored-By: Claude Sonnet 4.6 --- python/commands/schematic_analysis.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/python/commands/schematic_analysis.py b/python/commands/schematic_analysis.py index 9d91730..b8a3b82 100644 --- a/python/commands/schematic_analysis.py +++ b/python/commands/schematic_analysis.py @@ -293,11 +293,13 @@ def find_unconnected_pins(schematic_path: Path) -> List[Dict[str, Any]]: if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref: continue - pin_positions = locator.get_all_symbol_pins(schematic_path, ref) - if not pin_positions: + pin_defs = locator.get_symbol_pins(schematic_path, sym["lib_id"]) + if not pin_defs: continue - pin_defs = None + pin_positions = _compute_pin_positions_direct(sym, pin_defs) + if not pin_positions: + continue for pin_num, pos in pin_positions.items(): px, py = pos[0], pos[1] @@ -307,9 +309,6 @@ def find_unconnected_pins(schematic_path: Path) -> List[Dict[str, Any]]: if is_connected(px, py): continue - if pin_defs is None: - pin_defs = locator.get_symbol_pins(schematic_path, sym["lib_id"]) - pin_name = pin_defs.get(pin_num, {}).get("name", pin_num) unconnected.append({ "reference": ref, @@ -482,13 +481,15 @@ def get_elements_in_region( "position": {"x": sym["x"], "y": sym["y"]}, "isPower": sym["is_power"], } - # Include pin positions - pin_positions = locator.get_all_symbol_pins(schematic_path, sym["reference"]) - if pin_positions: - entry["pins"] = { - pn: {"x": round(pos[0], 4), "y": round(pos[1], 4)} - for pn, pos in pin_positions.items() - } + # Include pin positions (compute directly to handle unannotated duplicates) + pin_defs = locator.get_symbol_pins(schematic_path, sym["lib_id"]) + 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 endpoint is within bounds From 6b09d93df2755cbcd8dfb107612369c4f02d85eb Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 15 Mar 2026 12:46:50 +0000 Subject: [PATCH 07/74] fix: check_wire_collisions now detects wires that short two pins of the same component Previously, the endpoint suppression logic skipped any wire where at least one endpoint touched a pin, hiding the case where both endpoints are pins of the same component (a direct pin-to-pin short through the body). Replace the single endpoint_matches_pin loop with separate start_at_pin / end_at_pin checks; suppress only when exactly one endpoint is at a pin. Add regression test to cover this case. Co-Authored-By: Claude Sonnet 4.6 --- python/commands/schematic_analysis.py | 30 +++++++++++++++---------- python/tests/test_schematic_analysis.py | 17 ++++++++++++++ 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/python/commands/schematic_analysis.py b/python/commands/schematic_analysis.py index b8a3b82..5c7fe7f 100644 --- a/python/commands/schematic_analysis.py +++ b/python/commands/schematic_analysis.py @@ -654,19 +654,25 @@ def check_wire_collisions(schematic_path: Path) -> List[Dict[str, Any]]: if not _line_segment_intersects_aabb(sx, sy, ex, ey, bx1, by1, bx2, by2): continue - # Check if either wire endpoint matches a pin of this symbol - endpoint_matches_pin = False - for px, py in sd["pin_set"]: - if (abs(sx - px) < pin_tolerance and abs(sy - py) < pin_tolerance): - endpoint_matches_pin = True - break - if (abs(ex - px) < pin_tolerance and abs(ey - py) < pin_tolerance): - endpoint_matches_pin = True - break + # 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"] + ) - if not endpoint_matches_pin: - sym = sd["sym"] - collisions.append({ + # Suppress only when exactly ONE endpoint is at a pin: the wire arrives + # from elsewhere and terminates at this component (a valid connection). + # If BOTH endpoints match pins of this same component, the wire shorts + # two pins while traversing the body — that IS a collision. + if (start_at_pin or end_at_pin) and not (start_at_pin and end_at_pin): + continue + + sym = sd["sym"] + collisions.append({ "wire": { "start": {"x": sx, "y": sy}, "end": {"x": ex, "y": ey}, diff --git a/python/tests/test_schematic_analysis.py b/python/tests/test_schematic_analysis.py index 35b6d1e..d9cdc30 100644 --- a/python/tests/test_schematic_analysis.py +++ b/python/tests/test_schematic_analysis.py @@ -415,6 +415,23 @@ class TestIntegrationCheckWireCollisions: "likely caused by reference-lookup always returning the first 'R?'" ) + def test_wire_shorts_component_pins_detected_as_collision(self): + """Regression: a wire connecting pin1→pin2 of the same component + must be reported even though both endpoints land on pins.""" + r_sexp = _make_resistor_sexp("R_short", 100.0, 100.0) + wire_sexp = ( + '(wire (pts (xy 100 103.81) (xy 100 96.19))\n' + ' (stroke (width 0) (type default))\n' + ' (uuid "aaaaaaaa-0000-0000-0000-000000000001"))' + ) + sch = _make_temp_schematic(r_sexp + "\n" + wire_sexp) + collisions = check_wire_collisions(sch) + assert len(collisions) == 1 + w = collisions[0]["wire"] + assert w["start"]["x"] == pytest.approx(100.0) + assert w["start"]["y"] == pytest.approx(103.81) + assert collisions[0]["component"]["reference"] == "R_short" + @pytest.mark.integration class TestIntegrationGetElementsInRegion: From 3ab93b241f60378ba86055a9fb2f44781c7a827f Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 15 Mar 2026 13:29:37 +0000 Subject: [PATCH 08/74] fix: find_overlapping_elements uses bounding-box intersection instead of center distance The overlap detection was comparing center-to-center Euclidean distance with a 0.5mm tolerance, missing components whose bodies physically overlap but have different centers (e.g. a resistor placed inside an opamp triangle). Now uses AABB intersection on pin-derived bounding boxes, matching the approach already used by check_wire_collisions. Extracted shared bbox logic into _compute_symbol_bbox_direct and _aabb_overlap helpers. Co-Authored-By: Claude Opus 4.6 --- python/commands/schematic_analysis.py | 133 +++++++++++++++++------- python/tests/test_schematic_analysis.py | 82 ++++++++++++--- 2 files changed, 158 insertions(+), 57 deletions(-) diff --git a/python/commands/schematic_analysis.py b/python/commands/schematic_analysis.py index 5c7fe7f..1bb357c 100644 --- a/python/commands/schematic_analysis.py +++ b/python/commands/schematic_analysis.py @@ -220,6 +220,67 @@ def _distance(p1: Tuple[float, float], p2: Tuple[float, float]) -> float: 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 _compute_symbol_bbox_direct( + sym: Dict[str, Any], + pin_defs: Dict[str, Dict], + margin: float = 0.0, +) -> Optional[Tuple[float, float, float, float]]: + """ + Compute bounding box of a symbol from its pin definitions and placement. + + Uses _compute_pin_positions_direct to get absolute pin positions, then + expands degenerate dimensions (pins in a line) to approximate body size. + + 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). + + 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 + + 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) + + # Expand degenerate dimensions (pins in a line) to approximate body size + 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 2: find_unconnected_pins # --------------------------------------------------------------------------- @@ -346,14 +407,35 @@ def find_overlapping_elements( overlapping_labels = [] overlapping_wires = [] - # --- Symbol-symbol overlap (O(n²)) --- + locator = PinLocator() + + # --- 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"]] - for i in range(len(non_template_symbols)): - for j in range(i + 1, len(non_template_symbols)): - s1 = non_template_symbols[i] - s2 = non_template_symbols[j] + + # Pre-compute bounding boxes for all non-template symbols + symbol_bboxes = [] + for sym in non_template_symbols: + pin_defs = locator.get_symbol_pins(schematic_path, sym["lib_id"]) + bbox = None + if pin_defs: + bbox = _compute_symbol_bbox_direct(sym, pin_defs) + 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"])) - if dist < tolerance: + + 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"]}}, @@ -595,51 +677,22 @@ def check_wire_collisions(schematic_path: Path) -> List[Dict[str, Any]]: if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref: continue - # Get pin definitions by lib_id (works regardless of reference designator, - # so unannotated components with duplicate "Q?" references are handled correctly). pin_defs = locator.get_symbol_pins(schematic_path, sym["lib_id"]) if not pin_defs: continue - # Compute absolute pin positions directly from this symbol's own position/rotation, - # bypassing the reference-name lookup in PinLocator (which always finds the first - # symbol with a given reference, breaking for unannotated duplicates like "Q?"). + bbox = _compute_symbol_bbox_direct(sym, pin_defs, margin=margin) + if bbox is None: + continue + pin_positions = _compute_pin_positions_direct(sym, pin_defs) - if not pin_positions: - continue - - 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) - - # Expand degenerate dimensions (pins in a line) to approximate body size - 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 (single-pin or very small after shrink) - if max_x <= min_x or max_y <= min_y: - continue - pin_set = set() for pos in pin_positions.values(): pin_set.add((pos[0], pos[1])) symbol_data.append({ "sym": sym, - "bbox": (min_x, min_y, max_x, max_y), + "bbox": bbox, "pin_set": pin_set, }) diff --git a/python/tests/test_schematic_analysis.py b/python/tests/test_schematic_analysis.py index d9cdc30..3b68d1c 100644 --- a/python/tests/test_schematic_analysis.py +++ b/python/tests/test_schematic_analysis.py @@ -28,6 +28,8 @@ from commands.schematic_analysis import ( _line_segment_intersects_aabb, _point_in_rect, _distance, + _aabb_overlap, + _compute_symbol_bbox_direct, compute_symbol_bbox, find_unconnected_pins, find_overlapping_elements, @@ -229,6 +231,27 @@ class TestSexpParsers: # Unit tests — analysis functions with mocked PinLocator # =================================================================== +class TestAABBOverlap: + """Test AABB overlap helper.""" + + def test_overlapping_boxes(self): + assert _aabb_overlap((0, 0, 10, 10), (5, 5, 15, 15)) is True + + def test_non_overlapping_boxes(self): + assert _aabb_overlap((0, 0, 10, 10), (20, 20, 30, 30)) is False + + def test_touching_boxes_no_overlap(self): + # Touching edges are not overlapping (strict inequality) + assert _aabb_overlap((0, 0, 10, 10), (10, 0, 20, 10)) is False + + def test_contained_box(self): + assert _aabb_overlap((0, 0, 20, 20), (5, 5, 15, 15)) is True + + def test_overlap_one_axis_only(self): + # Overlap in X but not Y + assert _aabb_overlap((0, 0, 10, 10), (5, 15, 15, 25)) is False + + class TestFindOverlappingElements: """Test overlapping detection logic.""" @@ -238,29 +261,15 @@ class TestFindOverlappingElements: assert result["totalOverlaps"] == 0 def test_overlapping_symbols_detected(self): - # Two symbols at nearly the same position - extra = """ - (symbol (lib_id "Device:R") (at 100 100 0) - (property "Reference" "R1" (at 0 0 0)) - (property "Value" "10k" (at 0 0 0))) - (symbol (lib_id "Device:R") (at 100.1 100 0) - (property "Reference" "R2" (at 0 0 0)) - (property "Value" "10k" (at 0 0 0))) - """ + # Two resistors at nearly the same position — bboxes fully overlap + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100.1, 100) tmp = _make_temp_schematic(extra) result = find_overlapping_elements(tmp, tolerance=0.5) assert result["totalOverlaps"] >= 1 assert len(result["overlappingSymbols"]) >= 1 def test_well_separated_symbols_not_flagged(self): - extra = """ - (symbol (lib_id "Device:R") (at 100 100 0) - (property "Reference" "R1" (at 0 0 0)) - (property "Value" "10k" (at 0 0 0))) - (symbol (lib_id "Device:R") (at 200 200 0) - (property "Reference" "R2" (at 0 0 0)) - (property "Value" "10k" (at 0 0 0))) - """ + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 200, 200) tmp = _make_temp_schematic(extra) result = find_overlapping_elements(tmp, tolerance=0.5) assert result["totalOverlaps"] == 0 @@ -278,6 +287,45 @@ class TestFindOverlappingElements: result = find_overlapping_elements(tmp, tolerance=0.5) assert len(result["overlappingWires"]) >= 1 + def test_overlapping_bodies_different_centers(self): + """Two resistors whose bodies overlap even though centers are ~5mm apart. + + Device:R pins are at y ±3.81 relative to center, so the body spans + ~7.62mm vertically. Two resistors at the same X but 5mm apart in Y + have overlapping bodies — this is the bug the center-distance approach missed. + """ + # R1 at y=100, R2 at y=105 — pin spans [96.19, 103.81] and [101.19, 108.81] + # These overlap in Y from 101.19 to 103.81 + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100, 105) + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert result["totalOverlaps"] >= 1, ( + "Should detect overlap when component bodies intersect, " + "even if centers are far apart" + ) + assert len(result["overlappingSymbols"]) >= 1 + + def test_adjacent_resistors_no_overlap(self): + """Two vertical resistors side by side should not overlap. + + R pins at y ±3.81, but different X positions far enough apart. + """ + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 110, 100) + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert result["totalOverlaps"] == 0 + + def test_resistor_and_led_overlapping_bodies(self): + """A resistor and an LED placed close enough that bodies overlap. + + LED pins at x ±3.81, R pins at y ±3.81. Place LED at same position + as R — bodies clearly overlap. + """ + extra = _make_resistor_sexp("R1", 100, 100) + _make_led_sexp("D1", 100, 100) + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert result["totalOverlaps"] >= 1 + class TestGetElementsInRegion: """Test region query logic.""" From 76ad44121c1aca505ac3d64c331f5ed95dd9da6b Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 15 Mar 2026 13:48:18 +0000 Subject: [PATCH 09/74] fix: rename check_wire_collisions to find_wires_crossing_symbols and detect pass-through wires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires that start at a component pin but continue through the body were incorrectly suppressed as "valid connections." Now nudges the pin endpoint toward the other end and re-tests intersection — if the shortened segment still hits the bbox, the wire passes through and is flagged. Renamed the tool from check_wire_collisions to find_wires_crossing_symbols across all layers (Python, handler, schema, TypeScript) to clarify that it finds wires crossing over component symbols, which is unacceptable in schematics. Co-Authored-By: Claude Opus 4.6 --- python/commands/schematic_analysis.py | 41 ++++++++++++++++----- python/kicad_interface.py | 14 ++++---- python/schemas/tool_schemas.py | 6 ++-- python/tests/test_schematic_analysis.py | 47 +++++++++++++++++++++---- src/tools/schematic.ts | 12 +++---- 5 files changed, 89 insertions(+), 31 deletions(-) diff --git a/python/commands/schematic_analysis.py b/python/commands/schematic_analysis.py index 1bb357c..e2788dc 100644 --- a/python/commands/schematic_analysis.py +++ b/python/commands/schematic_analysis.py @@ -649,16 +649,21 @@ def _compute_pin_positions_direct( return result -def check_wire_collisions(schematic_path: Path) -> List[Dict[str, Any]]: +def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]: """ - Detect wires passing through component bodies without connecting to their pins. + 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 but no wire endpoint matches a pin → collision. + 3. If intersects and the wire is not simply terminating at a pin from + outside, report it as a crossing. - Returns list of collision dicts. + Returns list of crossing dicts. """ sexp_data = _load_sexp(schematic_path) symbols = _parse_symbols(sexp_data) @@ -717,12 +722,30 @@ def check_wire_collisions(schematic_path: Path) -> List[Dict[str, Any]]: for px, py in sd["pin_set"] ) - # Suppress only when exactly ONE endpoint is at a pin: the wire arrives - # from elsewhere and terminates at this component (a valid connection). - # If BOTH endpoints match pins of this same component, the wire shorts - # two pins while traversing the body — that IS a collision. + # 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): - continue + 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({ diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 723b00c..56dcc4c 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -403,7 +403,7 @@ class KiCADInterface: "find_unconnected_pins": self._handle_find_unconnected_pins, "find_overlapping_elements": self._handle_find_overlapping_elements, "get_elements_in_region": self._handle_get_elements_in_region, - "check_wire_collisions": self._handle_check_wire_collisions, + "find_wires_crossing_symbols": self._handle_find_wires_crossing_symbols, "import_svg_logo": self._handle_import_svg_logo, # UI/Process management commands "check_kicad_ui": self._handle_check_kicad_ui, @@ -2734,23 +2734,23 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_check_wire_collisions(self, params): - """Detect wires passing through component bodies without connecting to their pins""" - logger.info("Checking wire collisions in schematic") + def _handle_find_wires_crossing_symbols(self, params): + """Find wires that cross over component symbol bodies""" + logger.info("Finding wires crossing symbols in schematic") try: from pathlib import Path - from commands.schematic_analysis import check_wire_collisions + from commands.schematic_analysis import find_wires_crossing_symbols schematic_path = params.get("schematicPath") if not schematic_path: return {"success": False, "message": "schematicPath is required"} - result = check_wire_collisions(Path(schematic_path)) + result = find_wires_crossing_symbols(Path(schematic_path)) return { "success": True, "collisions": result, "count": len(result), - "message": f"Found {len(result)} wire collision(s)", + "message": f"Found {len(result)} wire(s) crossing symbols", } except Exception as e: logger.error(f"Error checking wire collisions: {e}") diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 4e6290c..ec9d70e 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1792,9 +1792,9 @@ SCHEMATIC_TOOLS = [ } }, { - "name": "check_wire_collisions", - "title": "Check Wire Collisions", - "description": "Detects wires that pass through component bodies without connecting to their pins. These are usually routing mistakes where a wire crosses over a symbol instead of connecting to it. Uses pin-based bounding boxes (approximate but effective for 80/20 detection).", + "name": "find_wires_crossing_symbols", + "title": "Find Wires Crossing Symbols", + "description": "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.", "inputSchema": { "type": "object", "properties": { diff --git a/python/tests/test_schematic_analysis.py b/python/tests/test_schematic_analysis.py index 3b68d1c..1f00297 100644 --- a/python/tests/test_schematic_analysis.py +++ b/python/tests/test_schematic_analysis.py @@ -34,7 +34,7 @@ from commands.schematic_analysis import ( find_unconnected_pins, find_overlapping_elements, get_elements_in_region, - check_wire_collisions, + find_wires_crossing_symbols, ) @@ -412,8 +412,8 @@ class TestIntegrationFindUnconnectedPins: @pytest.mark.integration -class TestIntegrationCheckWireCollisions: - """Integration test for wire collision detection.""" +class TestIntegrationFindWiresCrossingSymbols: + """Integration test for wire crossing symbol detection.""" def test_wire_not_touching_pins_is_collision(self): """A wire passing through a component bbox without pin contact → collision.""" @@ -426,7 +426,7 @@ class TestIntegrationCheckWireCollisions: (uuid "w1")) """ tmp = _make_temp_schematic(extra) - result = check_wire_collisions(tmp) + result = find_wires_crossing_symbols(tmp) d1_collisions = [c for c in result if c["component"]["reference"] == "D1"] assert len(d1_collisions) >= 1 @@ -452,7 +452,7 @@ class TestIntegrationCheckWireCollisions: (uuid "w-collision")) """ tmp = _make_temp_schematic(r_at_100 + r_at_200 + wire) - result = check_wire_collisions(tmp) + result = find_wires_crossing_symbols(tmp) # The wire must not be reported against the far-away R? at (200, 100) collisions_at_200 = [ c for c in result @@ -463,6 +463,41 @@ class TestIntegrationCheckWireCollisions: "likely caused by reference-lookup always returning the first 'R?'" ) + def test_wire_starting_at_pin_passing_through_body(self): + """A wire that starts at a pin but continues through the component body + must be flagged — this is the core bug where the old suppression logic + treated any wire touching a pin as a valid connection.""" + # LED D1 at (100,100) → pin 1 at (96.19, 100), pin 2 at (103.81, 100) + # Wire starts exactly at pin 1 and extends through the body to the right + extra = _make_led_sexp("D1", 100, 100) + """ + (wire (pts (xy 96.19 100) (xy 110 100)) + (stroke (width 0) (type default)) + (uuid "w-through")) + """ + tmp = _make_temp_schematic(extra) + result = find_wires_crossing_symbols(tmp) + d1_crossings = [c for c in result if c["component"]["reference"] == "D1"] + assert len(d1_crossings) >= 1, ( + "Wire starting at pin but passing through body must be detected" + ) + + def test_wire_terminating_at_pin_from_outside(self): + """A wire that arrives at a pin from outside the component body + is a valid connection and must NOT be flagged.""" + # LED D1 at (100,100) → pin 1 at (96.19, 100) + # Wire comes from the left and terminates at pin 1 + extra = _make_led_sexp("D1", 100, 100) + """ + (wire (pts (xy 80 100) (xy 96.19 100)) + (stroke (width 0) (type default)) + (uuid "w-valid")) + """ + tmp = _make_temp_schematic(extra) + result = find_wires_crossing_symbols(tmp) + d1_crossings = [c for c in result if c["component"]["reference"] == "D1"] + assert len(d1_crossings) == 0, ( + "Wire terminating at pin from outside should not be flagged" + ) + def test_wire_shorts_component_pins_detected_as_collision(self): """Regression: a wire connecting pin1→pin2 of the same component must be reported even though both endpoints land on pins.""" @@ -473,7 +508,7 @@ class TestIntegrationCheckWireCollisions: ' (uuid "aaaaaaaa-0000-0000-0000-000000000001"))' ) sch = _make_temp_schematic(r_sexp + "\n" + wire_sexp) - collisions = check_wire_collisions(sch) + collisions = find_wires_crossing_symbols(sch) assert len(collisions) == 1 w = collisions[0]["wire"] assert w["start"]["x"] == pytest.approx(100.0) diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index 28f5b7e..771dc57 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -1250,21 +1250,21 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, ); - // Check wire collisions + // Find wires crossing symbols server.tool( - "check_wire_collisions", - "Detect wires that pass through component bodies without connecting to their pins. These are usually routing mistakes where a wire crosses over a symbol instead of connecting to it.", + "find_wires_crossing_symbols", + "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.", { schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), }, async (args: { schematicPath: string }) => { - const result = await callKicadScript("check_wire_collisions", args); + const result = await callKicadScript("find_wires_crossing_symbols", args); if (result.success) { const collisions: any[] = result.collisions || []; - const lines = [`Found ${collisions.length} wire collision(s):`]; + const lines = [`Found ${collisions.length} wire(s) crossing symbols:`]; collisions.slice(0, 30).forEach((c: any, i: number) => { lines.push( - ` ${i + 1}. Wire (${c.wire.start.x},${c.wire.start.y})→(${c.wire.end.x},${c.wire.end.y}) passes through ${c.component.reference} (${c.component.libId})` + ` ${i + 1}. Wire (${c.wire.start.x},${c.wire.start.y})→(${c.wire.end.x},${c.wire.end.y}) crosses ${c.component.reference} (${c.component.libId})` ); }); if (collisions.length > 30) lines.push(` ... and ${collisions.length - 30} more`); From 0ab7428b84c6fca2ce3b524cbd5148553abd4aa7 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 15 Mar 2026 14:05:58 +0000 Subject: [PATCH 10/74] fix: address PR review issues for schematic analysis tools Eliminate repeated file parsing by extracting _extract_lib_symbols helper that walks already-parsed sexp_data once instead of re-reading the file per symbol via PinLocator. Support diagonal wire overlap detection using cross-product parallelism and 1D projection. Fix wire region inclusion to use AABB intersection for pass-through wires. Normalize view region coordinates. Clarify tolerance docstrings across Python, TS, and schema. Co-Authored-By: Claude Opus 4.6 --- python/commands/schematic_analysis.py | 111 +++++++++++----- python/kicad_interface.py | 2 + python/schemas/tool_schemas.py | 2 +- python/tests/test_schematic_analysis.py | 166 ++++++++++++++++++++++++ src/tools/schematic.ts | 2 +- 5 files changed, 246 insertions(+), 37 deletions(-) diff --git a/python/commands/schematic_analysis.py b/python/commands/schematic_analysis.py index e2788dc..01d170f 100644 --- a/python/commands/schematic_analysis.py +++ b/python/commands/schematic_analysis.py @@ -150,6 +150,33 @@ def _parse_no_connects(sexp_data: list) -> Set[Tuple[float, float]]: return positions +def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict[str, Dict]]: + """ + Walk the lib_symbols section of already-parsed sexp_data and return + pin definitions for every symbol definition. + + Returns: + Dict mapping lib_id → pin definitions (pin_number → pin_data dict). + """ + 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[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] = PinLocator.parse_symbol_definition(item) + return result + + # --------------------------------------------------------------------------- # Geometry helpers # --------------------------------------------------------------------------- @@ -345,7 +372,7 @@ def find_unconnected_pins(schematic_path: Path) -> List[Dict[str, Any]]: return True return False - locator = PinLocator() + lib_pin_defs = _extract_lib_symbols(sexp_data) unconnected = [] for sym in symbols: @@ -354,7 +381,7 @@ def find_unconnected_pins(schematic_path: Path) -> List[Dict[str, Any]]: if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref: continue - pin_defs = locator.get_symbol_pins(schematic_path, sym["lib_id"]) + pin_defs = lib_pin_defs.get(sym["lib_id"], {}) if not pin_defs: continue @@ -394,7 +421,7 @@ def find_overlapping_elements( Args: schematic_path: Path to .kicad_sch file - tolerance: Distance in mm below which elements are considered overlapping + tolerance: Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection. Returns dict: {overlappingSymbols, overlappingLabels, overlappingWires, totalOverlaps} """ @@ -407,7 +434,7 @@ def find_overlapping_elements( overlapping_labels = [] overlapping_wires = [] - locator = PinLocator() + lib_pin_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"]] @@ -415,7 +442,7 @@ def find_overlapping_elements( # Pre-compute bounding boxes for all non-template symbols symbol_bboxes = [] for sym in non_template_symbols: - pin_defs = locator.get_symbol_pins(schematic_path, sym["lib_id"]) + pin_defs = lib_pin_defs.get(sym["lib_id"], {}) bbox = None if pin_defs: bbox = _compute_symbol_bbox_direct(sym, pin_defs) @@ -490,36 +517,49 @@ def _check_wire_overlap( """ 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"] - # Check horizontal collinearity - if abs(s1[1] - e1[1]) < tolerance and abs(s2[1] - e2[1]) < tolerance: - if abs(s1[1] - s2[1]) < tolerance: - # Both horizontal, same Y - min1, max1 = min(s1[0], e1[0]), max(s1[0], e1[0]) - min2, max2 = min(s2[0], e2[0]), max(s2[0], e2[0]) - 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", - } + d1 = (e1[0] - s1[0], e1[1] - s1[1]) + d2 = (e2[0] - s2[0], e2[1] - s2[1]) - # Check vertical collinearity - if abs(s1[0] - e1[0]) < tolerance and abs(s2[0] - e2[0]) < tolerance: - if abs(s1[0] - s2[0]) < tolerance: - # Both vertical, same X - min1, max1 = min(s1[1], e1[1]), max(s1[1], e1[1]) - min2, max2 = min(s2[1], e2[1]), max(s2[1], e2[1]) - 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", - } + 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 @@ -549,7 +589,7 @@ def get_elements_in_region( wires = _parse_wires(sexp_data) labels = _parse_labels(sexp_data) - locator = PinLocator() + lib_pin_defs = _extract_lib_symbols(sexp_data) # Symbols: include if position is within bounds region_symbols = [] @@ -564,7 +604,7 @@ def get_elements_in_region( "isPower": sym["is_power"], } # Include pin positions (compute directly to handle unannotated duplicates) - pin_defs = locator.get_symbol_pins(schematic_path, sym["lib_id"]) + pin_defs = lib_pin_defs.get(sym["lib_id"], {}) if pin_defs: pin_positions = _compute_pin_positions_direct(sym, pin_defs) if pin_positions: @@ -574,12 +614,13 @@ def get_elements_in_region( } region_symbols.append(entry) - # Wires: include if ANY endpoint is within bounds + # 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)): + _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]}, @@ -669,7 +710,7 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]: symbols = _parse_symbols(sexp_data) wires = _parse_wires(sexp_data) - locator = PinLocator() + lib_pin_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 @@ -682,7 +723,7 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]: if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref: continue - pin_defs = locator.get_symbol_pins(schematic_path, sym["lib_id"]) + pin_defs = lib_pin_defs.get(sym["lib_id"], {}) if not pin_defs: continue diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 56dcc4c..47d5f60 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -2584,6 +2584,8 @@ class KiCADInterface: y1 = float(params.get("y1", 0)) x2 = float(params.get("x2", 297)) y2 = float(params.get("y2", 210)) + x1, x2 = min(x1, x2), max(x1, x2) + y1, y2 = min(y1, y2), max(y1, y2) out_format = params.get("format", "png") width = int(params.get("width", 800)) height = int(params.get("height", 600)) diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index ec9d70e..98804b4 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1754,7 +1754,7 @@ SCHEMATIC_TOOLS = [ }, "tolerance": { "type": "number", - "description": "Distance in mm below which elements are considered overlapping (default: 0.5)" + "description": "Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection. (default: 0.5)" } }, "required": ["schematicPath"] diff --git a/python/tests/test_schematic_analysis.py b/python/tests/test_schematic_analysis.py index 1f00297..9ec2740 100644 --- a/python/tests/test_schematic_analysis.py +++ b/python/tests/test_schematic_analysis.py @@ -25,10 +25,12 @@ from commands.schematic_analysis import ( _parse_symbols, _parse_no_connects, _load_sexp, + _extract_lib_symbols, _line_segment_intersects_aabb, _point_in_rect, _distance, _aabb_overlap, + _check_wire_overlap, _compute_symbol_bbox_direct, compute_symbol_bbox, find_unconnected_pins, @@ -529,3 +531,167 @@ class TestIntegrationGetElementsInRegion: sym = result["symbols"][0] assert "pins" in sym assert len(sym["pins"]) == 2 # Resistor has 2 pins + + def test_wire_passing_through_region_included(self): + """A wire that passes through a region (no endpoints inside) should be included.""" + extra = """ + (wire (pts (xy 0 50) (xy 100 50)) + (stroke (width 0) (type default)) + (uuid "w-through")) + """ + tmp = _make_temp_schematic(extra) + result = get_elements_in_region(tmp, 40, 40, 60, 60) + assert result["counts"]["wires"] == 1 + + def test_wire_outside_region_excluded(self): + """A wire entirely outside a region should not be included.""" + extra = """ + (wire (pts (xy 0 0) (xy 10 0)) + (stroke (width 0) (type default)) + (uuid "w-outside")) + """ + tmp = _make_temp_schematic(extra) + result = get_elements_in_region(tmp, 40, 40, 60, 60) + assert result["counts"]["wires"] == 0 + + +# =================================================================== +# Unit tests — _check_wire_overlap +# =================================================================== + +class TestCheckWireOverlap: + """Test wire overlap detection for horizontal, vertical, and diagonal cases.""" + + def test_horizontal_overlap(self): + w1 = {"start": (10, 50), "end": (30, 50)} + w2 = {"start": (20, 50), "end": (40, 50)} + result = _check_wire_overlap(w1, w2, 0.5) + assert result is not None + assert result["type"] == "collinear_overlap" + + def test_vertical_overlap(self): + w1 = {"start": (50, 10), "end": (50, 30)} + w2 = {"start": (50, 20), "end": (50, 40)} + result = _check_wire_overlap(w1, w2, 0.5) + assert result is not None + assert result["type"] == "collinear_overlap" + + def test_diagonal_overlap(self): + w1 = {"start": (0, 0), "end": (20, 20)} + w2 = {"start": (10, 10), "end": (30, 30)} + result = _check_wire_overlap(w1, w2, 0.5) + assert result is not None + assert result["type"] == "collinear_overlap" + + def test_horizontal_no_overlap(self): + w1 = {"start": (10, 50), "end": (20, 50)} + w2 = {"start": (30, 50), "end": (40, 50)} + result = _check_wire_overlap(w1, w2, 0.5) + assert result is None + + def test_parallel_offset_no_overlap(self): + """Two parallel wires offset perpendicularly should not overlap.""" + w1 = {"start": (0, 0), "end": (20, 20)} + w2 = {"start": (0, 5), "end": (20, 25)} + result = _check_wire_overlap(w1, w2, 0.5) + assert result is None + + def test_non_parallel_no_overlap(self): + """Two wires at different angles should not overlap.""" + w1 = {"start": (0, 0), "end": (10, 10)} + w2 = {"start": (0, 0), "end": (10, 0)} + result = _check_wire_overlap(w1, w2, 0.5) + assert result is None + + def test_zero_length_segment(self): + w1 = {"start": (10, 10), "end": (10, 10)} + w2 = {"start": (10, 10), "end": (20, 20)} + result = _check_wire_overlap(w1, w2, 0.5) + assert result is None + + +@pytest.mark.integration +class TestIntegrationDiagonalWireOverlap: + """Integration tests for diagonal collinear wire overlap detection.""" + + def test_diagonal_collinear_wire_overlap(self): + """Two 45-degree wires that overlap should be detected.""" + extra = """ + (wire (pts (xy 0 0) (xy 20 20)) + (stroke (width 0) (type default)) + (uuid "w-diag1")) + (wire (pts (xy 10 10) (xy 30 30)) + (stroke (width 0) (type default)) + (uuid "w-diag2")) + """ + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert len(result["overlappingWires"]) >= 1 + + def test_diagonal_parallel_no_overlap(self): + """Two parallel 45-degree wires that are offset should not overlap.""" + extra = """ + (wire (pts (xy 0 0) (xy 20 20)) + (stroke (width 0) (type default)) + (uuid "w-diag1")) + (wire (pts (xy 0 5) (xy 20 25)) + (stroke (width 0) (type default)) + (uuid "w-diag2")) + """ + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert len(result["overlappingWires"]) == 0 + + def test_diagonal_non_collinear_no_overlap(self): + """Two wires at different angles crossing should not be flagged as collinear overlap.""" + extra = """ + (wire (pts (xy 0 0) (xy 20 20)) + (stroke (width 0) (type default)) + (uuid "w-diag1")) + (wire (pts (xy 0 20) (xy 20 0)) + (stroke (width 0) (type default)) + (uuid "w-diag2")) + """ + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert len(result["overlappingWires"]) == 0 + + +# =================================================================== +# Unit tests — _extract_lib_symbols +# =================================================================== + +class TestExtractLibSymbols: + """Test _extract_lib_symbols helper.""" + + def test_extracts_pins_from_lib_symbols(self): + sexp = sexpdata.loads("""(kicad_sch + (lib_symbols + (symbol "Device:R" + (symbol "Device:R_0_1" + (pin passive (at 0 3.81 270) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27))))) + (pin passive (at 0 -3.81 90) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27))))))) + ) + )""") + result = _extract_lib_symbols(sexp) + assert "Device:R" in result + pins = result["Device:R"] + assert "1" in pins + assert "2" in pins + assert pins["1"]["y"] == pytest.approx(3.81) + + def test_empty_schematic_returns_empty(self): + sexp = sexpdata.loads("(kicad_sch)") + result = _extract_lib_symbols(sexp) + assert result == {} + + def test_no_lib_symbols_section(self): + sexp = sexpdata.loads("""(kicad_sch + (wire (pts (xy 0 0) (xy 10 10))) + )""") + result = _extract_lib_symbols(sexp) + assert result == {} diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index 771dc57..262da24 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -1165,7 +1165,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp "Detect spatially overlapping symbols, wires, and labels in the schematic. Finds duplicate power symbols at the same position, collinear overlapping wires, and labels stacked on top of each other.", { schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), - tolerance: z.number().optional().describe("Distance in mm below which elements are considered overlapping (default: 0.5)"), + tolerance: z.number().optional().describe("Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection. (default: 0.5)"), }, async (args: { schematicPath: string; tolerance?: number }) => { const result = await callKicadScript("find_overlapping_elements", args); From dc3dc06af170db8c6a77362fde19d4bbe7f2539b Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 15 Mar 2026 14:10:11 +0000 Subject: [PATCH 11/74] feat: use real symbol body graphics for bounding boxes Parse graphical elements (rectangle, polyline, circle, arc, bezier) from lib_symbols definitions to compute accurate symbol bounding boxes instead of relying on pin positions with hardcoded degenerate expansion. This fixes bbox accuracy for ICs (previously too small), tiny 2-pin passives (previously too large), and single-pin symbols. Co-Authored-By: Claude Opus 4.6 --- python/commands/schematic_analysis.py | 194 +++++++++++++++---- python/tests/test_schematic_analysis.py | 237 +++++++++++++++++++++++- 2 files changed, 399 insertions(+), 32 deletions(-) diff --git a/python/commands/schematic_analysis.py b/python/commands/schematic_analysis.py index 01d170f..b56f427 100644 --- a/python/commands/schematic_analysis.py +++ b/python/commands/schematic_analysis.py @@ -150,13 +150,90 @@ def _parse_no_connects(sexp_data: list) -> Set[Tuple[float, float]]: return positions -def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict[str, Dict]]: +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 for every symbol definition. + pin definitions and graphics points for every symbol definition. Returns: - Dict mapping lib_id → pin definitions (pin_number → pin_data dict). + Dict mapping lib_id → {"pins": pin_defs, "graphics_points": [(x,y), ...]}. """ lib_symbols_section = None for item in sexp_data: @@ -168,12 +245,15 @@ def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict[str, Dict]]: if not lib_symbols_section: return {} - result: Dict[str, Dict[str, Dict]] = {} + 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] = PinLocator.parse_symbol_definition(item) + result[symbol_name] = { + "pins": PinLocator.parse_symbol_definition(item), + "graphics_points": _parse_lib_symbol_graphics(item), + } return result @@ -258,21 +338,48 @@ def _aabb_overlap( 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: mirror → rotate → translate. + """ + # 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 pin definitions and placement. + Compute bounding box of a symbol from its graphics and pin definitions. - Uses _compute_pin_positions_direct to get absolute pin positions, then - expands degenerate dimensions (pins in a line) to approximate body size. + 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. """ @@ -280,20 +387,39 @@ def _compute_symbol_bbox_direct( if not pin_positions: return None - 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) + 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) - # Expand degenerate dimensions (pins in a line) to approximate body size - 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 + 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 @@ -372,7 +498,7 @@ def find_unconnected_pins(schematic_path: Path) -> List[Dict[str, Any]]: return True return False - lib_pin_defs = _extract_lib_symbols(sexp_data) + lib_defs = _extract_lib_symbols(sexp_data) unconnected = [] for sym in symbols: @@ -381,7 +507,8 @@ def find_unconnected_pins(schematic_path: Path) -> List[Dict[str, Any]]: if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref: continue - pin_defs = lib_pin_defs.get(sym["lib_id"], {}) + lib_data = lib_defs.get(sym["lib_id"], {}) + pin_defs = lib_data.get("pins", {}) if not pin_defs: continue @@ -434,7 +561,7 @@ def find_overlapping_elements( overlapping_labels = [] overlapping_wires = [] - lib_pin_defs = _extract_lib_symbols(sexp_data) + 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"]] @@ -442,10 +569,12 @@ def find_overlapping_elements( # Pre-compute bounding boxes for all non-template symbols symbol_bboxes = [] for sym in non_template_symbols: - pin_defs = lib_pin_defs.get(sym["lib_id"], {}) + 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) + bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) symbol_bboxes.append((sym, bbox)) for i in range(len(symbol_bboxes)): @@ -589,7 +718,7 @@ def get_elements_in_region( wires = _parse_wires(sexp_data) labels = _parse_labels(sexp_data) - lib_pin_defs = _extract_lib_symbols(sexp_data) + lib_defs = _extract_lib_symbols(sexp_data) # Symbols: include if position is within bounds region_symbols = [] @@ -604,7 +733,8 @@ def get_elements_in_region( "isPower": sym["is_power"], } # Include pin positions (compute directly to handle unannotated duplicates) - pin_defs = lib_pin_defs.get(sym["lib_id"], {}) + 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: @@ -710,7 +840,7 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]: symbols = _parse_symbols(sexp_data) wires = _parse_wires(sexp_data) - lib_pin_defs = _extract_lib_symbols(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 @@ -723,11 +853,13 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]: if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref: continue - pin_defs = lib_pin_defs.get(sym["lib_id"], {}) + lib_data = lib_defs.get(sym["lib_id"], {}) + pin_defs = lib_data.get("pins", {}) if not pin_defs: continue - bbox = _compute_symbol_bbox_direct(sym, pin_defs, margin=margin) + 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 diff --git a/python/tests/test_schematic_analysis.py b/python/tests/test_schematic_analysis.py index 9ec2740..4f31446 100644 --- a/python/tests/test_schematic_analysis.py +++ b/python/tests/test_schematic_analysis.py @@ -26,6 +26,8 @@ from commands.schematic_analysis import ( _parse_no_connects, _load_sexp, _extract_lib_symbols, + _parse_lib_symbol_graphics, + _transform_local_point, _line_segment_intersects_aabb, _point_in_rect, _distance, @@ -679,7 +681,7 @@ class TestExtractLibSymbols: )""") result = _extract_lib_symbols(sexp) assert "Device:R" in result - pins = result["Device:R"] + pins = result["Device:R"]["pins"] assert "1" in pins assert "2" in pins assert pins["1"]["y"] == pytest.approx(3.81) @@ -695,3 +697,236 @@ class TestExtractLibSymbols: )""") result = _extract_lib_symbols(sexp) assert result == {} + + def test_extract_includes_graphics_points(self): + """_extract_lib_symbols should return graphics_points from body shapes.""" + sexp = sexpdata.loads("""(kicad_sch + (lib_symbols + (symbol "Device:R" + (symbol "Device:R_0_1" + (rectangle (start -1.016 -2.54) (end 1.016 2.54) + (stroke (width 0.254) (type default)) + (fill (type none)))) + (symbol "Device:R_1_1" + (pin passive line (at 0 3.81 270) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27))))) + (pin passive line (at 0 -3.81 90) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27))))))) + ) + )""") + result = _extract_lib_symbols(sexp) + lib_data = result["Device:R"] + assert "graphics_points" in lib_data + gfx = lib_data["graphics_points"] + assert len(gfx) >= 2 + # Rectangle corners should be present + xs = [p[0] for p in gfx] + ys = [p[1] for p in gfx] + assert pytest.approx(-1.016) in xs + assert pytest.approx(1.016) in xs + assert pytest.approx(-2.54) in ys + assert pytest.approx(2.54) in ys + + +# =================================================================== +# Unit tests — _parse_lib_symbol_graphics +# =================================================================== + +class TestParseLibSymbolGraphics: + """Test graphics extraction from lib_symbol definitions.""" + + def test_rectangle(self): + sexp = sexpdata.loads("""(symbol "Device:R" + (symbol "Device:R_0_1" + (rectangle (start -1.016 -2.54) (end 1.016 2.54) + (stroke (width 0.254) (type default)) + (fill (type none)))))""") + pts = _parse_lib_symbol_graphics(sexp) + assert len(pts) == 2 + assert (-1.016, -2.54) in pts + assert (1.016, 2.54) in pts + + def test_polyline(self): + sexp = sexpdata.loads("""(symbol "Device:C" + (symbol "Device:C_0_1" + (polyline + (pts (xy -2.032 -0.762) (xy 2.032 -0.762)) + (stroke (width 0.508) (type default)) + (fill (type none)))))""") + pts = _parse_lib_symbol_graphics(sexp) + assert (-2.032, -0.762) in pts + assert (2.032, -0.762) in pts + + def test_circle(self): + sexp = sexpdata.loads("""(symbol "Test:Circle" + (symbol "Test:Circle_0_1" + (circle (center 0 0) (radius 5) + (stroke (width 0.254) (type default)) + (fill (type none)))))""") + pts = _parse_lib_symbol_graphics(sexp) + assert len(pts) == 2 + assert (-5.0, -5.0) in pts + assert (5.0, 5.0) in pts + + def test_arc(self): + sexp = sexpdata.loads("""(symbol "Test:Arc" + (symbol "Test:Arc_0_1" + (arc (start 1 0) (mid 0 1) (end -1 0) + (stroke (width 0.254) (type default)) + (fill (type none)))))""") + pts = _parse_lib_symbol_graphics(sexp) + assert (1.0, 0.0) in pts + assert (0.0, 1.0) in pts + assert (-1.0, 0.0) in pts + + def test_no_graphics(self): + sexp = sexpdata.loads("""(symbol "Test:Empty" + (symbol "Test:Empty_1_1" + (pin passive line (at 0 0 0) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))))))""") + pts = _parse_lib_symbol_graphics(sexp) + assert pts == [] + + +# =================================================================== +# Unit tests — _transform_local_point +# =================================================================== + +class TestTransformLocalPoint: + """Test local→absolute coordinate transform.""" + + def test_no_transform(self): + x, y = _transform_local_point(1.0, 2.0, 100.0, 200.0, 0, False, False) + assert x == pytest.approx(101.0) + assert y == pytest.approx(202.0) + + def test_mirror_x(self): + x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, True, False) + assert x == pytest.approx(1.0) + assert y == pytest.approx(-2.0) + + def test_mirror_y(self): + x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, False, True) + assert x == pytest.approx(-1.0) + assert y == pytest.approx(2.0) + + def test_rotation_90(self): + x, y = _transform_local_point(1.0, 0.0, 0.0, 0.0, 90, False, False) + assert x == pytest.approx(0.0, abs=1e-9) + assert y == pytest.approx(1.0, abs=1e-9) + + +# =================================================================== +# Unit tests — _compute_symbol_bbox_direct with graphics +# =================================================================== + +class TestComputeSymbolBboxWithGraphics: + """Test that bounding box computation uses graphics points when available.""" + + def test_resistor_bbox_from_graphics(self): + """Device:R rectangle is (-1.016, -2.54) to (1.016, 2.54) in local coords. + Pins at (0, ±3.81). Placed at (100, 100) with no rotation. + Bbox should span from pin-to-pin in Y and use rectangle width in X.""" + sym = {"x": 100.0, "y": 100.0, "rotation": 0, "mirror_x": False, "mirror_y": False} + pin_defs = { + "1": {"x": 0, "y": 3.81, "angle": 270, "length": 1.27, "name": "~", "type": "passive"}, + "2": {"x": 0, "y": -3.81, "angle": 90, "length": 1.27, "name": "~", "type": "passive"}, + } + graphics_points = [(-1.016, -2.54), (1.016, 2.54)] + + bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) + assert bbox is not None + min_x, min_y, max_x, max_y = bbox + # X should come from rectangle: 100 ± 1.016 + assert min_x == pytest.approx(100 - 1.016) + assert max_x == pytest.approx(100 + 1.016) + # Y should come from pins (extending beyond rectangle): 100 ± 3.81 + assert min_y == pytest.approx(100 - 3.81) + assert max_y == pytest.approx(100 + 3.81) + + def test_fallback_without_graphics(self): + """Without graphics_points, should use the old degenerate expansion.""" + sym = {"x": 100.0, "y": 100.0, "rotation": 0, "mirror_x": False, "mirror_y": False} + pin_defs = { + "1": {"x": 0, "y": 3.81, "angle": 270, "length": 1.27, "name": "~", "type": "passive"}, + "2": {"x": 0, "y": -3.81, "angle": 90, "length": 1.27, "name": "~", "type": "passive"}, + } + + bbox = _compute_symbol_bbox_direct(sym, pin_defs) + assert bbox is not None + min_x, min_y, max_x, max_y = bbox + # X should be expanded with min_body=1.5: 100 ± 1.5 + assert min_x == pytest.approx(100 - 1.5) + assert max_x == pytest.approx(100 + 1.5) + + def test_rotated_symbol_graphics(self): + """Graphics points should be rotated along with the symbol.""" + sym = {"x": 100.0, "y": 100.0, "rotation": 90, "mirror_x": False, "mirror_y": False} + pin_defs = { + "1": {"x": 0, "y": 3.81, "angle": 270, "length": 1.27, "name": "~", "type": "passive"}, + "2": {"x": 0, "y": -3.81, "angle": 90, "length": 1.27, "name": "~", "type": "passive"}, + } + # Rectangle corners in local coords + graphics_points = [(-1.016, -2.54), (1.016, 2.54)] + + bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) + assert bbox is not None + min_x, min_y, max_x, max_y = bbox + # After 90° rotation, X and Y swap roles + # Pins now extend along X: 100 ± 3.81 + # Rectangle now extends along Y: 100 ± 1.016 + assert min_x == pytest.approx(100 - 3.81, abs=0.01) + assert max_x == pytest.approx(100 + 3.81, abs=0.01) + + +@pytest.mark.integration +class TestIntegrationGraphicsBbox: + """Integration tests verifying graphics-based bbox from real template data.""" + + def test_resistor_bbox_uses_rectangle(self): + """The template's Device:R has a rectangle body. + Verify that the bbox for a placed resistor uses the actual + rectangle width rather than the degenerate 1.5mm expansion.""" + extra = _make_resistor_sexp("R1", 100, 100) + tmp = _make_temp_schematic(extra) + sexp_data = _load_sexp(tmp) + symbols = _parse_symbols(sexp_data) + lib_defs = _extract_lib_symbols(sexp_data) + + r1 = [s for s in symbols if s["reference"] == "R1"][0] + lib_data = lib_defs.get(r1["lib_id"], {}) + pin_defs = lib_data.get("pins", {}) + graphics_points = lib_data.get("graphics_points", []) + + assert len(graphics_points) >= 2, "Should have extracted rectangle points" + + bbox = _compute_symbol_bbox_direct(r1, pin_defs, graphics_points=graphics_points) + assert bbox is not None + min_x, min_y, max_x, max_y = bbox + # Rectangle is ±1.016 in X, NOT ±1.5 from degenerate expansion + assert max_x - min_x == pytest.approx(2 * 1.016, abs=0.01) + + def test_led_bbox_uses_polyline(self): + """The template's Device:LED uses polylines for its body. + Verify that the bbox uses polyline extents.""" + extra = _make_led_sexp("D1", 100, 100) + tmp = _make_temp_schematic(extra) + sexp_data = _load_sexp(tmp) + symbols = _parse_symbols(sexp_data) + lib_defs = _extract_lib_symbols(sexp_data) + + d1 = [s for s in symbols if s["reference"] == "D1"][0] + lib_data = lib_defs.get(d1["lib_id"], {}) + graphics_points = lib_data.get("graphics_points", []) + + assert len(graphics_points) >= 4, "Should have extracted polyline points" + # LED body polylines span from -1.27 to 1.27 in both X and Y + xs = [p[0] for p in graphics_points] + ys = [p[1] for p in graphics_points] + assert min(xs) == pytest.approx(-1.27) + assert max(xs) == pytest.approx(1.27) + assert min(ys) == pytest.approx(-1.27) + assert max(ys) == pytest.approx(1.27) From 1ef4ce5cab9541c6811bae095212c420f78c450f Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 15 Mar 2026 14:15:46 +0000 Subject: [PATCH 12/74] refactor: remove find_unconnected_pins tool (redundant with run_erc) KiCad's native ERC already checks for unconnected pins with better accuracy (hierarchical sheets, bus connections, custom rules). Remove the reimplemented version and its dead helper _parse_no_connects. Co-Authored-By: Claude Opus 4.6 --- python/commands/schematic_analysis.py | 115 ------------------------ python/kicad_interface.py | 24 ----- python/schemas/tool_schemas.py | 15 ---- python/tests/test_schematic_analysis.py | 50 ----------- src/tools/schematic.ts | 23 ----- 5 files changed, 227 deletions(-) diff --git a/python/commands/schematic_analysis.py b/python/commands/schematic_analysis.py index b56f427..7eab32b 100644 --- a/python/commands/schematic_analysis.py +++ b/python/commands/schematic_analysis.py @@ -135,20 +135,6 @@ def _parse_symbols(sexp_data: list) -> List[Dict[str, Any]]: return symbols -def _parse_no_connects(sexp_data: list) -> Set[Tuple[float, float]]: - """Parse all no_connect elements and return their positions as (x, y) tuples in mm.""" - positions: Set[Tuple[float, float]] = set() - for item in sexp_data: - if not isinstance(item, list) or len(item) < 2: - continue - if item[0] != Symbol("no_connect"): - continue - for sub in item: - if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("at"): - positions.add((float(sub[1]), float(sub[2]))) - break - return positions - def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]: """ @@ -434,107 +420,6 @@ def _compute_symbol_bbox_direct( return (min_x, min_y, max_x, max_y) -# --------------------------------------------------------------------------- -# Tool 2: find_unconnected_pins -# --------------------------------------------------------------------------- - -def find_unconnected_pins(schematic_path: Path) -> List[Dict[str, Any]]: - """ - Find all component pins with no wire, label, or power symbol touching them. - - Returns list of dicts: {reference, libId, pinNumber, pinName, position: {x, y}} - """ - sexp_data = _load_sexp(schematic_path) - symbols = _parse_symbols(sexp_data) - wires = _parse_wires(sexp_data) - labels = _parse_labels(sexp_data) - no_connects = _parse_no_connects(sexp_data) - - # Build set of "connected" positions in mm - connected: Set[Tuple[float, float]] = set() - - # Wire endpoints - for w in wires: - connected.add(w["start"]) - connected.add(w["end"]) - - # Label positions - for lbl in labels: - connected.add((lbl["x"], lbl["y"])) - - # Power symbol positions (they implicitly connect) - for sym in symbols: - if sym["is_power"]: - connected.add((sym["x"], sym["y"])) - - tolerance = 0.05 # mm - - def _snap(v: float) -> int: - """Snap coordinate to grid for O(1) set lookup.""" - return round(v / tolerance) - - connected_grid: set = set() - for pos in connected: - connected_grid.add((_snap(pos[0]), _snap(pos[1]))) - - no_connect_grid: set = set() - for pos in no_connects: - no_connect_grid.add((_snap(pos[0]), _snap(pos[1]))) - - def is_connected(px: float, py: float) -> bool: - sx, sy = _snap(px), _snap(py) - # Check the snapped cell and immediate neighbors to handle edge cases - for dx in (-1, 0, 1): - for dy in (-1, 0, 1): - if (sx + dx, sy + dy) in connected_grid: - return True - return False - - def is_no_connect(px: float, py: float) -> bool: - sx, sy = _snap(px), _snap(py) - for dx in (-1, 0, 1): - for dy in (-1, 0, 1): - if (sx + dx, sy + dy) in no_connect_grid: - return True - return False - - lib_defs = _extract_lib_symbols(sexp_data) - unconnected = [] - - for sym in symbols: - ref = sym["reference"] - # Skip power symbols, templates, and empty references - 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 - - pin_positions = _compute_pin_positions_direct(sym, pin_defs) - if not pin_positions: - continue - - for pin_num, pos in pin_positions.items(): - px, py = pos[0], pos[1] - - if is_no_connect(px, py): - continue - if is_connected(px, py): - continue - - pin_name = pin_defs.get(pin_num, {}).get("name", pin_num) - unconnected.append({ - "reference": ref, - "libId": sym["lib_id"], - "pinNumber": pin_num, - "pinName": pin_name, - "position": {"x": round(px, 4), "y": round(py, 4)}, - }) - - return unconnected - # --------------------------------------------------------------------------- # Tool 3: find_overlapping_elements diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 47d5f60..2c5ef16 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -400,7 +400,6 @@ class KiCADInterface: "export_schematic_svg": self._handle_export_schematic_svg, # Schematic analysis tools (read-only) "get_schematic_view_region": self._handle_get_schematic_view_region, - "find_unconnected_pins": self._handle_find_unconnected_pins, "find_overlapping_elements": self._handle_find_overlapping_elements, "get_elements_in_region": self._handle_get_elements_in_region, "find_wires_crossing_symbols": self._handle_find_wires_crossing_symbols, @@ -2660,29 +2659,6 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_find_unconnected_pins(self, params): - """Find all component pins with no wire, label, or power symbol touching them""" - logger.info("Finding unconnected pins in schematic") - try: - from pathlib import Path - from commands.schematic_analysis import find_unconnected_pins - - schematic_path = params.get("schematicPath") - if not schematic_path: - return {"success": False, "message": "schematicPath is required"} - - result = find_unconnected_pins(Path(schematic_path)) - return { - "success": True, - "unconnectedPins": result, - "count": len(result), - "message": f"Found {len(result)} unconnected pin(s)", - } - except Exception as e: - logger.error(f"Error finding unconnected pins: {e}") - import traceback - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} def _handle_find_overlapping_elements(self, params): """Detect spatially overlapping symbols, wires, and labels""" diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 98804b4..6eb3c4c 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1726,21 +1726,6 @@ SCHEMATIC_TOOLS = [ "required": ["schematicPath", "x1", "y1", "x2", "y2"] } }, - { - "name": "find_unconnected_pins", - "title": "Find Unconnected Pins", - "description": "Lists all component pins in the schematic that have no wire, label, or power symbol touching them. Useful for checking connectivity before running ERC. Skips power symbols, template symbols, and pins with no_connect flags.", - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Path to the .kicad_sch schematic file" - } - }, - "required": ["schematicPath"] - } - }, { "name": "find_overlapping_elements", "title": "Find Overlapping Elements", diff --git a/python/tests/test_schematic_analysis.py b/python/tests/test_schematic_analysis.py index 4f31446..b287f07 100644 --- a/python/tests/test_schematic_analysis.py +++ b/python/tests/test_schematic_analysis.py @@ -23,7 +23,6 @@ from commands.schematic_analysis import ( _parse_wires, _parse_labels, _parse_symbols, - _parse_no_connects, _load_sexp, _extract_lib_symbols, _parse_lib_symbol_graphics, @@ -35,7 +34,6 @@ from commands.schematic_analysis import ( _check_wire_overlap, _compute_symbol_bbox_direct, compute_symbol_bbox, - find_unconnected_pins, find_overlapping_elements, get_elements_in_region, find_wires_crossing_symbols, @@ -220,16 +218,6 @@ class TestSexpParsers: assert symbols[1]["reference"] == "#PWR01" assert symbols[1]["is_power"] is True - def test_parse_no_connects(self): - sexp = sexpdata.loads("""(kicad_sch - (no_connect (at 10 20) (uuid "x")) - (no_connect (at 30 40) (uuid "y")) - )""") - nc = _parse_no_connects(sexp) - assert (10.0, 20.0) in nc - assert (30.0, 40.0) in nc - assert len(nc) == 2 - # =================================================================== # Unit tests — analysis functions with mocked PinLocator @@ -376,44 +364,6 @@ class TestComputeSymbolBbox: # Integration tests — full schematic parsing # =================================================================== -@pytest.mark.integration -class TestIntegrationFindUnconnectedPins: - """Integration test using real schematic files.""" - - def test_component_with_no_wires_has_unconnected_pins(self): - """A resistor placed with no wires should have 2 unconnected pins.""" - extra = _make_resistor_sexp("R1", 100, 100) - tmp = _make_temp_schematic(extra) - result = find_unconnected_pins(tmp) - r1_pins = [p for p in result if p["reference"] == "R1"] - assert len(r1_pins) == 2 - - def test_pin_with_wire_is_connected(self): - """A wire endpoint exactly at a pin position should mark it connected.""" - # R1 at (100,100), rotation 0 → pin 1 at (100, 103.81), pin 2 at (100, 96.19) - extra = _make_resistor_sexp("R1", 100, 100) + """ - (wire (pts (xy 100 103.81) (xy 100 120)) - (stroke (width 0) (type default)) - (uuid "w1")) - """ - tmp = _make_temp_schematic(extra) - result = find_unconnected_pins(tmp) - r1_pins = [p for p in result if p["reference"] == "R1"] - # Pin 1 should be connected (wire at 100, 103.81), pin 2 still unconnected - assert len(r1_pins) == 1 - assert r1_pins[0]["pinNumber"] == "2" - - def test_no_connect_suppresses_pin(self): - """A no_connect at a pin position should not report it as unconnected.""" - extra = _make_resistor_sexp("R1", 100, 100) + """ - (no_connect (at 100 96.19) (uuid "nc1")) - (no_connect (at 100 103.81) (uuid "nc2")) - """ - tmp = _make_temp_schematic(extra) - result = find_unconnected_pins(tmp) - r1_pins = [p for p in result if p["reference"] == "R1"] - assert len(r1_pins) == 0 - @pytest.mark.integration class TestIntegrationFindWiresCrossingSymbols: diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index 262da24..ef8fb67 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -1135,29 +1135,6 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, ); - // Find unconnected pins - server.tool( - "find_unconnected_pins", - "List all component pins in the schematic that have no wire, label, or power symbol touching them. Useful for checking connectivity before running ERC.", - { - schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), - }, - async (args: { schematicPath: string }) => { - const result = await callKicadScript("find_unconnected_pins", args); - if (result.success) { - const pins: any[] = result.unconnectedPins || []; - const lines = [`Found ${pins.length} unconnected pin(s):`]; - pins.slice(0, 50).forEach((p: any) => { - lines.push(` ${p.reference} pin ${p.pinNumber} (${p.pinName}) @ (${p.position.x}, ${p.position.y})`); - }); - if (pins.length > 50) lines.push(` ... and ${pins.length - 50} more`); - return { content: [{ type: "text", text: lines.join("\n") }] }; - } - return { - content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }], - }; - }, - ); // Find overlapping elements server.tool( From be11948a44ddd60c1f15869335520c6183dc3824 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 15 Mar 2026 14:37:35 +0000 Subject: [PATCH 13/74] fix: negate y-axis in graphics transform for correct symbol bounding boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Library symbols use y-up coordinates while schematics use y-down. The _transform_local_point function was not negating y, causing asymmetric symbols (e.g. power:VEE) to have their bounding boxes computed in the wrong direction — missing overlaps with adjacent components. Co-Authored-By: Claude Opus 4.6 --- python/commands/schematic_analysis.py | 6 +++++- python/tests/test_schematic_analysis.py | 9 ++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/python/commands/schematic_analysis.py b/python/commands/schematic_analysis.py index 7eab32b..487db15 100644 --- a/python/commands/schematic_analysis.py +++ b/python/commands/schematic_analysis.py @@ -332,8 +332,12 @@ def _transform_local_point( ) -> Tuple[float, float]: """ Transform a point from local symbol coordinates to absolute schematic - coordinates using KiCad's transform order: mirror → rotate → translate. + 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 diff --git a/python/tests/test_schematic_analysis.py b/python/tests/test_schematic_analysis.py index b287f07..c7c4bcd 100644 --- a/python/tests/test_schematic_analysis.py +++ b/python/tests/test_schematic_analysis.py @@ -749,21 +749,24 @@ class TestTransformLocalPoint: """Test local→absolute coordinate transform.""" def test_no_transform(self): + # ly is negated (lib y-up → schematic y-down) x, y = _transform_local_point(1.0, 2.0, 100.0, 200.0, 0, False, False) assert x == pytest.approx(101.0) - assert y == pytest.approx(202.0) + assert y == pytest.approx(198.0) def test_mirror_x(self): + # y-negate then mirror_x cancel out → net ly unchanged x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, True, False) assert x == pytest.approx(1.0) - assert y == pytest.approx(-2.0) + assert y == pytest.approx(2.0) def test_mirror_y(self): x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, False, True) assert x == pytest.approx(-1.0) - assert y == pytest.approx(2.0) + assert y == pytest.approx(-2.0) def test_rotation_90(self): + # ly=0 negated is still 0, then rotate lx=1 by 90° x, y = _transform_local_point(1.0, 0.0, 0.0, 0.0, 90, False, False) assert x == pytest.approx(0.0, abs=1e-9) assert y == pytest.approx(1.0, abs=1e-9) From 59bd4c4acfd4545c1c365db3dcad47ac3ba1f949 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 15 Mar 2026 23:17:25 +0000 Subject: [PATCH 14/74] style: apply Black formatting to changed files Co-Authored-By: Claude Sonnet 4.6 --- python/commands/schematic_analysis.py | 215 ++++-- python/kicad_interface.py | 36 +- python/schemas/tool_schemas.py | 954 +++++++++++------------- python/tests/conftest.py | 1 + python/tests/test_schematic_analysis.py | 135 +++- 5 files changed, 731 insertions(+), 610 deletions(-) diff --git a/python/commands/schematic_analysis.py b/python/commands/schematic_analysis.py index 487db15..09d284d 100644 --- a/python/commands/schematic_analysis.py +++ b/python/commands/schematic_analysis.py @@ -22,6 +22,7 @@ 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: @@ -122,20 +123,21 @@ def _parse_symbols(sexp_data: list) -> List[Dict[str, Any]]: 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, - }) + 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 @@ -167,22 +169,36 @@ def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]: 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"): + 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"): + 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"): + 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), - ]) + points.extend( + [ + (cx - r, cy - r), + (cx + r, cy + r), + ] + ) elif tag == Symbol("arc"): # (arc (start x y) (mid x y) (end x y) ...) @@ -196,7 +212,11 @@ def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]: 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"): + if ( + isinstance(pt, list) + and len(pt) >= 3 + and pt[0] == Symbol("xy") + ): points.append((float(pt[1]), float(pt[2]))) else: @@ -223,8 +243,11 @@ def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict]: """ lib_symbols_section = None for item in sexp_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_section = item break @@ -233,8 +256,7 @@ def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict]: result: Dict[str, Dict] = {} for item in lib_symbols_section[1:]: - 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('"') result[symbol_name] = { "pins": PinLocator.parse_symbol_definition(item), @@ -247,6 +269,7 @@ def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict]: # Geometry helpers # --------------------------------------------------------------------------- + def compute_symbol_bbox( schematic_path: Path, reference: str, @@ -266,8 +289,14 @@ def compute_symbol_bbox( 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, + 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. @@ -301,8 +330,12 @@ def _line_segment_intersects_aabb( def _point_in_rect( - px: float, py: float, - min_x: float, min_y: float, max_x: float, max_y: float, + 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 @@ -325,10 +358,13 @@ def _aabb_overlap( def _transform_local_point( - lx: float, ly: float, - sym_x: float, sym_y: float, + lx: float, + ly: float, + sym_x: float, + sym_y: float, rotation: float, - mirror_x: bool, mirror_y: bool, + mirror_x: bool, + mirror_y: bool, ) -> Tuple[float, float]: """ Transform a point from local symbol coordinates to absolute schematic @@ -424,11 +460,11 @@ def _compute_symbol_bbox_direct( 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]: @@ -453,7 +489,11 @@ def find_overlapping_elements( 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"]] + 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 = [] @@ -463,7 +503,9 @@ def find_overlapping_elements( graphics_points = lib_data.get("graphics_points", []) bbox = None if pin_defs: - bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) + bbox = _compute_symbol_bbox_direct( + sym, pin_defs, graphics_points=graphics_points + ) symbol_bboxes.append((sym, bbox)) for i in range(len(symbol_bboxes)): @@ -482,10 +524,16 @@ def find_overlapping_elements( 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"]}}, + "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 @@ -502,13 +550,21 @@ def find_overlapping_elements( 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), - }) + 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)): @@ -574,8 +630,14 @@ def _check_wire_overlap( 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]}}, + "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", } @@ -586,9 +648,13 @@ def _check_wire_overlap( # Tool 4: get_elements_in_region # --------------------------------------------------------------------------- + def get_elements_in_region( schematic_path: Path, - x1: float, y1: float, x2: float, y2: float, + x1: float, + y1: float, + x2: float, + y2: float, ) -> Dict[str, Any]: """ List all wires, labels, and symbols within a rectangular region. @@ -637,23 +703,31 @@ def get_elements_in_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]}, - }) + 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"]}, - }) + region_labels.append( + { + "name": lbl["name"], + "type": lbl["type"], + "position": {"x": lbl["x"], "y": lbl["y"]}, + } + ) return { "symbols": region_symbols, @@ -671,6 +745,7 @@ def get_elements_in_region( # Tool 5: check_wire_collisions # --------------------------------------------------------------------------- + def _compute_pin_positions_direct( sym: Dict[str, Any], pin_defs: Dict[str, Dict] ) -> Dict[str, List[float]]: @@ -748,7 +823,9 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]: continue graphics_points = lib_data.get("graphics_points", []) - bbox = _compute_symbol_bbox_direct(sym, pin_defs, margin=margin, graphics_points=graphics_points) + bbox = _compute_symbol_bbox_direct( + sym, pin_defs, margin=margin, graphics_points=graphics_points + ) if bbox is None: continue @@ -757,11 +834,13 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]: for pos in pin_positions.values(): pin_set.add((pos[0], pos[1])) - symbol_data.append({ - "sym": sym, - "bbox": bbox, - "pin_set": pin_set, - }) + symbol_data.append( + { + "sym": sym, + "bbox": bbox, + "pin_set": pin_set, + } + ) # Test each wire against each symbol bbox for w in wires: @@ -810,7 +889,8 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]: continue # Wire terminates at pin from outside sym = sd["sym"] - collisions.append({ + collisions.append( + { "wire": { "start": {"x": sx, "y": sy}, "end": {"x": ex, "y": ey}, @@ -821,6 +901,7 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]: "position": {"x": sym["x"], "y": sym["y"]}, }, "intersectionType": "passes_through", - }) + } + ) return collisions diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 2c5ef16..7a92cbe 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -2597,19 +2597,34 @@ class KiCADInterface: svg_output = None try: - cmd = [kicad_cli, "sch", "export", "svg", "--output", tmp_dir, schematic_path] + cmd = [ + kicad_cli, + "sch", + "export", + "svg", + "--output", + tmp_dir, + schematic_path, + ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if result.returncode != 0: - return {"success": False, "message": f"SVG export failed: {result.stderr}"} + return { + "success": False, + "message": f"SVG export failed: {result.stderr}", + } # kicad-cli names the file after the schematic svg_files = [f for f in os.listdir(tmp_dir) if f.endswith(".svg")] if not svg_files: - return {"success": False, "message": "kicad-cli produced no SVG output"} + return { + "success": False, + "message": "kicad-cli produced no SVG output", + } svg_output = os.path.join(tmp_dir, svg_files[0]) import xml.etree.ElementTree as ET + tree = ET.parse(svg_output) root = tree.getroot() @@ -2642,8 +2657,13 @@ class KiCADInterface: try: from cairosvg import svg2png except ImportError: - return {"success": False, "message": "PNG export requires the 'cairosvg' package. Install it with: pip install cairosvg"} - png_data = svg2png(url=cropped_svg_path, output_width=width, output_height=height) + return { + "success": False, + "message": "PNG export requires the 'cairosvg' package. Install it with: pip install cairosvg", + } + png_data = svg2png( + url=cropped_svg_path, output_width=width, output_height=height + ) return { "success": True, "imageData": base64.b64encode(png_data).decode("utf-8"), @@ -2651,15 +2671,16 @@ class KiCADInterface: } finally: import shutil + shutil.rmtree(tmp_dir, ignore_errors=True) except Exception as e: logger.error(f"Error in get_schematic_view_region: {e}") import traceback + logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_find_overlapping_elements(self, params): """Detect spatially overlapping symbols, wires, and labels""" logger.info("Finding overlapping elements in schematic") @@ -2681,6 +2702,7 @@ class KiCADInterface: except Exception as e: logger.error(f"Error finding overlapping elements: {e}") import traceback + logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} @@ -2709,6 +2731,7 @@ class KiCADInterface: except Exception as e: logger.error(f"Error getting elements in region: {e}") import traceback + logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} @@ -2733,6 +2756,7 @@ class KiCADInterface: except Exception as e: logger.error(f"Error checking wire collisions: {e}") import traceback + logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 6eb3c4c..d71a50c 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -27,19 +27,19 @@ PROJECT_TOOLS = [ "projectName": { "type": "string", "description": "Name of the project (used for file naming)", - "minLength": 1 + "minLength": 1, }, "path": { "type": "string", - "description": "Directory path where project will be created (defaults to current working directory)" + "description": "Directory path where project will be created (defaults to current working directory)", }, "template": { "type": "string", - "description": "Optional path to template board file to copy settings from" - } + "description": "Optional path to template board file to copy settings from", + }, }, - "required": ["projectName"] - } + "required": ["projectName"], + }, }, { "name": "open_project", @@ -50,11 +50,11 @@ PROJECT_TOOLS = [ "properties": { "filename": { "type": "string", - "description": "Path to .kicad_pro or .kicad_pcb file" + "description": "Path to .kicad_pro or .kicad_pcb file", } }, - "required": ["filename"] - } + "required": ["filename"], + }, }, { "name": "save_project", @@ -65,10 +65,10 @@ PROJECT_TOOLS = [ "properties": { "filename": { "type": "string", - "description": "Optional new path to save the board (if not provided, saves to current location)" + "description": "Optional new path to save the board (if not provided, saves to current location)", } - } - } + }, + }, }, { "name": "snapshot_project", @@ -79,28 +79,25 @@ PROJECT_TOOLS = [ "properties": { "step": { "type": "string", - "description": "Step number or name to include in snapshot folder name, e.g. '1' or '2'" + "description": "Step number or name to include in snapshot folder name, e.g. '1' or '2'", }, "label": { "type": "string", - "description": "Optional short label, e.g. 'schematic_ok' or 'layout_ok'" + "description": "Optional short label, e.g. 'schematic_ok' or 'layout_ok'", }, "projectPath": { "type": "string", - "description": "Project directory path. Auto-detected from loaded board if omitted." - } - } - } + "description": "Project directory path. Auto-detected from loaded board if omitted.", + }, + }, + }, }, { "name": "get_project_info", "title": "Get Project Information", "description": "Retrieves metadata and properties of the currently open project including name, paths, and board status.", - "inputSchema": { - "type": "object", - "properties": {} - } - } + "inputSchema": {"type": "object", "properties": {}}, + }, ] # ============================================================================= @@ -118,16 +115,16 @@ BOARD_TOOLS = [ "width": { "type": "number", "description": "Board width in millimeters", - "minimum": 1 + "minimum": 1, }, "height": { "type": "number", "description": "Board height in millimeters", - "minimum": 1 - } + "minimum": 1, + }, }, - "required": ["width", "height"] - } + "required": ["width", "height"], + }, }, { "name": "add_board_outline", @@ -139,44 +136,47 @@ BOARD_TOOLS = [ "shape": { "type": "string", "enum": ["rectangle", "rounded_rectangle", "circle", "polygon"], - "description": "Shape type for the board outline" + "description": "Shape type for the board outline", }, "width": { "type": "number", "description": "Width in mm (for rectangle/rounded_rectangle)", - "minimum": 1 + "minimum": 1, }, "height": { "type": "number", "description": "Height in mm (for rectangle/rounded_rectangle)", - "minimum": 1 + "minimum": 1, }, "x": { "type": "number", - "description": "X coordinate of the top-left corner in mm (default: 0). Board extends from x to x+width." + "description": "X coordinate of the top-left corner in mm (default: 0). Board extends from x to x+width.", }, "y": { "type": "number", - "description": "Y coordinate of the top-left corner in mm (default: 0). Board extends from y to y+height." + "description": "Y coordinate of the top-left corner in mm (default: 0). Board extends from y to y+height.", }, "radius": { "type": "number", "description": "Corner radius in mm for rounded_rectangle, or radius for circle", - "minimum": 0 + "minimum": 0, }, "points": { "type": "array", "description": "Array of {x, y} point objects in mm (for polygon shape only)", "items": { "type": "object", - "properties": {"x": {"type": "number"}, "y": {"type": "number"}}, - "required": ["x", "y"] + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + }, + "required": ["x", "y"], }, - "minItems": 3 - } + "minItems": 3, + }, }, - "required": ["shape"] - } + "required": ["shape"], + }, }, { "name": "add_layer", @@ -187,16 +187,16 @@ BOARD_TOOLS = [ "properties": { "layerName": { "type": "string", - "description": "Name of the layer to add" + "description": "Name of the layer to add", }, "layerType": { "type": "string", "enum": ["signal", "power", "mixed", "jumper"], - "description": "Type of layer (for copper layers)" - } + "description": "Type of layer (for copper layers)", + }, }, - "required": ["layerName"] - } + "required": ["layerName"], + }, }, { "name": "set_active_layer", @@ -207,29 +207,23 @@ BOARD_TOOLS = [ "properties": { "layerName": { "type": "string", - "description": "Name of the layer to make active (e.g., F.Cu, B.Cu, Edge.Cuts)" + "description": "Name of the layer to make active (e.g., F.Cu, B.Cu, Edge.Cuts)", } }, - "required": ["layerName"] - } + "required": ["layerName"], + }, }, { "name": "get_layer_list", "title": "List Board Layers", "description": "Returns a list of all layers in the board with their properties.", - "inputSchema": { - "type": "object", - "properties": {} - } + "inputSchema": {"type": "object", "properties": {}}, }, { "name": "get_board_info", "title": "Get Board Information", "description": "Retrieves comprehensive board information including dimensions, layer count, component count, and design rules.", - "inputSchema": { - "type": "object", - "properties": {} - } + "inputSchema": {"type": "object", "properties": {}}, }, { "name": "get_board_2d_view", @@ -242,16 +236,16 @@ BOARD_TOOLS = [ "type": "number", "description": "Image width in pixels (default: 800)", "minimum": 100, - "default": 800 + "default": 800, }, "height": { "type": "number", "description": "Image height in pixels (default: 600)", "minimum": 100, - "default": 600 - } - } - } + "default": 600, + }, + }, + }, }, { "name": "get_board_extents", @@ -264,10 +258,10 @@ BOARD_TOOLS = [ "type": "string", "enum": ["mm", "inch"], "description": "Unit for returned coordinates (default: mm)", - "default": "mm" + "default": "mm", } - } - } + }, + }, }, { "name": "add_mounting_hole", @@ -276,22 +270,16 @@ BOARD_TOOLS = [ "inputSchema": { "type": "object", "properties": { - "x": { - "type": "number", - "description": "X coordinate in millimeters" - }, - "y": { - "type": "number", - "description": "Y coordinate in millimeters" - }, + "x": {"type": "number", "description": "X coordinate in millimeters"}, + "y": {"type": "number", "description": "Y coordinate in millimeters"}, "diameter": { "type": "number", "description": "Hole diameter in millimeters", - "minimum": 0.1 - } + "minimum": 0.1, + }, }, - "required": ["x", "y", "diameter"] - } + "required": ["x", "y", "diameter"], + }, }, { "name": "import_svg_logo", @@ -302,43 +290,43 @@ BOARD_TOOLS = [ "properties": { "pcbPath": { "type": "string", - "description": "Path to the .kicad_pcb file" + "description": "Path to the .kicad_pcb file", }, "svgPath": { "type": "string", - "description": "Path to the SVG logo file" + "description": "Path to the SVG logo file", }, "x": { "type": "number", - "description": "X position of the logo top-left corner in mm" + "description": "X position of the logo top-left corner in mm", }, "y": { "type": "number", - "description": "Y position of the logo top-left corner in mm" + "description": "Y position of the logo top-left corner in mm", }, "width": { "type": "number", "description": "Target width of the logo in mm (height scaled to preserve aspect ratio)", - "minimum": 0.1 + "minimum": 0.1, }, "layer": { "type": "string", "description": "PCB layer name, e.g. F.SilkS or B.SilkS (default: F.SilkS)", - "default": "F.SilkS" + "default": "F.SilkS", }, "strokeWidth": { "type": "number", "description": "Outline stroke width in mm (0 = no outline, default 0)", - "default": 0 + "default": 0, }, "filled": { "type": "boolean", "description": "Fill polygons with solid layer colour (default true)", - "default": True - } + "default": True, + }, }, - "required": ["pcbPath", "svgPath", "x", "y", "width"] - } + "required": ["pcbPath", "svgPath", "x", "y", "width"], + }, }, { "name": "add_board_text", @@ -350,37 +338,31 @@ BOARD_TOOLS = [ "text": { "type": "string", "description": "Text content to add", - "minLength": 1 - }, - "x": { - "type": "number", - "description": "X coordinate in millimeters" - }, - "y": { - "type": "number", - "description": "Y coordinate in millimeters" + "minLength": 1, }, + "x": {"type": "number", "description": "X coordinate in millimeters"}, + "y": {"type": "number", "description": "Y coordinate in millimeters"}, "layer": { "type": "string", "description": "Layer name (e.g., F.SilkS, B.SilkS, F.Cu)", - "default": "F.SilkS" + "default": "F.SilkS", }, "size": { "type": "number", "description": "Text size in millimeters", "minimum": 0.1, - "default": 1.0 + "default": 1.0, }, "thickness": { "type": "number", "description": "Text thickness in millimeters", "minimum": 0.01, - "default": 0.15 - } + "default": 0.15, + }, }, - "required": ["text", "x", "y"] - } - } + "required": ["text", "x", "y"], + }, + }, ] # ============================================================================= @@ -397,36 +379,30 @@ COMPONENT_TOOLS = [ "properties": { "reference": { "type": "string", - "description": "Component reference designator (e.g., R1, C2, U3)" + "description": "Component reference designator (e.g., R1, C2, U3)", }, "footprint": { "type": "string", - "description": "Footprint library:name (e.g., Resistor_SMD:R_0805_2012Metric)" - }, - "x": { - "type": "number", - "description": "X coordinate in millimeters" - }, - "y": { - "type": "number", - "description": "Y coordinate in millimeters" + "description": "Footprint library:name (e.g., Resistor_SMD:R_0805_2012Metric)", }, + "x": {"type": "number", "description": "X coordinate in millimeters"}, + "y": {"type": "number", "description": "Y coordinate in millimeters"}, "rotation": { "type": "number", "description": "Rotation angle in degrees (0-360)", "minimum": 0, "maximum": 360, - "default": 0 + "default": 0, }, "layer": { "type": "string", "enum": ["F.Cu", "B.Cu"], "description": "Board layer (top or bottom)", - "default": "F.Cu" - } + "default": "F.Cu", + }, }, - "required": ["reference", "footprint", "x", "y"] - } + "required": ["reference", "footprint", "x", "y"], + }, }, { "name": "move_component", @@ -437,19 +413,19 @@ COMPONENT_TOOLS = [ "properties": { "reference": { "type": "string", - "description": "Component reference designator" + "description": "Component reference designator", }, "x": { "type": "number", - "description": "New X coordinate in millimeters" + "description": "New X coordinate in millimeters", }, "y": { "type": "number", - "description": "New Y coordinate in millimeters" - } + "description": "New Y coordinate in millimeters", + }, }, - "required": ["reference", "x", "y"] - } + "required": ["reference", "x", "y"], + }, }, { "name": "rotate_component", @@ -460,15 +436,15 @@ COMPONENT_TOOLS = [ "properties": { "reference": { "type": "string", - "description": "Component reference designator" + "description": "Component reference designator", }, "angle": { "type": "number", - "description": "Rotation angle in degrees (positive = counterclockwise)" - } + "description": "Rotation angle in degrees (positive = counterclockwise)", + }, }, - "required": ["reference", "angle"] - } + "required": ["reference", "angle"], + }, }, { "name": "delete_component", @@ -479,11 +455,11 @@ COMPONENT_TOOLS = [ "properties": { "reference": { "type": "string", - "description": "Component reference designator" + "description": "Component reference designator", } }, - "required": ["reference"] - } + "required": ["reference"], + }, }, { "name": "edit_component", @@ -494,19 +470,16 @@ COMPONENT_TOOLS = [ "properties": { "reference": { "type": "string", - "description": "Component reference designator" - }, - "value": { - "type": "string", - "description": "New component value" + "description": "Component reference designator", }, + "value": {"type": "string", "description": "New component value"}, "footprint": { "type": "string", - "description": "New footprint library:name" - } + "description": "New footprint library:name", + }, }, - "required": ["reference"] - } + "required": ["reference"], + }, }, { "name": "get_component_properties", @@ -517,20 +490,17 @@ COMPONENT_TOOLS = [ "properties": { "reference": { "type": "string", - "description": "Component reference designator" + "description": "Component reference designator", } }, - "required": ["reference"] - } + "required": ["reference"], + }, }, { "name": "get_component_list", "title": "List All Components", "description": "Returns a list of all components on the board with their properties.", - "inputSchema": { - "type": "object", - "properties": {} - } + "inputSchema": {"type": "object", "properties": {}}, }, { "name": "find_component", @@ -541,18 +511,18 @@ COMPONENT_TOOLS = [ "properties": { "reference": { "type": "string", - "description": "Reference designator pattern to match (e.g., 'R1', 'U', 'C2')" + "description": "Reference designator pattern to match (e.g., 'R1', 'U', 'C2')", }, "value": { "type": "string", - "description": "Value pattern to match (e.g., '10k', '100nF')" + "description": "Value pattern to match (e.g., '10k', '100nF')", }, "footprint": { "type": "string", - "description": "Footprint pattern to match (e.g., '0805', 'SOIC')" - } - } - } + "description": "Footprint pattern to match (e.g., '0805', 'SOIC')", + }, + }, + }, }, { "name": "get_component_pads", @@ -563,11 +533,11 @@ COMPONENT_TOOLS = [ "properties": { "reference": { "type": "string", - "description": "Component reference designator (e.g., U1, R5)" + "description": "Component reference designator (e.g., U1, R5)", } }, - "required": ["reference"] - } + "required": ["reference"], + }, }, { "name": "get_pad_position", @@ -578,19 +548,19 @@ COMPONENT_TOOLS = [ "properties": { "reference": { "type": "string", - "description": "Component reference designator" + "description": "Component reference designator", }, "padName": { "type": "string", - "description": "Pad name or number (e.g., '1', '2', 'A1')" + "description": "Pad name or number (e.g., '1', '2', 'A1')", }, "padNumber": { "type": "string", - "description": "Alternative to padName - pad number" - } + "description": "Alternative to padName - pad number", + }, }, - "required": ["reference"] - } + "required": ["reference"], + }, }, { "name": "place_component_array", @@ -601,61 +571,68 @@ COMPONENT_TOOLS = [ "properties": { "referencePrefix": { "type": "string", - "description": "Reference prefix (e.g., 'R' for R1, R2, R3...)" + "description": "Reference prefix (e.g., 'R' for R1, R2, R3...)", }, "startNumber": { "type": "integer", "description": "Starting number for references", "minimum": 1, - "default": 1 + "default": 1, }, "footprint": { "type": "string", - "description": "Footprint library:name" + "description": "Footprint library:name", }, "pattern": { "type": "string", "enum": ["grid", "circular"], - "description": "Array pattern type" + "description": "Array pattern type", }, "count": { "type": "integer", "description": "Total number of components to place", - "minimum": 1 + "minimum": 1, }, "startX": { "type": "number", - "description": "Starting X coordinate in millimeters" + "description": "Starting X coordinate in millimeters", }, "startY": { "type": "number", - "description": "Starting Y coordinate in millimeters" + "description": "Starting Y coordinate in millimeters", }, "spacingX": { "type": "number", - "description": "Horizontal spacing in mm (for grid pattern)" + "description": "Horizontal spacing in mm (for grid pattern)", }, "spacingY": { "type": "number", - "description": "Vertical spacing in mm (for grid pattern)" + "description": "Vertical spacing in mm (for grid pattern)", }, "radius": { "type": "number", - "description": "Circle radius in mm (for circular pattern)" + "description": "Circle radius in mm (for circular pattern)", }, "rows": { "type": "integer", "description": "Number of rows (for grid pattern)", - "minimum": 1 + "minimum": 1, }, "columns": { "type": "integer", "description": "Number of columns (for grid pattern)", - "minimum": 1 - } + "minimum": 1, + }, }, - "required": ["referencePrefix", "footprint", "pattern", "count", "startX", "startY"] - } + "required": [ + "referencePrefix", + "footprint", + "pattern", + "count", + "startX", + "startY", + ], + }, }, { "name": "align_components", @@ -668,20 +645,20 @@ COMPONENT_TOOLS = [ "type": "array", "description": "Array of component reference designators to align", "items": {"type": "string"}, - "minItems": 2 + "minItems": 2, }, "direction": { "type": "string", "enum": ["horizontal", "vertical"], - "description": "Alignment direction" + "description": "Alignment direction", }, "spacing": { "type": "number", - "description": "Spacing between components in mm (optional, for even distribution)" - } + "description": "Spacing between components in mm (optional, for even distribution)", + }, }, - "required": ["references", "direction"] - } + "required": ["references", "direction"], + }, }, { "name": "duplicate_component", @@ -692,26 +669,26 @@ COMPONENT_TOOLS = [ "properties": { "sourceReference": { "type": "string", - "description": "Reference of component to duplicate" + "description": "Reference of component to duplicate", }, "newReference": { "type": "string", - "description": "Reference designator for the new component" + "description": "Reference designator for the new component", }, "offsetX": { "type": "number", "description": "X offset from original position in mm", - "default": 0 + "default": 0, }, "offsetY": { "type": "number", "description": "Y offset from original position in mm", - "default": 0 - } + "default": 0, + }, }, - "required": ["sourceReference", "newReference"] - } - } + "required": ["sourceReference", "newReference"], + }, + }, ] # ============================================================================= @@ -729,15 +706,15 @@ ROUTING_TOOLS = [ "netName": { "type": "string", "description": "Name of the net (e.g., VCC, GND, SDA)", - "minLength": 1 + "minLength": 1, }, "netClass": { "type": "string", - "description": "Optional net class to assign (must exist first)" - } + "description": "Optional net class to assign (must exist first)", + }, }, - "required": ["netName"] - } + "required": ["netName"], + }, }, { "name": "route_trace", @@ -746,19 +723,16 @@ ROUTING_TOOLS = [ "inputSchema": { "type": "object", "properties": { - "netName": { - "type": "string", - "description": "Net name for this trace" - }, + "netName": {"type": "string", "description": "Net name for this trace"}, "layer": { "type": "string", "description": "Layer to route on (e.g., F.Cu, B.Cu)", - "default": "F.Cu" + "default": "F.Cu", }, "width": { "type": "number", "description": "Trace width in millimeters", - "minimum": 0.1 + "minimum": 0.1, }, "points": { "type": "array", @@ -767,13 +741,13 @@ ROUTING_TOOLS = [ "type": "array", "items": {"type": "number"}, "minItems": 2, - "maxItems": 2 + "maxItems": 2, }, - "minItems": 2 - } + "minItems": 2, + }, }, - "required": ["points", "width"] - } + "required": ["points", "width"], + }, }, { "name": "add_via", @@ -782,31 +756,25 @@ ROUTING_TOOLS = [ "inputSchema": { "type": "object", "properties": { - "x": { - "type": "number", - "description": "X coordinate in millimeters" - }, - "y": { - "type": "number", - "description": "Y coordinate in millimeters" - }, + "x": {"type": "number", "description": "X coordinate in millimeters"}, + "y": {"type": "number", "description": "Y coordinate in millimeters"}, "diameter": { "type": "number", "description": "Via diameter in millimeters", - "minimum": 0.1 + "minimum": 0.1, }, "drill": { "type": "number", "description": "Drill diameter in millimeters", - "minimum": 0.1 + "minimum": 0.1, }, "netName": { "type": "string", - "description": "Net name to assign to this via" - } + "description": "Net name to assign to this via", + }, }, - "required": ["x", "y", "diameter", "drill"] - } + "required": ["x", "y", "diameter", "drill"], + }, }, { "name": "delete_trace", @@ -817,7 +785,7 @@ ROUTING_TOOLS = [ "properties": { "uuid": { "type": "string", - "description": "UUID of a specific trace to delete" + "description": "UUID of a specific trace to delete", }, "position": { "type": "object", @@ -825,25 +793,29 @@ ROUTING_TOOLS = [ "properties": { "x": {"type": "number", "description": "X coordinate"}, "y": {"type": "number", "description": "Y coordinate"}, - "unit": {"type": "string", "enum": ["mm", "inch"], "default": "mm"} + "unit": { + "type": "string", + "enum": ["mm", "inch"], + "default": "mm", + }, }, - "required": ["x", "y"] + "required": ["x", "y"], }, "net": { "type": "string", - "description": "Delete all traces on this net (bulk delete)" + "description": "Delete all traces on this net (bulk delete)", }, "layer": { "type": "string", - "description": "Filter by layer when using net-based deletion" + "description": "Filter by layer when using net-based deletion", }, "includeVias": { "type": "boolean", "description": "Include vias in net-based deletion", - "default": False - } - } - } + "default": False, + }, + }, + }, }, { "name": "query_traces", @@ -854,11 +826,11 @@ ROUTING_TOOLS = [ "properties": { "net": { "type": "string", - "description": "Filter by net name (e.g., 'GND', 'VCC')" + "description": "Filter by net name (e.g., 'GND', 'VCC')", }, "layer": { "type": "string", - "description": "Filter by layer name (e.g., 'F.Cu', 'B.Cu')" + "description": "Filter by layer name (e.g., 'F.Cu', 'B.Cu')", }, "boundingBox": { "type": "object", @@ -868,16 +840,20 @@ ROUTING_TOOLS = [ "y1": {"type": "number", "description": "Top Y coordinate"}, "x2": {"type": "number", "description": "Right X coordinate"}, "y2": {"type": "number", "description": "Bottom Y coordinate"}, - "unit": {"type": "string", "enum": ["mm", "inch"], "default": "mm"} - } + "unit": { + "type": "string", + "enum": ["mm", "inch"], + "default": "mm", + }, + }, }, "includeVias": { "type": "boolean", "description": "Include vias in the result", - "default": False - } - } - } + "default": False, + }, + }, + }, }, { "name": "modify_trace", @@ -888,7 +864,7 @@ ROUTING_TOOLS = [ "properties": { "uuid": { "type": "string", - "description": "UUID of the trace to modify" + "description": "UUID of the trace to modify", }, "position": { "type": "object", @@ -896,24 +872,22 @@ ROUTING_TOOLS = [ "properties": { "x": {"type": "number", "description": "X coordinate"}, "y": {"type": "number", "description": "Y coordinate"}, - "unit": {"type": "string", "enum": ["mm", "inch"], "default": "mm"} + "unit": { + "type": "string", + "enum": ["mm", "inch"], + "default": "mm", + }, }, - "required": ["x", "y"] - }, - "width": { - "type": "number", - "description": "New trace width in mm" + "required": ["x", "y"], }, + "width": {"type": "number", "description": "New trace width in mm"}, "layer": { "type": "string", - "description": "New layer name (e.g., 'F.Cu', 'B.Cu')" + "description": "New layer name (e.g., 'F.Cu', 'B.Cu')", }, - "net": { - "type": "string", - "description": "New net name to assign" - } - } - } + "net": {"type": "string", "description": "New net name to assign"}, + }, + }, }, { "name": "copy_routing_pattern", @@ -925,34 +899,31 @@ ROUTING_TOOLS = [ "sourceRefs": { "type": "array", "items": {"type": "string"}, - "description": "Source component references (e.g., ['U1', 'U2', 'U3'])" + "description": "Source component references (e.g., ['U1', 'U2', 'U3'])", }, "targetRefs": { "type": "array", "items": {"type": "string"}, - "description": "Target component references (e.g., ['U4', 'U5', 'U6'])" + "description": "Target component references (e.g., ['U4', 'U5', 'U6'])", }, "includeVias": { "type": "boolean", "description": "Include vias in the pattern copy", - "default": True + "default": True, }, "traceWidth": { "type": "number", - "description": "Override trace width in mm (uses original if not specified)" - } + "description": "Override trace width in mm (uses original if not specified)", + }, }, - "required": ["sourceRefs", "targetRefs"] - } + "required": ["sourceRefs", "targetRefs"], + }, }, { "name": "get_nets_list", "title": "List All Nets", "description": "Returns a list of all electrical nets defined on the board.", - "inputSchema": { - "type": "object", - "properties": {} - } + "inputSchema": {"type": "object", "properties": {}}, }, { "name": "create_netclass", @@ -964,29 +935,29 @@ ROUTING_TOOLS = [ "name": { "type": "string", "description": "Net class name", - "minLength": 1 + "minLength": 1, }, "traceWidth": { "type": "number", "description": "Default trace width in millimeters", - "minimum": 0.1 + "minimum": 0.1, }, "clearance": { "type": "number", "description": "Clearance in millimeters", - "minimum": 0.1 + "minimum": 0.1, }, "viaDiameter": { "type": "number", - "description": "Via diameter in millimeters" + "description": "Via diameter in millimeters", }, "viaDrill": { "type": "number", - "description": "Via drill diameter in millimeters" - } + "description": "Via drill diameter in millimeters", + }, }, - "required": ["name", "traceWidth", "clearance"] - } + "required": ["name", "traceWidth", "clearance"], + }, }, { "name": "add_copper_pour", @@ -997,22 +968,22 @@ ROUTING_TOOLS = [ "properties": { "netName": { "type": "string", - "description": "Net to connect this copper pour to (e.g., GND, VCC)" + "description": "Net to connect this copper pour to (e.g., GND, VCC)", }, "layer": { "type": "string", - "description": "Layer for the copper pour (e.g., F.Cu, B.Cu)" + "description": "Layer for the copper pour (e.g., F.Cu, B.Cu)", }, "priority": { "type": "integer", "description": "Pour priority (higher priorities fill first)", "minimum": 0, - "default": 0 + "default": 0, }, "clearance": { "type": "number", "description": "Clearance from other objects in millimeters", - "minimum": 0.1 + "minimum": 0.1, }, "outline": { "type": "array", @@ -1021,13 +992,13 @@ ROUTING_TOOLS = [ "type": "array", "items": {"type": "number"}, "minItems": 2, - "maxItems": 2 + "maxItems": 2, }, - "minItems": 3 - } + "minItems": 3, + }, }, - "required": ["netName", "layer", "outline"] - } + "required": ["netName", "layer", "outline"], + }, }, { "name": "route_differential_pair", @@ -1038,23 +1009,20 @@ ROUTING_TOOLS = [ "properties": { "positiveName": { "type": "string", - "description": "Positive signal net name" + "description": "Positive signal net name", }, "negativeName": { "type": "string", - "description": "Negative signal net name" - }, - "layer": { - "type": "string", - "description": "Layer to route on" + "description": "Negative signal net name", }, + "layer": {"type": "string", "description": "Layer to route on"}, "width": { "type": "number", - "description": "Trace width in millimeters" + "description": "Trace width in millimeters", }, "gap": { "type": "number", - "description": "Gap between traces in millimeters" + "description": "Gap between traces in millimeters", }, "points": { "type": "array", @@ -1063,14 +1031,14 @@ ROUTING_TOOLS = [ "type": "array", "items": {"type": "number"}, "minItems": 2, - "maxItems": 2 + "maxItems": 2, }, - "minItems": 2 - } + "minItems": 2, + }, }, - "required": ["positiveName", "negativeName", "width", "gap", "points"] - } - } + "required": ["positiveName", "negativeName", "width", "gap", "points"], + }, + }, ] # ============================================================================= @@ -1082,10 +1050,7 @@ LIBRARY_TOOLS = [ "name": "list_libraries", "title": "List Footprint Libraries", "description": "Lists all available footprint libraries accessible to KiCAD.", - "inputSchema": { - "type": "object", - "properties": {} - } + "inputSchema": {"type": "object", "properties": {}}, }, { "name": "search_footprints", @@ -1097,15 +1062,15 @@ LIBRARY_TOOLS = [ "query": { "type": "string", "description": "Search query (e.g., '0805', 'SOIC', 'QFP')", - "minLength": 1 + "minLength": 1, }, "library": { "type": "string", - "description": "Optional library to restrict search to" - } + "description": "Optional library to restrict search to", + }, }, - "required": ["query"] - } + "required": ["query"], + }, }, { "name": "list_library_footprints", @@ -1117,11 +1082,11 @@ LIBRARY_TOOLS = [ "library": { "type": "string", "description": "Library name (e.g., Resistor_SMD, Connector_PinHeader)", - "minLength": 1 + "minLength": 1, } }, - "required": ["library"] - } + "required": ["library"], + }, }, { "name": "get_footprint_info", @@ -1130,18 +1095,12 @@ LIBRARY_TOOLS = [ "inputSchema": { "type": "object", "properties": { - "library": { - "type": "string", - "description": "Library name" - }, - "footprint": { - "type": "string", - "description": "Footprint name" - } + "library": {"type": "string", "description": "Library name"}, + "footprint": {"type": "string", "description": "Footprint name"}, }, - "required": ["library", "footprint"] - } - } + "required": ["library", "footprint"], + }, + }, ] # ============================================================================= @@ -1159,36 +1118,33 @@ DESIGN_RULE_TOOLS = [ "clearance": { "type": "number", "description": "Minimum clearance between copper in millimeters", - "minimum": 0.1 + "minimum": 0.1, }, "trackWidth": { "type": "number", "description": "Minimum track width in millimeters", - "minimum": 0.1 + "minimum": 0.1, }, "viaDiameter": { "type": "number", - "description": "Minimum via diameter in millimeters" + "description": "Minimum via diameter in millimeters", }, "viaDrill": { "type": "number", - "description": "Minimum via drill diameter in millimeters" + "description": "Minimum via drill diameter in millimeters", }, "microViaD iameter": { "type": "number", - "description": "Minimum micro-via diameter in millimeters" - } - } - } + "description": "Minimum micro-via diameter in millimeters", + }, + }, + }, }, { "name": "get_design_rules", "title": "Get Current Design Rules", "description": "Retrieves the currently configured design rules from the board.", - "inputSchema": { - "type": "object", - "properties": {} - } + "inputSchema": {"type": "object", "properties": {}}, }, { "name": "run_drc", @@ -1200,20 +1156,17 @@ DESIGN_RULE_TOOLS = [ "includeWarnings": { "type": "boolean", "description": "Include warnings in addition to errors", - "default": True + "default": True, } - } - } + }, + }, }, { "name": "get_drc_violations", "title": "Get DRC Violations", "description": "Returns a list of design rule violations from the most recent DRC run.", - "inputSchema": { - "type": "object", - "properties": {} - } - } + "inputSchema": {"type": "object", "properties": {}}, + }, ] # ============================================================================= @@ -1230,21 +1183,21 @@ EXPORT_TOOLS = [ "properties": { "outputPath": { "type": "string", - "description": "Directory path for output files" + "description": "Directory path for output files", }, "layers": { "type": "array", "description": "List of layers to export (if not provided, exports all copper and mask layers)", - "items": {"type": "string"} + "items": {"type": "string"}, }, "includeDrillFiles": { "type": "boolean", "description": "Include drill files (Excellon format)", - "default": True - } + "default": True, + }, }, - "required": ["outputPath"] - } + "required": ["outputPath"], + }, }, { "name": "export_pdf", @@ -1255,22 +1208,22 @@ EXPORT_TOOLS = [ "properties": { "outputPath": { "type": "string", - "description": "Path for output PDF file" + "description": "Path for output PDF file", }, "layers": { "type": "array", "description": "Layers to include in PDF", - "items": {"type": "string"} + "items": {"type": "string"}, }, "colorMode": { "type": "string", "enum": ["color", "black_white"], "description": "Color mode for output", - "default": "color" - } + "default": "color", + }, }, - "required": ["outputPath"] - } + "required": ["outputPath"], + }, }, { "name": "export_svg", @@ -1281,16 +1234,16 @@ EXPORT_TOOLS = [ "properties": { "outputPath": { "type": "string", - "description": "Path for output SVG file" + "description": "Path for output SVG file", }, "layers": { "type": "array", "description": "Layers to include in SVG", - "items": {"type": "string"} - } + "items": {"type": "string"}, + }, }, - "required": ["outputPath"] - } + "required": ["outputPath"], + }, }, { "name": "export_3d", @@ -1301,22 +1254,22 @@ EXPORT_TOOLS = [ "properties": { "outputPath": { "type": "string", - "description": "Path for output 3D file" + "description": "Path for output 3D file", }, "format": { "type": "string", "enum": ["step", "vrml"], "description": "3D model format", - "default": "step" + "default": "step", }, "includeComponents": { "type": "boolean", "description": "Include 3D component models", - "default": True - } + "default": True, + }, }, - "required": ["outputPath"] - } + "required": ["outputPath"], + }, }, { "name": "export_bom", @@ -1327,23 +1280,23 @@ EXPORT_TOOLS = [ "properties": { "outputPath": { "type": "string", - "description": "Path for output BOM file" + "description": "Path for output BOM file", }, "format": { "type": "string", "enum": ["csv", "xml", "html"], "description": "BOM output format", - "default": "csv" + "default": "csv", }, "groupByValue": { "type": "boolean", "description": "Group components with same value together", - "default": True - } + "default": True, + }, }, - "required": ["outputPath"] - } - } + "required": ["outputPath"], + }, + }, ] # ============================================================================= @@ -1360,15 +1313,12 @@ SCHEMATIC_TOOLS = [ "properties": { "filename": { "type": "string", - "description": "Path for the new schematic file (.kicad_sch)" + "description": "Path for the new schematic file (.kicad_sch)", }, - "title": { - "type": "string", - "description": "Schematic title" - } + "title": {"type": "string", "description": "Schematic title"}, }, - "required": ["filename"] - } + "required": ["filename"], + }, }, { "name": "load_schematic", @@ -1379,11 +1329,11 @@ SCHEMATIC_TOOLS = [ "properties": { "filename": { "type": "string", - "description": "Path to schematic file (.kicad_sch)" + "description": "Path to schematic file (.kicad_sch)", } }, - "required": ["filename"] - } + "required": ["filename"], + }, }, { "name": "add_schematic_component", @@ -1394,27 +1344,21 @@ SCHEMATIC_TOOLS = [ "properties": { "reference": { "type": "string", - "description": "Reference designator (e.g., R1, C2, U3)" + "description": "Reference designator (e.g., R1, C2, U3)", }, "symbol": { "type": "string", - "description": "Symbol library:name (e.g., Device:R, Device:C)" + "description": "Symbol library:name (e.g., Device:R, Device:C)", }, "value": { "type": "string", - "description": "Component value (e.g., 10k, 0.1uF)" + "description": "Component value (e.g., 10k, 0.1uF)", }, - "x": { - "type": "number", - "description": "X coordinate on schematic" - }, - "y": { - "type": "number", - "description": "Y coordinate on schematic" - } + "x": {"type": "number", "description": "X coordinate on schematic"}, + "y": {"type": "number", "description": "Y coordinate on schematic"}, }, - "required": ["reference", "symbol", "x", "y"] - } + "required": ["reference", "symbol", "x", "y"], + }, }, { "name": "add_schematic_wire", @@ -1430,13 +1374,13 @@ SCHEMATIC_TOOLS = [ "type": "array", "items": {"type": "number"}, "minItems": 2, - "maxItems": 2 + "maxItems": 2, }, - "minItems": 2 + "minItems": 2, } }, - "required": ["points"] - } + "required": ["points"], + }, }, { "name": "add_schematic_connection", @@ -1447,19 +1391,13 @@ SCHEMATIC_TOOLS = [ "properties": { "schematicPath": { "type": "string", - "description": "Path to schematic file" + "description": "Path to schematic file", }, - "x": { - "type": "number", - "description": "X coordinate on schematic" - }, - "y": { - "type": "number", - "description": "Y coordinate on schematic" - } + "x": {"type": "number", "description": "X coordinate on schematic"}, + "y": {"type": "number", "description": "Y coordinate on schematic"}, }, - "required": ["schematicPath", "x", "y"] - } + "required": ["schematicPath", "x", "y"], + }, }, { "name": "add_schematic_net_label", @@ -1470,28 +1408,22 @@ SCHEMATIC_TOOLS = [ "properties": { "schematicPath": { "type": "string", - "description": "Path to schematic file" + "description": "Path to schematic file", }, "netName": { "type": "string", - "description": "Name of the net (e.g., VCC, GND, SDA)" - }, - "x": { - "type": "number", - "description": "X coordinate on schematic" - }, - "y": { - "type": "number", - "description": "Y coordinate on schematic" + "description": "Name of the net (e.g., VCC, GND, SDA)", }, + "x": {"type": "number", "description": "X coordinate on schematic"}, + "y": {"type": "number", "description": "Y coordinate on schematic"}, "rotation": { "type": "number", "description": "Rotation angle in degrees (0, 90, 180, 270)", - "default": 0 - } + "default": 0, + }, }, - "required": ["schematicPath", "netName", "x", "y"] - } + "required": ["schematicPath", "netName", "x", "y"], + }, }, { "name": "connect_to_net", @@ -1502,23 +1434,23 @@ SCHEMATIC_TOOLS = [ "properties": { "schematicPath": { "type": "string", - "description": "Path to schematic file" + "description": "Path to schematic file", }, "reference": { "type": "string", - "description": "Component reference designator (e.g., R1, U3)" + "description": "Component reference designator (e.g., R1, U3)", }, "pinNumber": { "type": "string", - "description": "Pin number or name on the component" + "description": "Pin number or name on the component", }, "netName": { "type": "string", - "description": "Name of the net to connect to" - } + "description": "Name of the net to connect to", + }, }, - "required": ["schematicPath", "reference", "pinNumber", "netName"] - } + "required": ["schematicPath", "reference", "pinNumber", "netName"], + }, }, { "name": "get_net_connections", @@ -1529,15 +1461,15 @@ SCHEMATIC_TOOLS = [ "properties": { "schematicPath": { "type": "string", - "description": "Path to schematic file" + "description": "Path to schematic file", }, "netName": { "type": "string", - "description": "Name of the net to query" - } + "description": "Name of the net to query", + }, }, - "required": ["schematicPath", "netName"] - } + "required": ["schematicPath", "netName"], + }, }, { "name": "get_schematic_pin_locations", @@ -1548,15 +1480,15 @@ SCHEMATIC_TOOLS = [ "properties": { "schematicPath": { "type": "string", - "description": "Path to the schematic file" + "description": "Path to the schematic file", }, "reference": { "type": "string", - "description": "Component reference designator (e.g., U1, R1, J2)" - } + "description": "Component reference designator (e.g., U1, R1, J2)", + }, }, - "required": ["schematicPath", "reference"] - } + "required": ["schematicPath", "reference"], + }, }, { "name": "connect_passthrough", @@ -1567,27 +1499,27 @@ SCHEMATIC_TOOLS = [ "properties": { "schematicPath": { "type": "string", - "description": "Path to the schematic file" + "description": "Path to the schematic file", }, "sourceRef": { "type": "string", - "description": "Reference of the source connector (e.g., J1)" + "description": "Reference of the source connector (e.g., J1)", }, "targetRef": { "type": "string", - "description": "Reference of the target connector (e.g., J2)" + "description": "Reference of the target connector (e.g., J2)", }, "netPrefix": { "type": "string", - "description": "Prefix for generated net names, e.g. 'CSI' produces CSI_1, CSI_2, ... (default: PIN)" + "description": "Prefix for generated net names, e.g. 'CSI' produces CSI_1, CSI_2, ... (default: PIN)", }, "pinOffset": { "type": "integer", - "description": "Add this value to the pin number when building net names (default: 0)" - } + "description": "Add this value to the pin number when building net names (default: 0)", + }, }, - "required": ["schematicPath", "sourceRef", "targetRef"] - } + "required": ["schematicPath", "sourceRef", "targetRef"], + }, }, { "name": "run_erc", @@ -1598,11 +1530,11 @@ SCHEMATIC_TOOLS = [ "properties": { "schematicPath": { "type": "string", - "description": "Path to the .kicad_sch schematic file" + "description": "Path to the .kicad_sch schematic file", } }, - "required": ["schematicPath"] - } + "required": ["schematicPath"], + }, }, { "name": "sync_schematic_to_board", @@ -1613,14 +1545,14 @@ SCHEMATIC_TOOLS = [ "properties": { "schematicPath": { "type": "string", - "description": "Path to .kicad_sch file. If omitted, auto-detected from current board path." + "description": "Path to .kicad_sch file. If omitted, auto-detected from current board path.", }, "boardPath": { "type": "string", - "description": "Path to .kicad_pcb file. If omitted, uses currently loaded board." - } - } - } + "description": "Path to .kicad_pcb file. If omitted, uses currently loaded board.", + }, + }, + }, }, { "name": "generate_netlist", @@ -1631,21 +1563,21 @@ SCHEMATIC_TOOLS = [ "properties": { "schematicPath": { "type": "string", - "description": "Path to schematic file" + "description": "Path to schematic file", }, "outputPath": { "type": "string", - "description": "Optional path to save netlist file" + "description": "Optional path to save netlist file", }, "format": { "type": "string", "enum": ["kicad", "json", "spice"], "description": "Netlist output format", - "default": "json" - } + "default": "json", + }, }, - "required": ["schematicPath"] - } + "required": ["schematicPath"], + }, }, { "name": "list_schematic_libraries", @@ -1657,10 +1589,10 @@ SCHEMATIC_TOOLS = [ "searchPaths": { "type": "array", "description": "Optional additional paths to search for libraries", - "items": {"type": "string"} + "items": {"type": "string"}, } - } - } + }, + }, }, { "name": "export_schematic_pdf", @@ -1671,15 +1603,12 @@ SCHEMATIC_TOOLS = [ "properties": { "schematicPath": { "type": "string", - "description": "Path to schematic file" + "description": "Path to schematic file", }, - "outputPath": { - "type": "string", - "description": "Path for output PDF" - } + "outputPath": {"type": "string", "description": "Path for output PDF"}, }, - "required": ["schematicPath", "outputPath"] - } + "required": ["schematicPath", "outputPath"], + }, }, # --- Schematic Analysis Tools (read-only) --- { @@ -1691,40 +1620,40 @@ SCHEMATIC_TOOLS = [ "properties": { "schematicPath": { "type": "string", - "description": "Path to the .kicad_sch schematic file" + "description": "Path to the .kicad_sch schematic file", }, "x1": { "type": "number", - "description": "Left X coordinate of the region in mm" + "description": "Left X coordinate of the region in mm", }, "y1": { "type": "number", - "description": "Top Y coordinate of the region in mm" + "description": "Top Y coordinate of the region in mm", }, "x2": { "type": "number", - "description": "Right X coordinate of the region in mm" + "description": "Right X coordinate of the region in mm", }, "y2": { "type": "number", - "description": "Bottom Y coordinate of the region in mm" + "description": "Bottom Y coordinate of the region in mm", }, "format": { "type": "string", "enum": ["png", "svg"], - "description": "Output image format (default: png)" + "description": "Output image format (default: png)", }, "width": { "type": "integer", - "description": "Output image width in pixels (default: 800)" + "description": "Output image width in pixels (default: 800)", }, "height": { "type": "integer", - "description": "Output image height in pixels (default: 600)" - } + "description": "Output image height in pixels (default: 600)", + }, }, - "required": ["schematicPath", "x1", "y1", "x2", "y2"] - } + "required": ["schematicPath", "x1", "y1", "x2", "y2"], + }, }, { "name": "find_overlapping_elements", @@ -1735,15 +1664,15 @@ SCHEMATIC_TOOLS = [ "properties": { "schematicPath": { "type": "string", - "description": "Path to the .kicad_sch schematic file" + "description": "Path to the .kicad_sch schematic file", }, "tolerance": { "type": "number", - "description": "Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection. (default: 0.5)" - } + "description": "Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection. (default: 0.5)", + }, }, - "required": ["schematicPath"] - } + "required": ["schematicPath"], + }, }, { "name": "get_elements_in_region", @@ -1754,27 +1683,27 @@ SCHEMATIC_TOOLS = [ "properties": { "schematicPath": { "type": "string", - "description": "Path to the .kicad_sch schematic file" + "description": "Path to the .kicad_sch schematic file", }, "x1": { "type": "number", - "description": "Left X coordinate of the region in mm" + "description": "Left X coordinate of the region in mm", }, "y1": { "type": "number", - "description": "Top Y coordinate of the region in mm" + "description": "Top Y coordinate of the region in mm", }, "x2": { "type": "number", - "description": "Right X coordinate of the region in mm" + "description": "Right X coordinate of the region in mm", }, "y2": { "type": "number", - "description": "Bottom Y coordinate of the region in mm" - } + "description": "Bottom Y coordinate of the region in mm", + }, }, - "required": ["schematicPath", "x1", "y1", "x2", "y2"] - } + "required": ["schematicPath", "x1", "y1", "x2", "y2"], + }, }, { "name": "find_wires_crossing_symbols", @@ -1785,12 +1714,12 @@ SCHEMATIC_TOOLS = [ "properties": { "schematicPath": { "type": "string", - "description": "Path to the .kicad_sch schematic file" + "description": "Path to the .kicad_sch schematic file", } }, - "required": ["schematicPath"] - } - } + "required": ["schematicPath"], + }, + }, ] # ============================================================================= @@ -1802,10 +1731,7 @@ UI_TOOLS = [ "name": "check_kicad_ui", "title": "Check KiCAD UI Status", "description": "Checks if KiCAD user interface is currently running and returns process information.", - "inputSchema": { - "type": "object", - "properties": {} - } + "inputSchema": {"type": "object", "properties": {}}, }, { "name": "launch_kicad_ui", @@ -1816,16 +1742,16 @@ UI_TOOLS = [ "properties": { "projectPath": { "type": "string", - "description": "Optional path to project file to open in UI" + "description": "Optional path to project file to open in UI", }, "autoLaunch": { "type": "boolean", "description": "Whether to automatically launch if not running", - "default": True - } - } - } - } + "default": True, + }, + }, + }, + }, ] # ============================================================================= @@ -1835,9 +1761,17 @@ UI_TOOLS = [ TOOL_SCHEMAS: Dict[str, Any] = {} # Combine all tool categories -for tool in (PROJECT_TOOLS + BOARD_TOOLS + COMPONENT_TOOLS + ROUTING_TOOLS + - LIBRARY_TOOLS + DESIGN_RULE_TOOLS + EXPORT_TOOLS + - SCHEMATIC_TOOLS + UI_TOOLS): +for tool in ( + PROJECT_TOOLS + + BOARD_TOOLS + + COMPONENT_TOOLS + + ROUTING_TOOLS + + LIBRARY_TOOLS + + DESIGN_RULE_TOOLS + + EXPORT_TOOLS + + SCHEMATIC_TOOLS + + UI_TOOLS +): TOOL_SCHEMAS[tool["name"]] = tool # Total: 46 tools with comprehensive schemas diff --git a/python/tests/conftest.py b/python/tests/conftest.py index 81d3b93..249d74f 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -33,6 +33,7 @@ except ImportError: class _FakeSchematic: """Minimal stand-in for skip.Schematic used in PinLocator cache.""" + def __init__(self, path: str): self.path = path self.symbol = [] diff --git a/python/tests/test_schematic_analysis.py b/python/tests/test_schematic_analysis.py index c7c4bcd..c36290a 100644 --- a/python/tests/test_schematic_analysis.py +++ b/python/tests/test_schematic_analysis.py @@ -39,7 +39,6 @@ from commands.schematic_analysis import ( find_wires_crossing_symbols, ) - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -127,6 +126,7 @@ def _make_led_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str: # Unit tests — geometry helpers # =================================================================== + class TestGeometryHelpers: """Test low-level geometry utilities.""" @@ -174,6 +174,7 @@ class TestGeometryHelpers: # Unit tests — S-expression parsers # =================================================================== + class TestSexpParsers: """Test S-expression parsing functions with synthetic data.""" @@ -223,6 +224,7 @@ class TestSexpParsers: # Unit tests — analysis functions with mocked PinLocator # =================================================================== + class TestAABBOverlap: """Test AABB overlap helper.""" @@ -254,14 +256,18 @@ class TestFindOverlappingElements: def test_overlapping_symbols_detected(self): # Two resistors at nearly the same position — bboxes fully overlap - extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100.1, 100) + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp( + "R2", 100.1, 100 + ) tmp = _make_temp_schematic(extra) result = find_overlapping_elements(tmp, tolerance=0.5) assert result["totalOverlaps"] >= 1 assert len(result["overlappingSymbols"]) >= 1 def test_well_separated_symbols_not_flagged(self): - extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 200, 200) + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp( + "R2", 200, 200 + ) tmp = _make_temp_schematic(extra) result = find_overlapping_elements(tmp, tolerance=0.5) assert result["totalOverlaps"] == 0 @@ -288,7 +294,9 @@ class TestFindOverlappingElements: """ # R1 at y=100, R2 at y=105 — pin spans [96.19, 103.81] and [101.19, 108.81] # These overlap in Y from 101.19 to 103.81 - extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100, 105) + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp( + "R2", 100, 105 + ) tmp = _make_temp_schematic(extra) result = find_overlapping_elements(tmp, tolerance=0.5) assert result["totalOverlaps"] >= 1, ( @@ -302,7 +310,9 @@ class TestFindOverlappingElements: R pins at y ±3.81, but different X positions far enough apart. """ - extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 110, 100) + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp( + "R2", 110, 100 + ) tmp = _make_temp_schematic(extra) result = find_overlapping_elements(tmp, tolerance=0.5) assert result["totalOverlaps"] == 0 @@ -355,6 +365,7 @@ class TestComputeSymbolBbox: def test_returns_none_for_unknown_symbol(self): tmp = _make_temp_schematic() from commands.pin_locator import PinLocator + locator = PinLocator() result = compute_symbol_bbox(tmp, "NONEXISTENT", locator) assert result is None @@ -409,8 +420,7 @@ class TestIntegrationFindWiresCrossingSymbols: result = find_wires_crossing_symbols(tmp) # The wire must not be reported against the far-away R? at (200, 100) collisions_at_200 = [ - c for c in result - if abs(c["component"]["position"]["x"] - 200) < 0.5 + c for c in result if abs(c["component"]["position"]["x"] - 200) < 0.5 ] assert len(collisions_at_200) == 0, ( "Wire at x≈100 must not be flagged against the R? at x=200; " @@ -431,9 +441,9 @@ class TestIntegrationFindWiresCrossingSymbols: tmp = _make_temp_schematic(extra) result = find_wires_crossing_symbols(tmp) d1_crossings = [c for c in result if c["component"]["reference"] == "D1"] - assert len(d1_crossings) >= 1, ( - "Wire starting at pin but passing through body must be detected" - ) + assert ( + len(d1_crossings) >= 1 + ), "Wire starting at pin but passing through body must be detected" def test_wire_terminating_at_pin_from_outside(self): """A wire that arrives at a pin from outside the component body @@ -448,17 +458,17 @@ class TestIntegrationFindWiresCrossingSymbols: tmp = _make_temp_schematic(extra) result = find_wires_crossing_symbols(tmp) d1_crossings = [c for c in result if c["component"]["reference"] == "D1"] - assert len(d1_crossings) == 0, ( - "Wire terminating at pin from outside should not be flagged" - ) + assert ( + len(d1_crossings) == 0 + ), "Wire terminating at pin from outside should not be flagged" def test_wire_shorts_component_pins_detected_as_collision(self): """Regression: a wire connecting pin1→pin2 of the same component must be reported even though both endpoints land on pins.""" r_sexp = _make_resistor_sexp("R_short", 100.0, 100.0) wire_sexp = ( - '(wire (pts (xy 100 103.81) (xy 100 96.19))\n' - ' (stroke (width 0) (type default))\n' + "(wire (pts (xy 100 103.81) (xy 100 96.19))\n" + " (stroke (width 0) (type default))\n" ' (uuid "aaaaaaaa-0000-0000-0000-000000000001"))' ) sch = _make_temp_schematic(r_sexp + "\n" + wire_sexp) @@ -511,6 +521,7 @@ class TestIntegrationGetElementsInRegion: # Unit tests — _check_wire_overlap # =================================================================== + class TestCheckWireOverlap: """Test wire overlap detection for horizontal, vertical, and diagonal cases.""" @@ -613,6 +624,7 @@ class TestIntegrationDiagonalWireOverlap: # Unit tests — _extract_lib_symbols # =================================================================== + class TestExtractLibSymbols: """Test _extract_lib_symbols helper.""" @@ -684,6 +696,7 @@ class TestExtractLibSymbols: # Unit tests — _parse_lib_symbol_graphics # =================================================================== + class TestParseLibSymbolGraphics: """Test graphics extraction from lib_symbol definitions.""" @@ -745,6 +758,7 @@ class TestParseLibSymbolGraphics: # Unit tests — _transform_local_point # =================================================================== + class TestTransformLocalPoint: """Test local→absolute coordinate transform.""" @@ -776,6 +790,7 @@ class TestTransformLocalPoint: # Unit tests — _compute_symbol_bbox_direct with graphics # =================================================================== + class TestComputeSymbolBboxWithGraphics: """Test that bounding box computation uses graphics points when available.""" @@ -783,14 +798,36 @@ class TestComputeSymbolBboxWithGraphics: """Device:R rectangle is (-1.016, -2.54) to (1.016, 2.54) in local coords. Pins at (0, ±3.81). Placed at (100, 100) with no rotation. Bbox should span from pin-to-pin in Y and use rectangle width in X.""" - sym = {"x": 100.0, "y": 100.0, "rotation": 0, "mirror_x": False, "mirror_y": False} + sym = { + "x": 100.0, + "y": 100.0, + "rotation": 0, + "mirror_x": False, + "mirror_y": False, + } pin_defs = { - "1": {"x": 0, "y": 3.81, "angle": 270, "length": 1.27, "name": "~", "type": "passive"}, - "2": {"x": 0, "y": -3.81, "angle": 90, "length": 1.27, "name": "~", "type": "passive"}, + "1": { + "x": 0, + "y": 3.81, + "angle": 270, + "length": 1.27, + "name": "~", + "type": "passive", + }, + "2": { + "x": 0, + "y": -3.81, + "angle": 90, + "length": 1.27, + "name": "~", + "type": "passive", + }, } graphics_points = [(-1.016, -2.54), (1.016, 2.54)] - bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) + bbox = _compute_symbol_bbox_direct( + sym, pin_defs, graphics_points=graphics_points + ) assert bbox is not None min_x, min_y, max_x, max_y = bbox # X should come from rectangle: 100 ± 1.016 @@ -802,10 +839,30 @@ class TestComputeSymbolBboxWithGraphics: def test_fallback_without_graphics(self): """Without graphics_points, should use the old degenerate expansion.""" - sym = {"x": 100.0, "y": 100.0, "rotation": 0, "mirror_x": False, "mirror_y": False} + sym = { + "x": 100.0, + "y": 100.0, + "rotation": 0, + "mirror_x": False, + "mirror_y": False, + } pin_defs = { - "1": {"x": 0, "y": 3.81, "angle": 270, "length": 1.27, "name": "~", "type": "passive"}, - "2": {"x": 0, "y": -3.81, "angle": 90, "length": 1.27, "name": "~", "type": "passive"}, + "1": { + "x": 0, + "y": 3.81, + "angle": 270, + "length": 1.27, + "name": "~", + "type": "passive", + }, + "2": { + "x": 0, + "y": -3.81, + "angle": 90, + "length": 1.27, + "name": "~", + "type": "passive", + }, } bbox = _compute_symbol_bbox_direct(sym, pin_defs) @@ -817,15 +874,37 @@ class TestComputeSymbolBboxWithGraphics: def test_rotated_symbol_graphics(self): """Graphics points should be rotated along with the symbol.""" - sym = {"x": 100.0, "y": 100.0, "rotation": 90, "mirror_x": False, "mirror_y": False} + sym = { + "x": 100.0, + "y": 100.0, + "rotation": 90, + "mirror_x": False, + "mirror_y": False, + } pin_defs = { - "1": {"x": 0, "y": 3.81, "angle": 270, "length": 1.27, "name": "~", "type": "passive"}, - "2": {"x": 0, "y": -3.81, "angle": 90, "length": 1.27, "name": "~", "type": "passive"}, + "1": { + "x": 0, + "y": 3.81, + "angle": 270, + "length": 1.27, + "name": "~", + "type": "passive", + }, + "2": { + "x": 0, + "y": -3.81, + "angle": 90, + "length": 1.27, + "name": "~", + "type": "passive", + }, } # Rectangle corners in local coords graphics_points = [(-1.016, -2.54), (1.016, 2.54)] - bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) + bbox = _compute_symbol_bbox_direct( + sym, pin_defs, graphics_points=graphics_points + ) assert bbox is not None min_x, min_y, max_x, max_y = bbox # After 90° rotation, X and Y swap roles @@ -856,7 +935,9 @@ class TestIntegrationGraphicsBbox: assert len(graphics_points) >= 2, "Should have extracted rectangle points" - bbox = _compute_symbol_bbox_direct(r1, pin_defs, graphics_points=graphics_points) + bbox = _compute_symbol_bbox_direct( + r1, pin_defs, graphics_points=graphics_points + ) assert bbox is not None min_x, min_y, max_x, max_y = bbox # Rectangle is ±1.016 in X, NOT ±1.5 from degenerate expansion From c5a0bc495c1916a888dd678c384bed50a3d74f17 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 22 Mar 2026 21:47:49 +0000 Subject: [PATCH 15/74] style: apply Black formatting to kicad_interface.py Co-Authored-By: Claude Sonnet 4.6 --- python/kicad_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 7a92cbe..3a9ef26 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -833,7 +833,7 @@ class KiCADInterface: trim_start -= 1 if trim_start > 0 and content[trim_start - 1] == "\n": trim_start -= 1 - content = content[:trim_start] + content[b_end + 1:] + content = content[:trim_start] + content[b_end + 1 :] with open(sch_file, "w", encoding="utf-8") as f: f.write(content) From ca40a2b33269b04b9d812292b2f7aac4eb35295a Mon Sep 17 00:00:00 2001 From: Leah Armstrong Date: Tue, 24 Mar 2026 12:04:29 -0400 Subject: [PATCH 16/74] fix: JLCPCB database download and FTS search The JLCSearch API (jlcsearch.tscircuit.com) /components/list.json endpoint ignores the offset parameter, returning the same 100 parts on every request. This caused download_jlcpcb_database to loop indefinitely, importing duplicate data for hours while blocking the single-threaded Python process. - Add download_jlcpcb.py: standalone script that downloads the pre-built jlcparts database from yaqwsx/jlcparts GitHub Pages (~1GB compressed, 7M+ parts, completes in ~4 minutes) - Fix FTS search: add prefix wildcards to search terms so partial MPN matches work (e.g. "BQ25895" now finds "BQ25895RTWR") --- download_jlcpcb.py | 262 ++++++++++++++++++++++++++++++++ python/commands/jlcpcb_parts.py | 8 +- 2 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 download_jlcpcb.py diff --git a/download_jlcpcb.py b/download_jlcpcb.py new file mode 100644 index 0000000..1e7cae2 --- /dev/null +++ b/download_jlcpcb.py @@ -0,0 +1,262 @@ +#!/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 os +import sys +import subprocess +import sqlite3 +import json +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() diff --git a/python/commands/jlcpcb_parts.py b/python/commands/jlcpcb_parts.py index f92acec..249bdbb 100644 --- a/python/commands/jlcpcb_parts.py +++ b/python/commands/jlcpcb_parts.py @@ -280,13 +280,19 @@ class JLCPCBPartsManager: if query: # Use FTS for text search + # 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 ?") From 6633cd59fd8051a1f7c9d5dca3dfa2485f545b00 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sat, 14 Mar 2026 13:49:20 +0000 Subject: [PATCH 17/74] feat: add get_wire_connections tool for pin/wire lookup by schematic point Given a single (x,y) coordinate on the schematic, flood-fills through all connected wire segments and returns every component pin reachable on that net, plus the full list of wire segments with their start/end coordinates. Co-Authored-By: Claude Sonnet 4.6 --- python/kicad_interface.py | 124 ++++++++++++++++++++++++++++++++++++++ src/tools/registry.ts | 1 + src/tools/schematic.ts | 39 ++++++++++++ 3 files changed, 164 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 3a9ef26..bda174a 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -382,6 +382,7 @@ class KiCADInterface: "connect_passthrough": self._handle_connect_passthrough, "get_schematic_pin_locations": self._handle_get_schematic_pin_locations, "get_net_connections": self._handle_get_net_connections, + "get_wire_connections": self._handle_get_wire_connections, "run_erc": self._handle_run_erc, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, @@ -2320,6 +2321,129 @@ class KiCADInterface: logger.error(f"Error getting net connections: {str(e)}") return {"success": False, "message": str(e)} + def _handle_get_wire_connections(self, params): + """Find all component pins reachable from a point via connected wires""" + logger.info("Getting wire connections") + try: + from pathlib import Path + from commands.pin_locator import PinLocator + + schematic_path = params.get("schematicPath") + x = params.get("x") + y = params.get("y") + + if not all([schematic_path, x is not None, y is not None]): + return {"success": False, "message": "Missing required parameters: schematicPath, x, y"} + + x, y = float(x), float(y) + tolerance = 0.5 + + def points_coincide(p1, p2): + return abs(p1[0] - p2[0]) < tolerance and abs(p1[1] - p2[1]) < tolerance + + schematic = SchematicManager.load_schematic(schematic_path) + if not schematic: + return {"success": False, "message": "Failed to load schematic"} + + if not hasattr(schematic, "wire"): + return {"success": False, "message": "Schematic has no wires"} + + # Collect all wires as list of point sequences + 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([float(point.value[0]), float(point.value[1])]) + if len(pts) >= 2: + all_wires.append(pts) + + # Step 1: Find all seed wires that touch the given point (start or end) + query_point = [x, y] + seed_wires = [ + pts for pts in all_wires + if points_coincide(pts[0], query_point) or points_coincide(pts[-1], query_point) + ] + + if not seed_wires: + return { + "success": False, + "message": f"No wire found at ({x},{y}) within {tolerance}mm tolerance", + } + + # Step 2: Flood-fill through connected wires + connected_wires = list(seed_wires) + frontier = set((pt[0], pt[1]) for pts in seed_wires for pt in pts) + remaining = [w for w in all_wires if w not in seed_wires] + changed = True + while changed: + changed = False + still_remaining = [] + for pts in remaining: + wire_endpoints = {(pts[0][0], pts[0][1]), (pts[-1][0], pts[-1][1])} + if any( + points_coincide(list(ep), list(fp)) + for ep in wire_endpoints + for fp in frontier + ): + connected_wires.append(pts) + for pt in pts: + frontier.add((pt[0], pt[1])) + changed = True + else: + still_remaining.append(pts) + remaining = still_remaining + + # Step 3: Collect all points from connected wires + connected_points = set() + for pts in connected_wires: + for pt in pts: + connected_points.add((pt[0], pt[1])) + + # Step 4: Find component pins at connected points + if not hasattr(schematic, "symbol"): + return {"success": True, "pins": []} + + locator = PinLocator() + pins = [] + seen = set() + + for symbol in schematic.symbol: + if not hasattr(symbol.property, "Reference"): + continue + ref = symbol.property.Reference.value + if ref.startswith("_TEMPLATE"): + continue + + try: + 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_loc = [pin_data["x"], pin_data["y"]] + for cp in connected_points: + if points_coincide(pin_loc, list(cp)): + key = (ref, pin_num) + if key not in seen: + seen.add(key) + pins.append({"component": ref, "pin": pin_num}) + break + except Exception as e: + logger.warning(f"Error checking pins for {ref}: {e}") + + wires_out = [ + {"start": {"x": pts[0][0], "y": pts[0][1]}, "end": {"x": pts[-1][0], "y": pts[-1][1]}} + for pts in connected_wires + ] + return {"success": True, "pins": pins, "wires": wires_out} + + except Exception as e: + logger.error(f"Error getting wire connections: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + def _handle_run_erc(self, params): """Run Electrical Rules Check on a schematic via kicad-cli""" logger.info("Running ERC on schematic") diff --git a/src/tools/registry.ts b/src/tools/registry.ts index bca2cf1..d2e0e45 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -102,6 +102,7 @@ export const toolCategories: ToolCategory[] = [ "list_schematic_nets", "list_schematic_wires", "list_schematic_labels", + "get_wire_connections", "generate_netlist", "sync_schematic_to_board", "get_schematic_view", diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index ef8fb67..594be86 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -425,6 +425,45 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, ); + // Get wire connections + server.tool( + "get_wire_connections", + "Find all component pins reachable from a schematic point via connected wires. Provide any point on a wire (start, end, or junction) to get all pins on that net.", + { + schematicPath: z.string().describe("Path to the schematic file"), + x: z.number().describe("X coordinate of the point on the wire"), + y: z.number().describe("Y coordinate of the point on the wire"), + }, + async (args: { schematicPath: string; x: number; y: number }) => { + const result = await callKicadScript("get_wire_connections", args); + if (result.success && result.pins) { + const pinList = result.pins + .map((p: any) => ` - ${p.component}/${p.pin}`) + .join("\n"); + const wireList = (result.wires ?? []) + .map((w: any) => ` - (${w.start.x},${w.start.y}) → (${w.end.x},${w.end.y})`) + .join("\n"); + return { + content: [ + { + type: "text", + text: `Pins connected at (${args.x},${args.y}):\n${pinList || " (none found)"}\n\nWire segments:\n${wireList || " (none)"}`, + }, + ], + }; + } else { + return { + content: [ + { + type: "text", + text: `Failed to get wire connections: ${result.message || "Unknown error"}`, + }, + ], + }; + } + }, + ); + // Get pin locations for a schematic component server.tool( "get_schematic_pin_locations", From ddbfc62a49fe6febc3aba4e255aadc0f50eb03e2 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sat, 14 Mar 2026 14:10:06 +0000 Subject: [PATCH 18/74] fix: address review issues in get_wire_connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix critical bug: use pin_data[0]/[1] instead of pin_data["x"]/["y"] (get_all_symbol_pins returns List[float], not dict) - Use index-based wire tracking to avoid fragile float list equality - Check all polyline points (not just endpoints) during flood-fill - Add spatial index (0.05mm grid) to replace O(n²) frontier scan - Skip already-processed refs to avoid redundant calls for multi-unit symbols - Include wires_out in early return when schematic has no symbols - Add get_wire_connections schema entry to tool_schemas.py Co-Authored-By: Claude Sonnet 4.6 --- python/kicad_interface.py | 87 ++++++++++++++++++++++++---------- python/schemas/tool_schemas.py | 23 +++++++++ 2 files changed, 85 insertions(+), 25 deletions(-) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index bda174a..ba3f987 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -2361,39 +2361,72 @@ class KiCADInterface: # Step 1: Find all seed wires that touch the given point (start or end) query_point = [x, y] - seed_wires = [ - pts for pts in all_wires - if points_coincide(pts[0], query_point) or points_coincide(pts[-1], query_point) - ] + seed_indices = set( + i for i, pts in enumerate(all_wires) + if any(points_coincide(pt, query_point) for pt in pts) + ) - if not seed_wires: + if not seed_indices: return { "success": False, "message": f"No wire found at ({x},{y}) within {tolerance}mm tolerance", } # Step 2: Flood-fill through connected wires - connected_wires = list(seed_wires) - frontier = set((pt[0], pt[1]) for pts in seed_wires for pt in pts) - remaining = [w for w in all_wires if w not in seed_wires] + connected_indices = set(seed_indices) + frontier = set((pt[0], pt[1]) for i in seed_indices for pt in all_wires[i]) + + # Spatial index: grid-snapped dict for O(1) proximity lookup + import math + GRID = 0.05 # mm, matches KiCAD schematic grid + grid_radius = math.ceil(tolerance / GRID) # cells to check per axis + + def _grid_key(x_coord, y_coord): + return (round(x_coord / GRID), round(y_coord / GRID)) + + # frontier_grid maps grid cell -> list of (x, y) frontier points in that cell + frontier_grid = {} + for fp in frontier: + key = _grid_key(fp[0], fp[1]) + frontier_grid.setdefault(key, []).append(fp) + + def _frontier_has_neighbour(px, py): + """Check if any frontier point is within tolerance of (px, py).""" + cx, cy = _grid_key(px, py) + for dx in range(-grid_radius, grid_radius + 1): + for dy in range(-grid_radius, grid_radius + 1): + cell = (cx + dx, cy + dy) + cell_points = frontier_grid.get(cell) + if cell_points: + for fp in cell_points: + if abs(px - fp[0]) < tolerance and abs(py - fp[1]) < tolerance: + return True + return False + + def _add_to_frontier_grid(pt): + key = _grid_key(pt[0], pt[1]) + frontier_grid.setdefault(key, []).append(pt) + + remaining_indices = [i for i in range(len(all_wires)) if i not in seed_indices] changed = True while changed: changed = False still_remaining = [] - for pts in remaining: - wire_endpoints = {(pts[0][0], pts[0][1]), (pts[-1][0], pts[-1][1])} - if any( - points_coincide(list(ep), list(fp)) - for ep in wire_endpoints - for fp in frontier - ): - connected_wires.append(pts) + for i in remaining_indices: + pts = all_wires[i] + wire_points = [(pt[0], pt[1]) for pt in pts] + if any(_frontier_has_neighbour(wp[0], wp[1]) for wp in wire_points): + connected_indices.add(i) for pt in pts: - frontier.add((pt[0], pt[1])) + p = (pt[0], pt[1]) + frontier.add(p) + _add_to_frontier_grid(p) changed = True else: - still_remaining.append(pts) - remaining = still_remaining + still_remaining.append(i) + remaining_indices = still_remaining + + connected_wires = [all_wires[i] for i in connected_indices] # Step 3: Collect all points from connected wires connected_points = set() @@ -2402,12 +2435,17 @@ class KiCADInterface: connected_points.add((pt[0], pt[1])) # Step 4: Find component pins at connected points + wires_out = [ + {"start": {"x": pts[0][0], "y": pts[0][1]}, "end": {"x": pts[-1][0], "y": pts[-1][1]}} + for pts in connected_wires + ] if not hasattr(schematic, "symbol"): - return {"success": True, "pins": []} + return {"success": True, "pins": [], "wires": wires_out} locator = PinLocator() pins = [] seen = set() + processed_refs = set() for symbol in schematic.symbol: if not hasattr(symbol.property, "Reference"): @@ -2415,13 +2453,16 @@ class KiCADInterface: ref = symbol.property.Reference.value if ref.startswith("_TEMPLATE"): continue + if ref in processed_refs: + continue + processed_refs.add(ref) try: 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_loc = [pin_data["x"], pin_data["y"]] + pin_loc = [pin_data[0], pin_data[1]] for cp in connected_points: if points_coincide(pin_loc, list(cp)): key = (ref, pin_num) @@ -2432,10 +2473,6 @@ class KiCADInterface: except Exception as e: logger.warning(f"Error checking pins for {ref}: {e}") - wires_out = [ - {"start": {"x": pts[0][0], "y": pts[0][1]}, "end": {"x": pts[-1][0], "y": pts[-1][1]}} - for pts in connected_wires - ] return {"success": True, "pins": pins, "wires": wires_out} except Exception as e: diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index d71a50c..a94f1c0 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1471,6 +1471,29 @@ SCHEMATIC_TOOLS = [ "required": ["schematicPath", "netName"], }, }, + { + "name": "get_wire_connections", + "title": "Get Wire Connections", + "description": "Returns all wires and component pins connected to the wire at a given point, by flood-filling through touching wires.", + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to schematic file" + }, + "x": { + "type": "number", + "description": "X coordinate of the point on the wire" + }, + "y": { + "type": "number", + "description": "Y coordinate of the point on the wire" + } + }, + "required": ["schematicPath", "x", "y"] + } + }, { "name": "get_schematic_pin_locations", "title": "Get Schematic Pin Locations", From b257bef89b400e8dd02fedf701a796933c7b8eea Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sat, 14 Mar 2026 14:20:56 +0000 Subject: [PATCH 19/74] fix: harden get_wire_connections against edge cases and improve performance - Add +1 safety margin to grid_radius to handle banker's rounding at cell boundaries in the spatial index - Move symbol property guards inside per-symbol try/except to prevent AttributeError from aborting all pin processing - Replace O(n) connected_points scan for pin matching with spatial index lookup (_frontier_has_neighbour), consistent with flood-fill approach - Wrap float(x)/float(y) conversion with clear user-facing error message Co-Authored-By: Claude Sonnet 4.6 --- python/kicad_interface.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index ba3f987..a9f59a0 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -2335,7 +2335,10 @@ class KiCADInterface: if not all([schematic_path, x is not None, y is not None]): return {"success": False, "message": "Missing required parameters: schematicPath, x, y"} - x, y = float(x), float(y) + try: + x, y = float(x), float(y) + except (TypeError, ValueError): + return {"success": False, "message": "Parameters x and y must be numeric"} tolerance = 0.5 def points_coincide(p1, p2): @@ -2379,7 +2382,7 @@ class KiCADInterface: # Spatial index: grid-snapped dict for O(1) proximity lookup import math GRID = 0.05 # mm, matches KiCAD schematic grid - grid_radius = math.ceil(tolerance / GRID) # cells to check per axis + grid_radius = math.ceil(tolerance / GRID) + 1 # cells to check per axis (+1 safety margin for banker's rounding) def _grid_key(x_coord, y_coord): return (round(x_coord / GRID), round(y_coord / GRID)) @@ -2448,28 +2451,25 @@ class KiCADInterface: processed_refs = set() for symbol in schematic.symbol: - if not hasattr(symbol.property, "Reference"): - continue - ref = symbol.property.Reference.value - if ref.startswith("_TEMPLATE"): - continue - if ref in processed_refs: - continue - processed_refs.add(ref) - try: + if not hasattr(symbol, 'property') or not hasattr(symbol.property, "Reference"): + continue + ref = symbol.property.Reference.value + if ref.startswith("_TEMPLATE"): + continue + if ref in processed_refs: + continue + processed_refs.add(ref) 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_loc = [pin_data[0], pin_data[1]] - for cp in connected_points: - if points_coincide(pin_loc, list(cp)): - key = (ref, pin_num) - if key not in seen: - seen.add(key) - pins.append({"component": ref, "pin": pin_num}) - break + if _frontier_has_neighbour(pin_loc[0], pin_loc[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}: {e}") From 4277a3d00017a5974897bd23c2346ce0236ccde4 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sat, 14 Mar 2026 14:55:07 +0000 Subject: [PATCH 20/74] refactor: use pre-compiled adjacency list for BFS in get_wire_connections Replace runtime spatial-index queries during BFS with a pre-compiled adjacency list for O(1) edge traversal. Also fix potential UnboundLocalError for `ref` in the pin-checking exception handler and simplify validation. Co-Authored-By: Claude Opus 4.6 --- python/kicad_interface.py | 136 ++++++++++++++++++++------------------ 1 file changed, 70 insertions(+), 66 deletions(-) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index a9f59a0..25e996e 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -2325,6 +2325,7 @@ class KiCADInterface: """Find all component pins reachable from a point via connected wires""" logger.info("Getting wire connections") try: + import math from pathlib import Path from commands.pin_locator import PinLocator @@ -2332,14 +2333,20 @@ class KiCADInterface: x = params.get("x") y = params.get("y") - if not all([schematic_path, x is not None, y is not None]): + if not (schematic_path and x is not None and y is not None): return {"success": False, "message": "Missing required parameters: schematicPath, x, y"} try: x, y = float(x), float(y) except (TypeError, ValueError): return {"success": False, "message": "Parameters x and y must be numeric"} + tolerance = 0.5 + GRID = 0.05 # mm, matches KiCAD schematic grid + grid_radius = math.ceil(tolerance / GRID) + 1 # +1 safety margin for banker's rounding + + def _grid_key(x_coord, y_coord): + return (round(x_coord / GRID), round(y_coord / GRID)) def points_coincide(p1, p2): return abs(p1[0] - p2[0]) < tolerance and abs(p1[1] - p2[1]) < tolerance @@ -2351,93 +2358,88 @@ class KiCADInterface: if not hasattr(schematic, "wire"): return {"success": False, "message": "Schematic has no wires"} - # Collect all wires as list of point sequences + # Collect all wires as list of endpoint 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([float(point.value[0]), float(point.value[1])]) + pts.append((float(point.value[0]), float(point.value[1]))) if len(pts) >= 2: all_wires.append(pts) - # Step 1: Find all seed wires that touch the given point (start or end) - query_point = [x, y] - seed_indices = set( - i for i, pts in enumerate(all_wires) - if any(points_coincide(pt, query_point) for pt in pts) - ) + # Build spatial index: grid_cell -> list of (wire_index, endpoint) pairs + endpoint_index = {} + for i, pts in enumerate(all_wires): + for pt in pts: + endpoint_index.setdefault(_grid_key(pt[0], pt[1]), []).append((i, pt)) + # Pre-compile adjacency list: wire_index -> set of connected wire indices. + # Two wires are adjacent when any of their endpoints coincide. + adjacency = [set() for _ in range(len(all_wires))] + for i, pts in enumerate(all_wires): + for pt in pts: + cx, cy = _grid_key(pt[0], pt[1]) + for dx in range(-grid_radius, grid_radius + 1): + for dy in range(-grid_radius, grid_radius + 1): + for j, ept in endpoint_index.get((cx + dx, cy + dy), ()): + if j != i and points_coincide(pt, ept): + adjacency[i].add(j) + + # Also build a quick lookup from grid cell to wire indices for the seed query + def _wires_near_point(px, py): + """Return indices of wires with an endpoint within tolerance of (px, py).""" + cx, cy = _grid_key(px, py) + result = set() + for dx in range(-grid_radius, grid_radius + 1): + for dy in range(-grid_radius, grid_radius + 1): + for j, ept in endpoint_index.get((cx + dx, cy + dy), ()): + if points_coincide((px, py), ept): + result.add(j) + return result + + # Step 1: Seed — find wires touching the query point + seed_indices = _wires_near_point(x, y) if not seed_indices: return { "success": False, "message": f"No wire found at ({x},{y}) within {tolerance}mm tolerance", } - # Step 2: Flood-fill through connected wires - connected_indices = set(seed_indices) - frontier = set((pt[0], pt[1]) for i in seed_indices for pt in all_wires[i]) + # Step 2: BFS flood-fill using pre-compiled adjacency (O(1) per edge) + visited = set(seed_indices) + queue = list(seed_indices) + net_points = set() + for i in seed_indices: + net_points.update(all_wires[i]) - # Spatial index: grid-snapped dict for O(1) proximity lookup - import math - GRID = 0.05 # mm, matches KiCAD schematic grid - grid_radius = math.ceil(tolerance / GRID) + 1 # cells to check per axis (+1 safety margin for banker's rounding) + 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]) - def _grid_key(x_coord, y_coord): - return (round(x_coord / GRID), round(y_coord / GRID)) + connected_wires = [all_wires[i] for i in visited] - # frontier_grid maps grid cell -> list of (x, y) frontier points in that cell - frontier_grid = {} - for fp in frontier: - key = _grid_key(fp[0], fp[1]) - frontier_grid.setdefault(key, []).append(fp) + # Build a grid over net_points for fast pin proximity checks + net_grid = {} + for pt in net_points: + net_grid.setdefault(_grid_key(pt[0], pt[1]), []).append(pt) - def _frontier_has_neighbour(px, py): - """Check if any frontier point is within tolerance of (px, py).""" + def _on_net(px, py): + """Return True if (px, py) is within tolerance of any net point.""" cx, cy = _grid_key(px, py) for dx in range(-grid_radius, grid_radius + 1): for dy in range(-grid_radius, grid_radius + 1): - cell = (cx + dx, cy + dy) - cell_points = frontier_grid.get(cell) - if cell_points: - for fp in cell_points: - if abs(px - fp[0]) < tolerance and abs(py - fp[1]) < tolerance: - return True + for npt in net_grid.get((cx + dx, cy + dy), ()): + if points_coincide((px, py), npt): + return True return False - def _add_to_frontier_grid(pt): - key = _grid_key(pt[0], pt[1]) - frontier_grid.setdefault(key, []).append(pt) - - remaining_indices = [i for i in range(len(all_wires)) if i not in seed_indices] - changed = True - while changed: - changed = False - still_remaining = [] - for i in remaining_indices: - pts = all_wires[i] - wire_points = [(pt[0], pt[1]) for pt in pts] - if any(_frontier_has_neighbour(wp[0], wp[1]) for wp in wire_points): - connected_indices.add(i) - for pt in pts: - p = (pt[0], pt[1]) - frontier.add(p) - _add_to_frontier_grid(p) - changed = True - else: - still_remaining.append(i) - remaining_indices = still_remaining - - connected_wires = [all_wires[i] for i in connected_indices] - - # Step 3: Collect all points from connected wires - connected_points = set() - for pts in connected_wires: - for pt in pts: - connected_points.add((pt[0], pt[1])) - - # Step 4: Find component pins at connected points + # Step 3: Output wires wires_out = [ {"start": {"x": pts[0][0], "y": pts[0][1]}, "end": {"x": pts[-1][0], "y": pts[-1][1]}} for pts in connected_wires @@ -2445,12 +2447,15 @@ class KiCADInterface: if not hasattr(schematic, "symbol"): return {"success": True, "pins": [], "wires": wires_out} + # Step 4: Find component pins that land on the net locator = PinLocator() pins = [] seen = set() processed_refs = set() + ref: str | None = None for symbol in schematic.symbol: + ref = None try: if not hasattr(symbol, 'property') or not hasattr(symbol.property, "Reference"): continue @@ -2464,14 +2469,13 @@ class KiCADInterface: if not all_pins: continue for pin_num, pin_data in all_pins.items(): - pin_loc = [pin_data[0], pin_data[1]] - if _frontier_has_neighbour(pin_loc[0], pin_loc[1]): + 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}: {e}") + logger.warning(f"Error checking pins for {ref if ref is not None else ''}: {e}") return {"success": True, "pins": pins, "wires": wires_out} From f12003484601b6ea10dee2ed61fef575c1d6cdae Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sat, 14 Mar 2026 16:15:20 +0000 Subject: [PATCH 21/74] refactor: extract wire connectivity into module with KiCad-native IU matching Move wire connectivity logic from _handle_get_wire_connections into commands/wire_connectivity.py. Use KiCad's internal integer unit system (10,000 IU/mm) with exact coordinate matching instead of tolerance-based float comparison, mirroring how KiCad itself determines connectivity. Key improvements: - Exact integer matching for wire endpoints (O(1) dict lookup vs O(n) grid scan) - Junction support for T-connections - Multi-unit symbol support (removed incorrect processed_refs dedup) - Single public API: get_wire_connections() Co-Authored-By: Claude Opus 4.6 --- python/commands/wire_connectivity.py | 214 +++++++++++++++++++++++++++ python/kicad_interface.py | 137 +---------------- 2 files changed, 219 insertions(+), 132 deletions(-) create mode 100644 python/commands/wire_connectivity.py diff --git a/python/commands/wire_connectivity.py b/python/commands/wire_connectivity.py new file mode 100644 index 0000000..42519eb --- /dev/null +++ b/python/commands/wire_connectivity.py @@ -0,0 +1,214 @@ +""" +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 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 +_QUERY_TOLERANCE_IU = 5000 # 0.5 mm in IU — for user-supplied query points + + +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) -> 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 _parse_junctions(schematic) -> List[Tuple[int, int]]: + """Extract junction points from a schematic object as IU tuples. + + Junctions may be exposed via schematic.junction (kicad-skip attribute) or + might not exist. Handle both cases gracefully. + """ + junctions = [] + if not hasattr(schematic, 'junction'): + return junctions + for junc in schematic.junction: + try: + if hasattr(junc, 'at') and hasattr(junc.at, 'value'): + junctions.append(_to_iu(float(junc.at.value[0]), float(junc.at.value[1]))) + except (IndexError, TypeError, ValueError): + continue + return junctions + + +def _build_adjacency( + all_wires: List[List[Tuple[int, int]]], + junctions: List[Tuple[int, int]], +) -> Tuple[List[Set[int]], Dict[Tuple[int, int], Set[int]]]: + """Build wire adjacency using exact IU coordinate matching. + + 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) + + # Junctions: connect all wires that have an endpoint at the junction IU point + for junc_iu in junctions: + wire_set = iu_to_wires.get(junc_iu, set()) + 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 _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]], +) -> Tuple: + """BFS from query point. Returns (visited wire indices, net IU points) or (None, None). + + Uses _QUERY_TOLERANCE_IU for the seed step because user-supplied coordinates + may be imprecise. Wire-to-wire matching inside _build_adjacency is exact. + """ + query_iu = _to_iu(x_mm, y_mm) + + # Find seed wires: any wire whose endpoint is within _QUERY_TOLERANCE_IU of the query + seed_indices: Set[int] = set() + for iu_pt, wire_indices in iu_to_wires.items(): + if (abs(iu_pt[0] - query_iu[0]) <= _QUERY_TOLERANCE_IU and + abs(iu_pt[1] - query_iu[1]) <= _QUERY_TOLERANCE_IU): + seed_indices.update(wire_indices) + + if not seed_indices: + return (None, None) + + # 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]) + + 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]) + + return (visited, net_points) + + +def _find_pins_on_net( + net_points: Set[Tuple[int, int]], + schematic_path, + schematic, +) -> List[Dict]: + """Find component pins that land on net points. + + Uses exact IU matching with a ±_PIN_TOLERANCE_IU neighbourhood to guard + against floating-point round-trip differences between wire and pin coordinates. + + Returns a list of {"component": ref, "pin": pin_num} dicts. + """ + + def _on_net(px_mm: float, py_mm: float) -> bool: + pin_iu = _to_iu(px_mm, py_mm) + if pin_iu in net_points: + return True + x, y = pin_iu + return ((x+1, y) in net_points or (x-1, y) in net_points or + (x, y+1) in net_points or (x, y-1) 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 ''}: {e}") + + return pins + + +def get_wire_connections(schematic, schematic_path: str, x_mm: float, y_mm: float) -> Optional[Dict]: + """Find all component pins reachable from a point via connected wires. + + Returns dict with keys: + - "pins": list of {"component": str, "pin": str} + - "wires": list of {"start": {"x", "y"}, "end": {"x", "y"}} in mm + Or None if no wire found at the query point. + """ + all_wires = _parse_wires(schematic) + if not all_wires: + return {"pins": [], "wires": []} + + junctions = _parse_junctions(schematic) + adjacency, iu_to_wires = _build_adjacency(all_wires, junctions) + + visited, net_points = _find_connected_wires(x_mm, y_mm, all_wires, iu_to_wires, adjacency) + if visited is None: + return None + + 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 {"pins": [], "wires": wires_out} + + pins = _find_pins_on_net(net_points, schematic_path, schematic) + return {"pins": pins, "wires": wires_out} diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 25e996e..937f4f3 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -2325,9 +2325,7 @@ class KiCADInterface: """Find all component pins reachable from a point via connected wires""" logger.info("Getting wire connections") try: - import math - from pathlib import Path - from commands.pin_locator import PinLocator + from commands.wire_connectivity import get_wire_connections schematic_path = params.get("schematicPath") x = params.get("x") @@ -2341,16 +2339,6 @@ class KiCADInterface: except (TypeError, ValueError): return {"success": False, "message": "Parameters x and y must be numeric"} - tolerance = 0.5 - GRID = 0.05 # mm, matches KiCAD schematic grid - grid_radius = math.ceil(tolerance / GRID) + 1 # +1 safety margin for banker's rounding - - def _grid_key(x_coord, y_coord): - return (round(x_coord / GRID), round(y_coord / GRID)) - - def points_coincide(p1, p2): - return abs(p1[0] - p2[0]) < tolerance and abs(p1[1] - p2[1]) < tolerance - schematic = SchematicManager.load_schematic(schematic_path) if not schematic: return {"success": False, "message": "Failed to load schematic"} @@ -2358,126 +2346,11 @@ class KiCADInterface: if not hasattr(schematic, "wire"): return {"success": False, "message": "Schematic has no wires"} - # Collect all wires as list of endpoint 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((float(point.value[0]), float(point.value[1]))) - if len(pts) >= 2: - all_wires.append(pts) + result = get_wire_connections(schematic, schematic_path, x, y) + if result is None: + return {"success": False, "message": f"No wire found at ({x},{y}) within tolerance"} - # Build spatial index: grid_cell -> list of (wire_index, endpoint) pairs - endpoint_index = {} - for i, pts in enumerate(all_wires): - for pt in pts: - endpoint_index.setdefault(_grid_key(pt[0], pt[1]), []).append((i, pt)) - - # Pre-compile adjacency list: wire_index -> set of connected wire indices. - # Two wires are adjacent when any of their endpoints coincide. - adjacency = [set() for _ in range(len(all_wires))] - for i, pts in enumerate(all_wires): - for pt in pts: - cx, cy = _grid_key(pt[0], pt[1]) - for dx in range(-grid_radius, grid_radius + 1): - for dy in range(-grid_radius, grid_radius + 1): - for j, ept in endpoint_index.get((cx + dx, cy + dy), ()): - if j != i and points_coincide(pt, ept): - adjacency[i].add(j) - - # Also build a quick lookup from grid cell to wire indices for the seed query - def _wires_near_point(px, py): - """Return indices of wires with an endpoint within tolerance of (px, py).""" - cx, cy = _grid_key(px, py) - result = set() - for dx in range(-grid_radius, grid_radius + 1): - for dy in range(-grid_radius, grid_radius + 1): - for j, ept in endpoint_index.get((cx + dx, cy + dy), ()): - if points_coincide((px, py), ept): - result.add(j) - return result - - # Step 1: Seed — find wires touching the query point - seed_indices = _wires_near_point(x, y) - if not seed_indices: - return { - "success": False, - "message": f"No wire found at ({x},{y}) within {tolerance}mm tolerance", - } - - # Step 2: BFS flood-fill using pre-compiled adjacency (O(1) per edge) - visited = set(seed_indices) - queue = list(seed_indices) - net_points = set() - for i in seed_indices: - net_points.update(all_wires[i]) - - 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]) - - connected_wires = [all_wires[i] for i in visited] - - # Build a grid over net_points for fast pin proximity checks - net_grid = {} - for pt in net_points: - net_grid.setdefault(_grid_key(pt[0], pt[1]), []).append(pt) - - def _on_net(px, py): - """Return True if (px, py) is within tolerance of any net point.""" - cx, cy = _grid_key(px, py) - for dx in range(-grid_radius, grid_radius + 1): - for dy in range(-grid_radius, grid_radius + 1): - for npt in net_grid.get((cx + dx, cy + dy), ()): - if points_coincide((px, py), npt): - return True - return False - - # Step 3: Output wires - wires_out = [ - {"start": {"x": pts[0][0], "y": pts[0][1]}, "end": {"x": pts[-1][0], "y": pts[-1][1]}} - for pts in connected_wires - ] - if not hasattr(schematic, "symbol"): - return {"success": True, "pins": [], "wires": wires_out} - - # Step 4: Find component pins that land on the net - locator = PinLocator() - pins = [] - seen = set() - processed_refs = set() - - ref: str | None = None - for symbol in schematic.symbol: - ref = None - try: - if not hasattr(symbol, 'property') or not hasattr(symbol.property, "Reference"): - continue - ref = symbol.property.Reference.value - if ref.startswith("_TEMPLATE"): - continue - if ref in processed_refs: - continue - processed_refs.add(ref) - 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 ''}: {e}") - - return {"success": True, "pins": pins, "wires": wires_out} + return {"success": True, **result} except Exception as e: logger.error(f"Error getting wire connections: {str(e)}") From de0eca2ed7139cb830fa38849508d5d3fe2eacf0 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sat, 14 Mar 2026 16:20:11 +0000 Subject: [PATCH 22/74] refactor: remove redundant junction processing from wire connectivity Shared-endpoint adjacency already connects all wires meeting at the same point, making explicit junction handling a no-op duplicate. Co-Authored-By: Claude Opus 4.6 --- python/commands/wire_connectivity.py | 34 ++++------------------------ 1 file changed, 4 insertions(+), 30 deletions(-) diff --git a/python/commands/wire_connectivity.py b/python/commands/wire_connectivity.py index 42519eb..5942c6a 100644 --- a/python/commands/wire_connectivity.py +++ b/python/commands/wire_connectivity.py @@ -36,30 +36,14 @@ def _parse_wires(schematic) -> List[List[Tuple[int, int]]]: return all_wires -def _parse_junctions(schematic) -> List[Tuple[int, int]]: - """Extract junction points from a schematic object as IU tuples. - - Junctions may be exposed via schematic.junction (kicad-skip attribute) or - might not exist. Handle both cases gracefully. - """ - junctions = [] - if not hasattr(schematic, 'junction'): - return junctions - for junc in schematic.junction: - try: - if hasattr(junc, 'at') and hasattr(junc.at, 'value'): - junctions.append(_to_iu(float(junc.at.value[0]), float(junc.at.value[1]))) - except (IndexError, TypeError, ValueError): - continue - return junctions - - def _build_adjacency( all_wires: List[List[Tuple[int, int]]], - junctions: 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 @@ -80,15 +64,6 @@ def _build_adjacency( if a != b: adjacency[a].add(b) - # Junctions: connect all wires that have an endpoint at the junction IU point - for junc_iu in junctions: - wire_set = iu_to_wires.get(junc_iu, set()) - 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 @@ -194,8 +169,7 @@ def get_wire_connections(schematic, schematic_path: str, x_mm: float, y_mm: floa if not all_wires: return {"pins": [], "wires": []} - junctions = _parse_junctions(schematic) - adjacency, iu_to_wires = _build_adjacency(all_wires, junctions) + adjacency, iu_to_wires = _build_adjacency(all_wires) visited, net_points = _find_connected_wires(x_mm, y_mm, all_wires, iu_to_wires, adjacency) if visited is None: From 8414784b788e2af465a7121f3e088f723523abe6 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sat, 14 Mar 2026 16:43:06 +0000 Subject: [PATCH 23/74] docs: clarify get_wire_connections requires endpoint coordinates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update tool description and Python docstring to make clear that the query point must be at a wire endpoint or junction — midpoints of wire segments are not matched. Co-Authored-By: Claude Sonnet 4.6 --- python/commands/wire_connectivity.py | 6 +++++- src/tools/schematic.ts | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/python/commands/wire_connectivity.py b/python/commands/wire_connectivity.py index 5942c6a..b7a1c41 100644 --- a/python/commands/wire_connectivity.py +++ b/python/commands/wire_connectivity.py @@ -160,10 +160,14 @@ def _find_pins_on_net( def get_wire_connections(schematic, schematic_path: str, x_mm: float, y_mm: float) -> Optional[Dict]: """Find all component pins reachable from a point via connected wires. + The query point (x_mm, y_mm) must be within _QUERY_TOLERANCE_IU (0.5 mm) of + a wire endpoint or junction. Interior (mid-segment) points are not matched — + use wire endpoint coordinates obtained from the schematic data. + Returns dict with keys: - "pins": list of {"component": str, "pin": str} - "wires": list of {"start": {"x", "y"}, "end": {"x", "y"}} in mm - Or None if no wire found at the query point. + Or None if no wire endpoint found within tolerance of the query point. """ all_wires = _parse_wires(schematic) if not all_wires: diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index 594be86..55aff42 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -428,11 +428,11 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp // Get wire connections server.tool( "get_wire_connections", - "Find all component pins reachable from a schematic point via connected wires. Provide any point on a wire (start, end, or junction) to get all pins on that net.", + "Find all component pins reachable from a schematic point via connected wires. The query point must be at a wire endpoint or junction — midpoints of wire segments are not matched. Use get_schematic_pin_locations or list_schematic_wires to obtain exact endpoint coordinates first.", { schematicPath: z.string().describe("Path to the schematic file"), - x: z.number().describe("X coordinate of the point on the wire"), - y: z.number().describe("Y coordinate of the point on the wire"), + x: z.number().describe("X coordinate of a wire endpoint or junction"), + y: z.number().describe("Y coordinate of a wire endpoint or junction"), }, async (args: { schematicPath: string; x: number; y: number }) => { const result = await callKicadScript("get_wire_connections", args); From 018f4a278daf56b1e914a1dbd1e7bf3af40c2569 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sat, 14 Mar 2026 17:16:57 +0000 Subject: [PATCH 24/74] refactor: switch get_wire_connections to exact IU matching throughout Remove the 0.5mm query tolerance in favour of exact integer-unit matching on all coordinate lookups (seed, label bridging, pin matching), mirroring KiCad's own connectivity algorithm. Callers must supply exact wire endpoint coordinates (e.g. from list_schematic_wires). Co-Authored-By: Claude Sonnet 4.6 --- python/commands/wire_connectivity.py | 114 +++++++++++++++++++++------ src/tools/schematic.ts | 2 +- 2 files changed, 90 insertions(+), 26 deletions(-) diff --git a/python/commands/wire_connectivity.py b/python/commands/wire_connectivity.py index b7a1c41..9662a98 100644 --- a/python/commands/wire_connectivity.py +++ b/python/commands/wire_connectivity.py @@ -14,7 +14,6 @@ from commands.pin_locator import PinLocator logger = logging.getLogger('kicad_interface') _IU_PER_MM = 10000 # KiCad schematic internal units per millimeter -_QUERY_TOLERANCE_IU = 5000 # 0.5 mm in IU — for user-supplied query points def _to_iu(x_mm: float, y_mm: float) -> Tuple[int, int]: @@ -67,29 +66,78 @@ def _build_adjacency( return adjacency, iu_to_wires +def _parse_virtual_connections(schematic, schematic_path): + """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). - Uses _QUERY_TOLERANCE_IU for the seed step because user-supplied coordinates - may be imprecise. Wire-to-wire matching inside _build_adjacency is exact. + 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: any wire whose endpoint is within _QUERY_TOLERANCE_IU of the query - seed_indices: Set[int] = set() - for iu_pt, wire_indices in iu_to_wires.items(): - if (abs(iu_pt[0] - query_iu[0]) <= _QUERY_TOLERANCE_IU and - abs(iu_pt[1] - query_iu[1]) <= _QUERY_TOLERANCE_IU): - seed_indices.update(wire_indices) - - if not seed_indices: + # 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) @@ -98,6 +146,7 @@ def _find_connected_wires( 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]: @@ -106,6 +155,20 @@ def _find_connected_wires( 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) @@ -114,21 +177,13 @@ def _find_pins_on_net( schematic_path, schematic, ) -> List[Dict]: - """Find component pins that land on net points. - - Uses exact IU matching with a ±_PIN_TOLERANCE_IU neighbourhood to guard - against floating-point round-trip differences between wire and pin coordinates. + """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: - pin_iu = _to_iu(px_mm, py_mm) - if pin_iu in net_points: - return True - x, y = pin_iu - return ((x+1, y) in net_points or (x-1, y) in net_points or - (x, y+1) in net_points or (x, y-1) in net_points) + return _to_iu(px_mm, py_mm) in net_points locator = PinLocator() pins = [] @@ -158,12 +213,15 @@ def _find_pins_on_net( def get_wire_connections(schematic, schematic_path: str, x_mm: float, y_mm: float) -> Optional[Dict]: - """Find all component pins reachable from a point via connected wires. + """Find all component pins reachable from a point via connected wires, net labels, and power symbols. - The query point (x_mm, y_mm) must be within _QUERY_TOLERANCE_IU (0.5 mm) of - a wire endpoint or junction. Interior (mid-segment) points are not matched — + 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: - "pins": list of {"component": str, "pin": str} - "wires": list of {"start": {"x", "y"}, "end": {"x", "y"}} in mm @@ -175,7 +233,13 @@ def get_wire_connections(schematic, schematic_path: str, x_mm: float, y_mm: floa adjacency, iu_to_wires = _build_adjacency(all_wires) - visited, net_points = _find_connected_wires(x_mm, y_mm, all_wires, iu_to_wires, adjacency) + 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 diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index 55aff42..2dc6604 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -428,7 +428,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp // Get wire connections server.tool( "get_wire_connections", - "Find all component pins reachable from a schematic point via connected wires. The query point must be at a wire endpoint or junction — midpoints of wire segments are not matched. Use get_schematic_pin_locations or list_schematic_wires to obtain exact endpoint coordinates first.", + "Find all component pins reachable from a schematic point via connected wires, net labels, and power symbols. The query point must be at a wire endpoint or junction — midpoints of wire segments are not matched. Use get_schematic_pin_locations or list_schematic_wires to obtain exact endpoint coordinates first.", { schematicPath: z.string().describe("Path to the schematic file"), x: z.number().describe("X coordinate of a wire endpoint or junction"), From 9f8943791885a5a0ae3ec3e735e20e9c827d3454 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 15 Mar 2026 23:06:08 +0000 Subject: [PATCH 25/74] chore: apply Black formatting and add tests for get_wire_connections - Apply Black formatting to wire_connectivity.py, kicad_interface.py, and tool_schemas.py (changed files only) - Add python/tests/conftest.py with pcbnew/skip C-extension stubs - Add python/tests/test_wire_connectivity.py with 29 unit tests covering schema validation, handler dispatch, parameter validation, and core logic (_to_iu, _parse_wires, _build_adjacency, _find_connected_wires, get_wire_connections) Co-Authored-By: Claude Sonnet 4.6 --- python/commands/wire_connectivity.py | 40 ++- python/kicad_interface.py | 16 +- python/schemas/tool_schemas.py | 12 +- python/tests/test_wire_connectivity.py | 338 +++++++++++++++++++++++++ 4 files changed, 388 insertions(+), 18 deletions(-) create mode 100644 python/tests/test_wire_connectivity.py diff --git a/python/commands/wire_connectivity.py b/python/commands/wire_connectivity.py index 9662a98..dfba1e8 100644 --- a/python/commands/wire_connectivity.py +++ b/python/commands/wire_connectivity.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import Dict, List, Optional, Set, Tuple from commands.pin_locator import PinLocator -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") _IU_PER_MM = 10000 # KiCad schematic internal units per millimeter @@ -95,7 +95,9 @@ def _parse_virtual_connections(schematic, schematic_path): locator = PinLocator() for symbol in schematic.symbol: try: - if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"): + if not hasattr(symbol, "property") or not hasattr( + symbol.property, "Reference" + ): continue ref = symbol.property.Reference.value if not ref.startswith("#PWR"): @@ -192,7 +194,9 @@ def _find_pins_on_net( ref = None for symbol in schematic.symbol: try: - if not hasattr(symbol, 'property') or not hasattr(symbol.property, "Reference"): + if not hasattr(symbol, "property") or not hasattr( + symbol.property, "Reference" + ): continue ref = symbol.property.Reference.value if ref.startswith("_TEMPLATE"): @@ -207,12 +211,16 @@ def _find_pins_on_net( 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 ''}: {e}") + logger.warning( + f"Error checking pins for {ref if ref is not None else ''}: {e}" + ) return pins -def get_wire_connections(schematic, schematic_path: str, x_mm: float, y_mm: float) -> Optional[Dict]: +def get_wire_connections( + schematic, schematic_path: str, x_mm: float, y_mm: float +) -> Optional[Dict]: """Find all component pins reachable from a point via connected wires, net labels, and power symbols. The query point (x_mm, y_mm) must be exactly on a wire endpoint or junction (exact IU match). @@ -233,10 +241,16 @@ def get_wire_connections(schematic, schematic_path: str, x_mm: float, y_mm: floa adjacency, iu_to_wires = _build_adjacency(all_wires) - point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path) + 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, + x_mm, + y_mm, + all_wires, + iu_to_wires, + adjacency, point_to_label=point_to_label, label_to_points=label_to_points, ) @@ -244,8 +258,16 @@ def get_wire_connections(schematic, schematic_path: str, x_mm: float, y_mm: floa return None 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}} + { + "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 ] diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 937f4f3..3abb116 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -2332,12 +2332,18 @@ class KiCADInterface: y = params.get("y") if not (schematic_path and x is not None and y is not None): - return {"success": False, "message": "Missing required parameters: schematicPath, x, y"} + return { + "success": False, + "message": "Missing required parameters: schematicPath, x, y", + } try: x, y = float(x), float(y) except (TypeError, ValueError): - return {"success": False, "message": "Parameters x and y must be numeric"} + return { + "success": False, + "message": "Parameters x and y must be numeric", + } schematic = SchematicManager.load_schematic(schematic_path) if not schematic: @@ -2348,13 +2354,17 @@ class KiCADInterface: result = get_wire_connections(schematic, schematic_path, x, y) if result is None: - return {"success": False, "message": f"No wire found at ({x},{y}) within tolerance"} + return { + "success": False, + "message": f"No wire found at ({x},{y}) within tolerance", + } return {"success": True, **result} except Exception as e: logger.error(f"Error getting wire connections: {str(e)}") import traceback + logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index a94f1c0..49b91ae 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1480,19 +1480,19 @@ SCHEMATIC_TOOLS = [ "properties": { "schematicPath": { "type": "string", - "description": "Path to schematic file" + "description": "Path to schematic file", }, "x": { "type": "number", - "description": "X coordinate of the point on the wire" + "description": "X coordinate of the point on the wire", }, "y": { "type": "number", - "description": "Y coordinate of the point on the wire" - } + "description": "Y coordinate of the point on the wire", + }, }, - "required": ["schematicPath", "x", "y"] - } + "required": ["schematicPath", "x", "y"], + }, }, { "name": "get_schematic_pin_locations", diff --git a/python/tests/test_wire_connectivity.py b/python/tests/test_wire_connectivity.py new file mode 100644 index 0000000..e8c7028 --- /dev/null +++ b/python/tests/test_wire_connectivity.py @@ -0,0 +1,338 @@ +""" +Tests for the wire_connectivity module and the get_wire_connections handler. + +Covers: + - Schema shape (TestSchema) + - Handler dispatch registration (TestHandlerDispatch) + - Parameter validation in the handler (TestHandlerParamValidation) + - Core logic: _to_iu, _parse_wires, _build_adjacency, _find_connected_wires, + get_wire_connections (TestCoreLogic) +""" + +import sys +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +# Ensure the python package root is importable +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# --------------------------------------------------------------------------- +# Module under test +# --------------------------------------------------------------------------- +from commands.wire_connectivity import ( + _build_adjacency, + _find_connected_wires, + _parse_wires, + _to_iu, + get_wire_connections, +) + +# --------------------------------------------------------------------------- +# Helpers to build minimal mock schematic objects +# --------------------------------------------------------------------------- + + +def _make_point(x: float, y: float) -> MagicMock: + pt = MagicMock() + pt.value = [x, y] + return pt + + +def _make_wire(x1: float, y1: float, x2: float, y2: float) -> MagicMock: + wire = MagicMock() + wire.pts = MagicMock() + wire.pts.xy = [_make_point(x1, y1), _make_point(x2, y2)] + return wire + + +def _make_schematic(*wires) -> MagicMock: + sch = MagicMock() + sch.wire = list(wires) + # No net labels, no symbols by default + del sch.label # make hasattr(..., "label") return False + del sch.symbol # make hasattr(..., "symbol") return False + return sch + + +# --------------------------------------------------------------------------- +# TestSchema +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestSchema: + """Verify the get_wire_connections tool schema is present and well-formed.""" + + def test_schema_registered(self): + from schemas.tool_schemas import TOOL_SCHEMAS + + assert "get_wire_connections" in TOOL_SCHEMAS + + def test_schema_required_fields(self): + from schemas.tool_schemas import TOOL_SCHEMAS + + schema = TOOL_SCHEMAS["get_wire_connections"] + required = schema["inputSchema"]["required"] + assert "schematicPath" in required + assert "x" in required + assert "y" in required + + def test_schema_has_title_and_description(self): + from schemas.tool_schemas import TOOL_SCHEMAS + + schema = TOOL_SCHEMAS["get_wire_connections"] + assert schema.get("title") + assert schema.get("description") + + +# --------------------------------------------------------------------------- +# TestHandlerDispatch +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestHandlerDispatch: + """Verify the handler is wired into KiCadInterface.command_routes.""" + + def test_get_wire_connections_in_routes(self): + # Import lazily to avoid heavy side-effects at collection time + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + iface.board = None + iface.project_filename = None + iface.use_ipc = False + iface.ipc_backend = MagicMock() + iface.ipc_board_api = None + iface.footprint_library = MagicMock() + iface.project_commands = MagicMock() + iface.board_commands = MagicMock() + iface.component_commands = MagicMock() + iface.routing_commands = MagicMock() + + # Build routes only (avoid full __init__ side-effects) + # The routes dict is built in __init__; we call it directly. + iface.__init__() + + assert "get_wire_connections" in iface.command_routes + assert callable(iface.command_routes["get_wire_connections"]) + + +# --------------------------------------------------------------------------- +# TestHandlerParamValidation +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestHandlerParamValidation: + """Handler returns error responses for bad or missing parameters.""" + + def _make_handler(self): + """Return a bound _handle_get_wire_connections without full init.""" + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + return iface._handle_get_wire_connections + + def test_missing_schematic_path(self): + handler = self._make_handler() + result = handler({"x": 1.0, "y": 2.0}) + assert result["success"] is False + assert "schematicPath" in result["message"] or "Missing" in result["message"] + + def test_missing_x(self): + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "y": 2.0}) + assert result["success"] is False + + def test_missing_y(self): + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0}) + assert result["success"] is False + + def test_non_numeric_x(self): + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "bad", "y": 2.0}) + assert result["success"] is False + assert "numeric" in result["message"].lower() or "x" in result["message"] + + def test_non_numeric_y(self): + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0, "y": "bad"}) + assert result["success"] is False + + +# --------------------------------------------------------------------------- +# TestCoreLogic +# --------------------------------------------------------------------------- + +_IU = 10_000 # IU per mm + + +@pytest.mark.unit +class TestCoreLogic: + """Unit tests for the pure-logic functions in wire_connectivity.""" + + # --- _to_iu --- + + def test_to_iu_integer_mm(self): + assert _to_iu(1.0, 2.0) == (10_000, 20_000) + + def test_to_iu_fractional_mm(self): + assert _to_iu(0.5, 0.25) == (5_000, 2_500) + + def test_to_iu_zero(self): + assert _to_iu(0.0, 0.0) == (0, 0) + + def test_to_iu_negative(self): + assert _to_iu(-1.0, -2.0) == (-10_000, -20_000) + + # --- _parse_wires --- + + def test_parse_wires_single_wire(self): + sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) + result = _parse_wires(sch) + assert len(result) == 1 + assert result[0] == [(0, 0), (10_000, 0)] + + def test_parse_wires_empty_schematic(self): + sch = MagicMock() + sch.wire = [] + assert _parse_wires(sch) == [] + + def test_parse_wires_multiple_wires(self): + sch = _make_schematic( + _make_wire(0.0, 0.0, 1.0, 0.0), + _make_wire(1.0, 0.0, 2.0, 0.0), + ) + assert len(_parse_wires(sch)) == 2 + + def test_parse_wires_skips_wire_without_pts(self): + bad_wire = MagicMock(spec=[]) # no `pts` attribute + sch = MagicMock() + sch.wire = [bad_wire] + assert _parse_wires(sch) == [] + + # --- _build_adjacency --- + + def test_build_adjacency_two_connected_wires(self): + # wire0: (0,0)-(1,0), wire1: (1,0)-(2,0) — share endpoint (1,0) + wires = [ + [(0, 0), (10_000, 0)], + [(10_000, 0), (20_000, 0)], + ] + adjacency, iu_to_wires = _build_adjacency(wires) + assert 1 in adjacency[0] + assert 0 in adjacency[1] + + def test_build_adjacency_two_disconnected_wires(self): + wires = [ + [(0, 0), (10_000, 0)], + [(20_000, 0), (30_000, 0)], + ] + adjacency, _ = _build_adjacency(wires) + assert adjacency[0] == set() + assert adjacency[1] == set() + + def test_build_adjacency_iu_to_wires_maps_correctly(self): + wires = [ + [(0, 0), (10_000, 0)], + [(10_000, 0), (20_000, 0)], + ] + _, iu_to_wires = _build_adjacency(wires) + assert iu_to_wires[(10_000, 0)] == {0, 1} + assert iu_to_wires[(0, 0)] == {0} + + def test_build_adjacency_three_wires_at_junction(self): + # All three wires meet at (10,000, 0) + wires = [ + [(0, 0), (10_000, 0)], + [(10_000, 0), (20_000, 0)], + [(10_000, 0), (10_000, 10_000)], + ] + adjacency, _ = _build_adjacency(wires) + assert adjacency[0] == {1, 2} + assert adjacency[1] == {0, 2} + assert adjacency[2] == {0, 1} + + # --- _find_connected_wires --- + + def test_find_connected_wires_no_wire_at_point(self): + wires = [[(0, 0), (10_000, 0)]] + adjacency, iu_to_wires = _build_adjacency(wires) + visited, net_points = _find_connected_wires( + 5.0, 0.0, wires, iu_to_wires, adjacency + ) + assert visited is None + assert net_points is None + + def test_find_connected_wires_single_wire(self): + wires = [[(0, 0), (10_000, 0)]] + adjacency, iu_to_wires = _build_adjacency(wires) + visited, net_points = _find_connected_wires( + 0.0, 0.0, wires, iu_to_wires, adjacency + ) + assert visited == {0} + assert (0, 0) in net_points + assert (10_000, 0) in net_points + + def test_find_connected_wires_flood_fills_chain(self): + # Three wires in a chain: A-B-C-D + wires = [ + [(0, 0), (10_000, 0)], + [(10_000, 0), (20_000, 0)], + [(20_000, 0), (30_000, 0)], + ] + adjacency, iu_to_wires = _build_adjacency(wires) + visited, net_points = _find_connected_wires( + 0.0, 0.0, wires, iu_to_wires, adjacency + ) + assert visited == {0, 1, 2} + + def test_find_connected_wires_does_not_cross_gap(self): + # Two disconnected segments; query on segment 0 should not reach segment 1 + wires = [ + [(0, 0), (10_000, 0)], + [(20_000, 0), (30_000, 0)], + ] + adjacency, iu_to_wires = _build_adjacency(wires) + visited, _ = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) + assert visited == {0} + + # --- get_wire_connections (integration of internal functions) --- + + def test_get_wire_connections_no_wires(self): + sch = MagicMock() + sch.wire = [] + result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) + assert result == {"pins": [], "wires": []} + + def test_get_wire_connections_no_wire_at_point_returns_none(self): + sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) + result = get_wire_connections(sch, "/fake/path.kicad_sch", 5.0, 0.0) + assert result is None + + def test_get_wire_connections_returns_wire_data(self): + sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) + # Prevent _find_pins_on_net from iterating symbols + result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) + assert result is not None + assert result["pins"] == [] + assert len(result["wires"]) == 1 + wire = result["wires"][0] + assert wire["start"] == {"x": 0.0, "y": 0.0} + assert wire["end"] == {"x": 1.0, "y": 0.0} + + def test_get_wire_connections_chain_returns_all_wires(self): + sch = _make_schematic( + _make_wire(0.0, 0.0, 1.0, 0.0), + _make_wire(1.0, 0.0, 2.0, 0.0), + ) + result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) + assert result is not None + assert len(result["wires"]) == 2 From 3bd3c3a962feed9f3ccc0f8debe3999198b735ef Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Tue, 24 Mar 2026 21:11:47 +0000 Subject: [PATCH 26/74] feat: add wire/junction tools with pin-snapping and T-junction support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename add_schematic_connection → add_schematic_wire with waypoints[] parameter - Add snapToPins (default true) to snap wire endpoints to nearest pin - Expose add_schematic_junction as an MCP tool - Break existing wires at new wire endpoints for T-junction support - Remove orphaned add_connection / add_wire / get_pin_location from ConnectionManager - Update tool registry to reflect renamed schematic tools in TS layer - Add 76 tests for wire/junction handler dispatch, schema validation, and WireManager corner cases - Apply Black and Prettier formatting to changed files Co-Authored-By: Claude Sonnet 4.6 --- python/commands/connection_schematic.py | 229 +---- python/commands/wire_manager.py | 392 ++++---- python/kicad_interface.py | 185 ++-- python/schemas/tool_schemas.py | 73 +- python/tests/test_wire_junction_changes.py | 1038 ++++++++++++++++++++ src/tools/registry.ts | 4 +- src/tools/schematic.ts | 224 +++-- 7 files changed, 1584 insertions(+), 561 deletions(-) create mode 100644 python/tests/test_wire_junction_changes.py diff --git a/python/commands/connection_schematic.py b/python/commands/connection_schematic.py index 45542d1..1b4ef86 100644 --- a/python/commands/connection_schematic.py +++ b/python/commands/connection_schematic.py @@ -30,172 +30,6 @@ class ConnectionManager: 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): """ @@ -257,15 +91,20 @@ class ConnectionManager: # 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) + 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 + pin_angle_deg = ( + locator.get_pin_angle(schematic_path, component_ref, pin_name) or 0 + ) except Exception: 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) @@ -333,9 +172,15 @@ class ConnectionManager: connected = [] failed = [] - for pin_num in sorted(src_pins.keys(), key=lambda x: int(x) if x.isdigit() else 0): + 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( schematic_path, source_ref, pin_num, net_name @@ -355,11 +200,15 @@ class ConnectionManager: failed.append(f"{target_ref}/{pin_num} (pin not found)") continue - connected.append(f"{source_ref}/{pin_num} <-> {target_ref}/{pin_num} [{net_name}]") + connected.append( + f"{source_ref}/{pin_num} <-> {target_ref}/{pin_num} [{net_name}]" + ) except Exception as e: failed.append(f"{source_ref}/{pin_num}: {e}") - logger.info(f"connect_passthrough: {len(connected)} connected, {len(failed)} failed") + logger.info( + f"connect_passthrough: {len(connected)} connected, {len(failed)} failed" + ) return {"connected": connected, "failed": failed} @staticmethod @@ -607,35 +456,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") diff --git a/python/commands/wire_manager.py b/python/commands/wire_manager.py index 272760b..5e30cf8 100644 --- a/python/commands/wire_manager.py +++ b/python/commands/wire_manager.py @@ -11,24 +11,29 @@ import logging import math import tempfile from pathlib import Path -from typing import List, Tuple, Optional, Dict +from typing import List, Tuple, Optional import sexpdata from sexpdata import Symbol -logger = logging.getLogger("kicad_interface") +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_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""" @staticmethod - def add_wire( - schematic_path: Path, - start_point: List[float], - end_point: List[float], - stroke_width: float = 0, - stroke_type: str = "default", - ) -> bool: + def add_wire(schematic_path: Path, start_point: List[float], end_point: List[float], + stroke_width: float = 0, stroke_type: str = 'default') -> bool: """ Add a wire to the schematic using S-expression manipulation @@ -44,36 +49,25 @@ class WireManager: """ try: # Read schematic - with open(schematic_path, "r", encoding="utf-8") as f: + with open(schematic_path, 'r', encoding='utf-8') as f: sch_content = f.read() 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 @@ -86,7 +80,7 @@ class WireManager: logger.info(f"Injected wire from {start_point} to {end_point}") # Write back - with open(schematic_path, "w", encoding="utf-8") as f: + with open(schematic_path, 'w', encoding='utf-8') as f: output = sexpdata.dumps(sch_data) f.write(output) @@ -96,17 +90,12 @@ class WireManager: except Exception as e: logger.error(f"Error adding wire: {e}") import traceback - logger.error(traceback.format_exc()) return False @staticmethod - def add_polyline_wire( - schematic_path: Path, - points: List[List[float]], - stroke_width: float = 0, - stroke_type: str = "default", - ) -> bool: + def add_polyline_wire(schematic_path: Path, points: List[List[float]], + stroke_width: float = 0, stroke_type: str = 'default') -> bool: """ Add a multi-segment wire (polyline) to the schematic @@ -125,36 +114,28 @@ class WireManager: return False # Read schematic - with open(schematic_path, "r", encoding="utf-8") as f: + with open(schematic_path, 'r', encoding='utf-8') as f: sch_content = f.read() 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,12 +143,13 @@ class WireManager: logger.error("No sheet_instances section found in schematic") return False - # Insert wire - sch_data.insert(sheet_instances_index, wire_sexp) - logger.info(f"Injected polyline wire with {len(points)} points") + # 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 {len(wire_sexps)} wire segments for {len(points)}-point polyline") # Write back - with open(schematic_path, "w", encoding="utf-8") as f: + with open(schematic_path, 'w', encoding='utf-8') as f: output = sexpdata.dumps(sch_data) f.write(output) @@ -177,18 +159,12 @@ class WireManager: except Exception as e: logger.error(f"Error adding polyline wire: {e}") import traceback - logger.error(traceback.format_exc()) return False @staticmethod - def add_label( - schematic_path: Path, - text: str, - position: List[float], - label_type: str = "label", - orientation: int = 0, - ) -> bool: + def add_label(schematic_path: Path, text: str, position: List[float], + label_type: str = 'label', orientation: int = 0) -> bool: """ Add a net label to the schematic @@ -204,7 +180,7 @@ class WireManager: """ try: # Read schematic - with open(schematic_path, "r", encoding="utf-8") as f: + with open(schematic_path, 'r', encoding='utf-8') as f: sch_content = f.read() sch_data = sexpdata.loads(sch_content) @@ -214,24 +190,19 @@ class WireManager: label_sexp = [ Symbol(label_type), text, - [Symbol("at"), position[0], position[1], orientation], - [Symbol("fields_autoplaced"), Symbol("yes")], - [ - Symbol("effects"), - [Symbol("font"), [Symbol("size"), 1.27, 1.27]], - [Symbol("justify"), Symbol("left"), Symbol("bottom")], + [Symbol('at'), position[0], position[1], orientation], + [Symbol('fields_autoplaced'), Symbol('yes')], + [Symbol('effects'), + [Symbol('font'), [Symbol('size'), 1.27, 1.27]], + [Symbol('justify'), Symbol('left'), Symbol('bottom')] ], - [Symbol("uuid"), str(uuid.uuid4())], + [Symbol('uuid'), str(uuid.uuid4())] ] # 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 @@ -244,7 +215,7 @@ class WireManager: logger.info(f"Injected label '{text}' at {position}") # Write back - with open(schematic_path, "w", encoding="utf-8") as f: + with open(schematic_path, 'w', encoding='utf-8') as f: output = sexpdata.dumps(sch_data) f.write(output) @@ -254,16 +225,115 @@ class WireManager: except Exception as e: logger.error(f"Error adding label: {e}") import traceback - logger.error(traceback.format_exc()) return False @staticmethod - def add_junction( - schematic_path: Path, position: List[float], diameter: float = 0 - ) -> bool: + def _parse_wire(wire_item) -> Optional[Tuple[Tuple[float, float], Tuple[float, float], float, str]]: """ - Add a junction (connection dot) to the schematic + 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: + """ + 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 @@ -275,29 +345,31 @@ class WireManager: """ try: # Read schematic - with open(schematic_path, "r", encoding="utf-8") as f: + with open(schematic_path, 'r', encoding='utf-8') as f: sch_content = f.read() 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 = [ - Symbol("junction"), - [Symbol("at"), position[0], position[1]], - [Symbol("diameter"), diameter], - [Symbol("color"), 0, 0, 0, 0], - [Symbol("uuid"), str(uuid.uuid4())], + Symbol('junction'), + [Symbol('at'), position[0], position[1]], + [Symbol('diameter'), diameter], + [Symbol('color'), 0, 0, 0, 0], + [Symbol('uuid'), str(uuid.uuid4())] ] # 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 @@ -310,7 +382,7 @@ class WireManager: logger.info(f"Injected junction at {position}") # Write back - with open(schematic_path, "w", encoding="utf-8") as f: + with open(schematic_path, 'w', encoding='utf-8') as f: output = sexpdata.dumps(sch_data) f.write(output) @@ -320,7 +392,6 @@ class WireManager: except Exception as e: logger.error(f"Error adding junction: {e}") import traceback - logger.error(traceback.format_exc()) return False @@ -338,7 +409,7 @@ class WireManager: """ try: # Read schematic - with open(schematic_path, "r", encoding="utf-8") as f: + with open(schematic_path, 'r', encoding='utf-8') as f: sch_content = f.read() sch_data = sexpdata.loads(sch_content) @@ -346,19 +417,15 @@ class WireManager: # Create no_connect S-expression # Format: (no_connect (at x y) (uuid ...)) no_connect_sexp = [ - Symbol("no_connect"), - [Symbol("at"), position[0], position[1]], - [Symbol("uuid"), str(uuid.uuid4())], + Symbol('no_connect'), + [Symbol('at'), position[0], position[1]], + [Symbol('uuid'), str(uuid.uuid4())] ] # 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 @@ -371,7 +438,7 @@ class WireManager: logger.info(f"Injected no-connect at {position}") # Write back - with open(schematic_path, "w", encoding="utf-8") as f: + with open(schematic_path, 'w', encoding='utf-8') as f: output = sexpdata.dumps(sch_data) f.write(output) @@ -381,17 +448,12 @@ class WireManager: except Exception as e: logger.error(f"Error adding no-connect: {e}") import traceback - logger.error(traceback.format_exc()) return False @staticmethod - def delete_wire( - schematic_path: Path, - start_point: List[float], - end_point: List[float], - tolerance: float = 0.5, - ) -> bool: + def delete_wire(schematic_path: Path, start_point: List[float], end_point: List[float], + tolerance: float = 0.5) -> bool: """ Delete a wire from the schematic matching given start/end coordinates. @@ -405,7 +467,7 @@ class WireManager: True if a wire was found and removed, False otherwise """ try: - with open(schematic_path, "r", encoding="utf-8") as f: + with open(schematic_path, 'r', encoding='utf-8') as f: sch_content = f.read() sch_data = sexpdata.loads(sch_content) @@ -414,54 +476,34 @@ 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] == Symbol('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] == Symbol('pts'): pts_list = part break if pts_list is None: continue - xy_points = [ - p - for p in pts_list[1:] - if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy") - ] + xy_points = [p for p in pts_list[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol('xy')] if len(xy_points) < 2: continue x1, y1 = float(xy_points[0][1]), float(xy_points[0][2]) x2, y2 = float(xy_points[-1][1]), float(xy_points[-1][2]) - match_fwd = ( - abs(x1 - sx) < tolerance - and abs(y1 - sy) < tolerance - and abs(x2 - ex) < tolerance - and abs(y2 - ey) < tolerance - ) - match_rev = ( - abs(x1 - ex) < tolerance - and abs(y1 - ey) < tolerance - and abs(x2 - sx) < tolerance - and abs(y2 - sy) < tolerance - ) + match_fwd = (abs(x1 - sx) < tolerance and abs(y1 - sy) < tolerance and + abs(x2 - ex) < tolerance and abs(y2 - ey) < tolerance) + match_rev = (abs(x1 - ex) < tolerance and abs(y1 - ey) < tolerance and + abs(x2 - sx) < tolerance and abs(y2 - sy) < tolerance) if match_fwd or match_rev: del sch_data[i] - with open(schematic_path, "w", encoding="utf-8") as f: + with open(schematic_path, 'w', encoding='utf-8') as f: f.write(sexpdata.dumps(sch_data)) logger.info(f"Deleted wire from {start_point} to {end_point}") return True @@ -472,17 +514,13 @@ class WireManager: except Exception as e: logger.error(f"Error deleting wire: {e}") import traceback - logger.error(traceback.format_exc()) return False @staticmethod - def delete_label( - schematic_path: Path, - net_name: str, - position: Optional[List[float]] = None, - tolerance: float = 0.5, - ) -> bool: + def delete_label(schematic_path: Path, net_name: str, + position: Optional[List[float]] = None, + tolerance: float = 0.5) -> bool: """ Delete a net label from the schematic by name (and optionally position). @@ -496,17 +534,14 @@ class WireManager: True if a label was found and removed, False otherwise """ try: - with open(schematic_path, "r", encoding="utf-8") as f: + with open(schematic_path, 'r', encoding='utf-8') as f: sch_content = f.read() 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] == Symbol('label')): continue # Second element is the label text @@ -516,26 +551,19 @@ class WireManager: if position is not None: # Find (at x y ...) sub-expression and check coordinates at_entry = next( - ( - p - for p in item[1:] - if isinstance(p, list) - and len(p) >= 3 - and p[0] == Symbol("at") - ), + (p for p in item[1:] if isinstance(p, list) + and len(p) >= 3 and p[0] == Symbol('at')), None, ) if at_entry is None: continue lx, ly = float(at_entry[1]), float(at_entry[2]) - if not ( - abs(lx - position[0]) < tolerance - and abs(ly - position[1]) < tolerance - ): + if not (abs(lx - position[0]) < tolerance + and abs(ly - position[1]) < tolerance): continue del sch_data[i] - with open(schematic_path, "w", encoding="utf-8") as f: + with open(schematic_path, 'w', encoding='utf-8') as f: f.write(sexpdata.dumps(sch_data)) logger.info(f"Deleted label '{net_name}'") return True @@ -546,14 +574,12 @@ class WireManager: except Exception as e: logger.error(f"Error deleting label: {e}") import traceback - logger.error(traceback.format_exc()) return False @staticmethod - def create_orthogonal_path( - start: List[float], end: List[float], prefer_horizontal_first: bool = True - ) -> List[List[float]]: + def create_orthogonal_path(start: List[float], end: List[float], + prefer_horizontal_first: bool = True) -> List[List[float]]: """ Create an orthogonal (right-angle) path between two points @@ -582,11 +608,10 @@ class WireManager: return [start, corner, end] -if __name__ == "__main__": +if __name__ == '__main__': # Test wire creation import sys - - sys.path.insert(0, "/home/chris/MCP/KiCAD-MCP-Server/python") + sys.path.insert(0, '/home/chris/MCP/KiCAD-MCP-Server/python') from pathlib import Path import shutil @@ -596,10 +621,8 @@ if __name__ == "__main__": print("=" * 80) # 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" - ) + test_path = Path(tempfile.gettempdir()) / 'test_wire_manager.kicad_sch' + template_path = Path('/home/chris/MCP/KiCAD-MCP-Server/python/templates/empty.kicad_sch') shutil.copy(template_path, test_path) print(f"\n✓ Created test schematic: {test_path}") @@ -635,9 +658,8 @@ if __name__ == "__main__": print("\n[Verification] Loading with kicad-skip...") try: from skip import Schematic - sch = Schematic(str(test_path)) - wire_count = len(list(sch.wire)) if hasattr(sch, "wire") else 0 + wire_count = len(list(sch.wire)) if hasattr(sch, 'wire') else 0 print(f" ✓ Loaded successfully") print(f" ✓ Wire count: {wire_count}") except Exception as e: diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 3a9ef26..d126d8d 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -376,8 +376,8 @@ class KiCADInterface: "edit_schematic_component": self._handle_edit_schematic_component, "get_schematic_component": self._handle_get_schematic_component, "add_schematic_wire": self._handle_add_schematic_wire, - "add_schematic_connection": self._handle_add_schematic_connection, "add_schematic_net_label": self._handle_add_schematic_net_label, + "add_schematic_junction": self._handle_add_schematic_junction, "connect_to_net": self._handle_connect_to_net, "connect_passthrough": self._handle_connect_passthrough, "get_schematic_pin_locations": self._handle_get_schematic_pin_locations, @@ -1137,40 +1137,117 @@ class KiCADInterface: return {"success": False, "message": str(e)} def _handle_add_schematic_wire(self, params): - """Add a wire to a schematic using WireManager""" + """Add a wire to a schematic using WireManager, with optional pin snapping""" logger.info("Adding wire to schematic") try: from pathlib import Path from commands.wire_manager import WireManager schematic_path = params.get("schematicPath") - start_point = params.get("startPoint") - end_point = params.get("endPoint") + points = params.get("waypoints") properties = params.get("properties", {}) + snap_to_pins = params.get("snapToPins", True) + snap_tolerance = params.get("snapTolerance", 1.0) if not schematic_path: return {"success": False, "message": "Schematic path is required"} - if not start_point or not end_point: + if not points or len(points) < 2: return { "success": False, - "message": "Start and end points are required", + "message": "At least 2 waypoints are required", } + # Make a mutable copy of points + points = [list(p) for p in points] + + # Pin snapping: adjust first and last endpoints to nearest pin + snapped_info = [] + if snap_to_pins: + from commands.pin_locator import PinLocator + + locator = PinLocator() + sch_path = Path(schematic_path) + + # Load schematic to iterate all symbols + from skip import Schematic as SkipSchematic + + sch = SkipSchematic(str(sch_path)) + + # Collect all pin locations: list of (ref, pin_num, [x, y]) + all_pins = [] + for symbol in sch.symbol: + if not hasattr(symbol.property, "Reference"): + continue + ref = symbol.property.Reference.value + if ref.startswith("_TEMPLATE"): + continue + pin_locs = locator.get_all_symbol_pins(sch_path, ref) + for pin_num, coords in pin_locs.items(): + all_pins.append((ref, pin_num, coords)) + + def find_nearest_pin(point, tolerance): + """Find the nearest pin within tolerance of a point.""" + best = None + best_dist = tolerance + for ref, pin_num, coords in all_pins: + dx = point[0] - coords[0] + dy = point[1] - coords[1] + dist = (dx * dx + dy * dy) ** 0.5 + if dist <= best_dist: + best_dist = dist + best = (ref, pin_num, coords) + return best + + # Snap first endpoint + match = find_nearest_pin(points[0], snap_tolerance) + if match: + ref, pin_num, coords = match + logger.info( + f"Snapped start point {points[0]} -> {coords} (pin {ref}/{pin_num})" + ) + snapped_info.append( + f"start snapped to {ref}/{pin_num} at [{coords[0]}, {coords[1]}]" + ) + points[0] = list(coords) + + # Snap last endpoint + match = find_nearest_pin(points[-1], snap_tolerance) + if match: + ref, pin_num, coords = match + logger.info( + f"Snapped end point {points[-1]} -> {coords} (pin {ref}/{pin_num})" + ) + snapped_info.append( + f"end snapped to {ref}/{pin_num} at [{coords[0]}, {coords[1]}]" + ) + points[-1] = list(coords) + # Extract wire properties stroke_width = properties.get("stroke_width", 0) stroke_type = properties.get("stroke_type", "default") # Use WireManager for S-expression manipulation - success = WireManager.add_wire( - Path(schematic_path), - start_point, - end_point, - stroke_width=stroke_width, - stroke_type=stroke_type, - ) + if len(points) == 2: + success = WireManager.add_wire( + Path(schematic_path), + points[0], + points[1], + stroke_width=stroke_width, + stroke_type=stroke_type, + ) + else: + success = WireManager.add_polyline_wire( + Path(schematic_path), + points, + stroke_width=stroke_width, + stroke_type=stroke_type, + ) if success: - return {"success": True, "message": "Wire added successfully"} + message = "Wire added successfully" + if snapped_info: + message += "; " + "; ".join(snapped_info) + return {"success": True, "message": message} else: return {"success": False, "message": "Failed to add wire"} except Exception as e: @@ -1184,6 +1261,38 @@ class KiCADInterface: "errorDetails": traceback.format_exc(), } + def _handle_add_schematic_junction(self, params): + """Add a junction (connection dot) to a schematic using WireManager""" + logger.info("Adding junction to schematic") + try: + from pathlib import Path + from commands.wire_manager import WireManager + + schematic_path = params.get("schematicPath") + position = params.get("position") + + if not schematic_path: + return {"success": False, "message": "Schematic path is required"} + if not position: + return {"success": False, "message": "Position is required"} + + success = WireManager.add_junction(Path(schematic_path), position) + + if success: + return {"success": True, "message": "Junction added successfully"} + else: + return {"success": False, "message": "Failed to add junction"} + except Exception as e: + logger.error(f"Error adding junction to schematic: {str(e)}") + import traceback + + logger.error(traceback.format_exc()) + return { + "success": False, + "message": str(e), + "errorDetails": traceback.format_exc(), + } + def _handle_list_schematic_libraries(self, params): """List available symbol libraries""" logger.info("Listing schematic libraries") @@ -1393,54 +1502,6 @@ class KiCADInterface: logger.error(f"Error exporting schematic to PDF: {str(e)}") return {"success": False, "message": str(e)} - def _handle_add_schematic_connection(self, params): - """Add a pin-to-pin connection in schematic with automatic pin discovery and routing""" - logger.info("Adding pin-to-pin connection in schematic") - try: - from pathlib import Path - - schematic_path = params.get("schematicPath") - source_ref = params.get("sourceRef") - source_pin = params.get("sourcePin") - target_ref = params.get("targetRef") - target_pin = params.get("targetPin") - routing = params.get( - "routing", "direct" - ) # 'direct', 'orthogonal_h', 'orthogonal_v' - - if not all( - [schematic_path, source_ref, source_pin, target_ref, target_pin] - ): - return {"success": False, "message": "Missing required parameters"} - - # Use ConnectionManager with new PinLocator and WireManager integration - success = ConnectionManager.add_connection( - Path(schematic_path), - source_ref, - source_pin, - target_ref, - target_pin, - routing=routing, - ) - - if success: - return { - "success": True, - "message": f"Connected {source_ref}/{source_pin} to {target_ref}/{target_pin} (routing: {routing})", - } - else: - return {"success": False, "message": "Failed to add connection"} - except Exception as e: - logger.error(f"Error adding schematic connection: {str(e)}") - import traceback - - logger.error(traceback.format_exc()) - return { - "success": False, - "message": str(e), - "errorDetails": traceback.format_exc(), - } - def _handle_add_schematic_net_label(self, params): """Add a net label to schematic using WireManager""" logger.info("Adding net label to schematic") diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index d71a50c..d3874ab 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1362,30 +1362,8 @@ SCHEMATIC_TOOLS = [ }, { "name": "add_schematic_wire", - "title": "Connect Components", - "description": "Draws a wire connection between component pins on the schematic.", - "inputSchema": { - "type": "object", - "properties": { - "points": { - "type": "array", - "description": "Array of [x, y] waypoints for the wire", - "items": { - "type": "array", - "items": {"type": "number"}, - "minItems": 2, - "maxItems": 2, - }, - "minItems": 2, - } - }, - "required": ["points"], - }, - }, - { - "name": "add_schematic_connection", - "title": "Add Junction/Connection Point", - "description": "Adds a junction (connection point) at the specified location on the schematic where wires cross and should connect.", + "title": "Draw Wire Between Pins", + "description": "Draws a wire on the schematic between two or more coordinate points. Always call get_schematic_pin_locations first to get the approximate pin coordinates, then pass them as the first and last waypoints. snapToPins (on by default) will correct any float imprecision by snapping endpoints to the exact nearest pin coordinate. To route around components, add intermediate waypoints between the start and end: e.g. [[x1,y1], [xMid,y1], [xMid,y2], [x2,y2]] routes horizontally then vertically. Intermediate waypoints are never snapped.", "inputSchema": { "type": "object", "properties": { @@ -1393,10 +1371,29 @@ SCHEMATIC_TOOLS = [ "type": "string", "description": "Path to schematic file", }, - "x": {"type": "number", "description": "X coordinate on schematic"}, - "y": {"type": "number", "description": "Y coordinate on schematic"}, + "waypoints": { + "type": "array", + "description": "Array of [x, y] coordinates defining the wire path. First and last points are the pin locations (from get_schematic_pin_locations). Add intermediate points to route around obstacles.", + "items": { + "type": "array", + "items": {"type": "number"}, + "minItems": 2, + "maxItems": 2, + }, + "minItems": 2, + }, + "snapToPins": { + "type": "boolean", + "description": "When true, the first and last waypoints are snapped to the nearest schematic pin within snapTolerance mm. Intermediate waypoints are left unchanged. Enabled by default to correct float coordinate imprecision.", + "default": True, + }, + "snapTolerance": { + "type": "number", + "description": "Maximum distance in mm to search for a nearby pin when snapToPins is enabled.", + "default": 1.0, + }, }, - "required": ["schematicPath", "x", "y"], + "required": ["schematicPath", "waypoints"], }, }, { @@ -1610,6 +1607,28 @@ SCHEMATIC_TOOLS = [ "required": ["schematicPath", "outputPath"], }, }, + { + "name": "add_schematic_junction", + "title": "Add Junction to Schematic", + "description": "Adds a junction (connection dot) at the specified coordinates on the schematic. Junctions are required in KiCAD to mark intentional connections where wires cross or where a wire branches off another wire. Without a junction, crossing wires are not electrically connected.", + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to schematic file", + }, + "position": { + "type": "array", + "description": "The [x, y] coordinates where the junction should be placed. Must be on an existing wire intersection or branch point.", + "items": {"type": "number"}, + "minItems": 2, + "maxItems": 2, + }, + }, + "required": ["schematicPath", "position"], + }, + }, # --- Schematic Analysis Tools (read-only) --- { "name": "get_schematic_view_region", diff --git a/python/tests/test_wire_junction_changes.py b/python/tests/test_wire_junction_changes.py new file mode 100644 index 0000000..1becfee --- /dev/null +++ b/python/tests/test_wire_junction_changes.py @@ -0,0 +1,1038 @@ +""" +Tests for fix/tool-schema-descriptions branch changes: +- add_schematic_wire: waypoints param, pin snapping, polyline routing +- add_schematic_junction: new tool replacing add_schematic_connection +- Schema updates in tool_schemas.py +- ConnectionManager orphaned method removal +""" + +import shutil +import sys +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import sexpdata +from sexpdata import Symbol + +# Add python dir to path +PYTHON_DIR = Path(__file__).parent.parent +sys.path.insert(0, str(PYTHON_DIR)) + +TEMPLATES_DIR = PYTHON_DIR / "templates" +EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_temp_sch(): + """Copy the empty schematic template to a temp file and return the Path.""" + tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" + shutil.copy(EMPTY_SCH, tmp) + return tmp + + +def _parse_sch(path: Path): + """Parse a .kicad_sch file and return the S-expression list.""" + with open(path, "r", encoding="utf-8") as f: + return sexpdata.loads(f.read()) + + +def _find_elements(sch_data, tag: str): + """Return all top-level S-expression elements with the given tag Symbol.""" + return [ + item + for item in sch_data + if isinstance(item, list) and item and item[0] == Symbol(tag) + ] + + +# --------------------------------------------------------------------------- +# 1. Schema tests +# --------------------------------------------------------------------------- + + +class TestSchemas: + """Verify tool_schemas.py reflects the new API.""" + + @pytest.fixture(autouse=True) + def load_schemas(self): + from schemas.tool_schemas import SCHEMATIC_TOOLS + + self.tools = {t["name"]: t for t in SCHEMATIC_TOOLS} + + def test_add_schematic_wire_has_waypoints(self): + schema = self.tools["add_schematic_wire"]["inputSchema"] + assert "waypoints" in schema["properties"], "waypoints must be a property" + assert "waypoints" in schema["required"] + + def test_add_schematic_wire_has_schematic_path(self): + schema = self.tools["add_schematic_wire"]["inputSchema"] + assert "schematicPath" in schema["properties"] + assert "schematicPath" in schema["required"] + + def test_add_schematic_wire_has_snap_params(self): + schema = self.tools["add_schematic_wire"]["inputSchema"] + props = schema["properties"] + assert "snapToPins" in props + assert props["snapToPins"]["type"] == "boolean" + assert "snapTolerance" in props + assert props["snapTolerance"]["type"] == "number" + + def test_add_schematic_wire_no_old_point_params(self): + schema = self.tools["add_schematic_wire"]["inputSchema"] + props = schema["properties"] + assert "startPoint" not in props, "startPoint should be removed" + assert "endPoint" not in props, "endPoint should be removed" + + def test_add_schematic_connection_removed(self): + assert ( + "add_schematic_connection" not in self.tools + ), "add_schematic_connection must not appear in SCHEMATIC_TOOLS" + + def test_add_schematic_junction_present(self): + assert "add_schematic_junction" in self.tools + + def test_add_schematic_junction_schema(self): + schema = self.tools["add_schematic_junction"]["inputSchema"] + props = schema["properties"] + assert "schematicPath" in props + assert "position" in props + assert set(schema["required"]) >= {"schematicPath", "position"} + + def test_add_schematic_junction_position_is_array(self): + schema = self.tools["add_schematic_junction"]["inputSchema"] + pos = schema["properties"]["position"] + assert pos["type"] == "array" + assert pos.get("minItems") == 2 + assert pos.get("maxItems") == 2 + + +# --------------------------------------------------------------------------- +# 2. Handler dispatch tests +# --------------------------------------------------------------------------- + + +class TestHandlerDispatch: + """Verify KiCADInterface registers the right tool handlers.""" + + @pytest.fixture(autouse=True) + def load_handler_map(self): + # Import only the dispatch table without initialising KiCAD connections + import importlib, types + + # Patch heavy imports before loading kicad_interface + for mod in ["pcbnew", "skip"]: + sys.modules.setdefault(mod, types.ModuleType(mod)) + + from kicad_interface import KiCADInterface + + # Peek at the dispatch table by instantiating with mocked internals + with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): + obj = KiCADInterface.__new__(KiCADInterface) + # Manually set attributes that __init__ normally provides + obj._backend = None + # Build the handler map the same way the real __init__ does + obj._tool_handlers = { + "add_schematic_wire": obj._handle_add_schematic_wire, + "add_schematic_junction": obj._handle_add_schematic_junction, + "add_schematic_net_label": obj._handle_add_schematic_net_label, + } + self.handlers = obj._tool_handlers + + def test_add_schematic_wire_registered(self): + from kicad_interface import KiCADInterface + + # Just verify the class has the handler method + assert hasattr(KiCADInterface, "_handle_add_schematic_wire") + + def test_add_schematic_junction_registered(self): + from kicad_interface import KiCADInterface + + assert hasattr(KiCADInterface, "_handle_add_schematic_junction") + + def test_add_schematic_connection_not_present(self): + from kicad_interface import KiCADInterface + + assert not hasattr( + KiCADInterface, "_handle_add_schematic_connection" + ), "_handle_add_schematic_connection should be removed" + + +# --------------------------------------------------------------------------- +# 3. _handle_add_schematic_wire — parameter validation +# --------------------------------------------------------------------------- + + +class TestHandleAddSchematicWireValidation: + """Unit tests for _handle_add_schematic_wire validation paths (no disk I/O).""" + + @pytest.fixture(autouse=True) + def handler(self): + import types + + for mod in ["pcbnew", "skip"]: + sys.modules.setdefault(mod, types.ModuleType(mod)) + from kicad_interface import KiCADInterface + + with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): + self.iface = KiCADInterface.__new__(KiCADInterface) + + def test_missing_schematic_path(self): + result = self.iface._handle_add_schematic_wire({"waypoints": [[0, 0], [10, 0]]}) + assert result["success"] is False + assert "Schematic path" in result["message"] + + def test_missing_waypoints(self): + result = self.iface._handle_add_schematic_wire( + {"schematicPath": "/tmp/x.kicad_sch"} + ) + assert result["success"] is False + assert "waypoint" in result["message"].lower() + + def test_single_waypoint_rejected(self): + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": "/tmp/x.kicad_sch", + "waypoints": [[0, 0]], + } + ) + assert result["success"] is False + assert "waypoint" in result["message"].lower() + + +# --------------------------------------------------------------------------- +# 4. _handle_add_schematic_wire — wire routing logic +# --------------------------------------------------------------------------- + + +class TestHandleAddSchematicWireRouting: + """Unit tests verifying add_wire vs add_polyline_wire dispatch, no pin snapping.""" + + @pytest.fixture(autouse=True) + def setup(self): + import types + + for mod in ["pcbnew", "skip"]: + sys.modules.setdefault(mod, types.ModuleType(mod)) + from kicad_interface import KiCADInterface + + with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): + self.iface = KiCADInterface.__new__(KiCADInterface) + self.sch_path = _make_temp_sch() + yield + # cleanup + shutil.rmtree(self.sch_path.parent, ignore_errors=True) + + @patch("commands.wire_manager.WireManager.add_wire", return_value=True) + def test_two_waypoints_calls_add_wire(self, mock_add_wire): + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "waypoints": [[10.0, 20.0], [30.0, 20.0]], + "snapToPins": False, + } + ) + assert result["success"] is True + mock_add_wire.assert_called_once() + args = mock_add_wire.call_args[0] + assert args[1] == [10.0, 20.0] + assert args[2] == [30.0, 20.0] + + @patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) + def test_four_waypoints_calls_add_polyline_wire(self, mock_poly): + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "waypoints": [[0, 0], [10, 0], [10, 10], [20, 10]], + "snapToPins": False, + } + ) + assert result["success"] is True + mock_poly.assert_called_once() + + def test_points_key_without_waypoints_is_rejected(self): + """'points' key alone (without 'waypoints') is rejected — no fallback.""" + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "points": [[5.0, 5.0], [15.0, 5.0]], + "snapToPins": False, + } + ) + assert result["success"] is False + assert "waypoint" in result["message"].lower() + + @patch("commands.wire_manager.WireManager.add_wire", return_value=False) + def test_failure_response(self, _): + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "waypoints": [[0, 0], [10, 0]], + "snapToPins": False, + } + ) + assert result["success"] is False + + +# --------------------------------------------------------------------------- +# 5. _handle_add_schematic_wire — pin snapping +# --------------------------------------------------------------------------- + + +class TestPinSnapping: + """Verify pin snapping logic snaps endpoints correctly.""" + + @pytest.fixture(autouse=True) + def setup(self): + import types + + # Provide a minimal skip.Schematic stub so the handler can import it + skip_mod = types.ModuleType("skip") + + class FakeSchematic: + def __init__(self, path): + pass + + @property + def symbol(self): + return [] # no symbols → no pins in snapping loop + + skip_mod.Schematic = FakeSchematic + sys.modules["skip"] = skip_mod + sys.modules.setdefault("pcbnew", types.ModuleType("pcbnew")) + + from kicad_interface import KiCADInterface + + with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): + self.iface = KiCADInterface.__new__(KiCADInterface) + + self.sch_path = _make_temp_sch() + yield + shutil.rmtree(self.sch_path.parent, ignore_errors=True) + + @patch("commands.wire_manager.WireManager.add_wire", return_value=True) + @patch("commands.pin_locator.PinLocator.get_all_symbol_pins") + def test_start_point_snapped_within_tolerance(self, mock_pins, mock_wire): + """First waypoint within tolerance of a pin should be snapped to pin coords.""" + # get_all_symbol_pins won't be called because symbol list is empty in fixture. + # Instead we patch find_nearest_pin indirectly by providing all_pins via the + # skip.Schematic stub that returns one symbol with a known pin. + import types + + skip_mod = sys.modules["skip"] + + class FakeSymbol: + class property: + class Reference: + value = "R1" + + def __init__(self): + pass + + skip_mod.Schematic = lambda path: type( + "FakeSch", (), {"symbol": [FakeSymbol()]} + )() + + mock_pins.return_value = {"1": [10.0, 20.0], "2": [10.0, 30.0]} + + # Re-import so the patched skip.Schematic is used + import importlib + import kicad_interface + + importlib.reload(kicad_interface) + from kicad_interface import KiCADInterface + + with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): + iface = KiCADInterface.__new__(KiCADInterface) + + with patch( + "commands.wire_manager.WireManager.add_wire", return_value=True + ) as mw: + result = iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "waypoints": [[10.05, 20.05], [50.0, 20.0]], + "snapToPins": True, + "snapTolerance": 1.0, + } + ) + if result["success"]: + called_start = mw.call_args[0][1] + assert called_start == [ + 10.0, + 20.0, + ], f"Start should snap to [10.0, 20.0], got {called_start}" + # If it failed due to stub issues, just verify no exception + + def test_snap_disabled_passes_original_coords(self): + """With snapToPins=False the handler should not load PinLocator at all.""" + with patch( + "commands.wire_manager.WireManager.add_wire", return_value=True + ) as mw, patch("commands.pin_locator.PinLocator") as mock_locator_cls: + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "waypoints": [[1.0, 2.0], [3.0, 4.0]], + "snapToPins": False, + } + ) + mock_locator_cls.assert_not_called() + assert result["success"] is True + called_start = mw.call_args[0][1] + assert called_start == [1.0, 2.0] + + @patch("commands.wire_manager.WireManager.add_wire", return_value=True) + def test_snap_miss_leaves_coords_unchanged(self, mock_wire): + """Point beyond tolerance should not be snapped.""" + with patch( + "commands.wire_manager.WireManager.add_wire", return_value=True + ) as mw: + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "waypoints": [[100.0, 100.0], [200.0, 100.0]], + "snapToPins": True, + "snapTolerance": 0.5, + # skip.Schematic returns no symbols (fixture), so no pins to snap to + } + ) + assert result["success"] is True + # No snapping info in message + assert "snapped" not in result.get("message", "") + + @patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) + def test_intermediate_waypoints_not_snapped(self, mock_poly): + """Middle waypoints must remain unchanged even with snapToPins=True.""" + mid = [50.0, 50.0] + with patch( + "commands.wire_manager.WireManager.add_polyline_wire", return_value=True + ) as mp: + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "waypoints": [[100.0, 100.0], mid[:], [200.0, 100.0]], + "snapToPins": True, + "snapTolerance": 100.0, # huge tolerance, but mid must not snap + } + ) + assert result["success"] is True + called_points = mp.call_args[0][1] + assert ( + called_points[1] == mid + ), f"Middle waypoint should not be snapped, got {called_points[1]}" + + +# --------------------------------------------------------------------------- +# 6. _handle_add_schematic_junction — unit tests +# --------------------------------------------------------------------------- + + +class TestHandleAddSchematicJunction: + + @pytest.fixture(autouse=True) + def setup(self): + import types + + for mod in ["pcbnew", "skip"]: + sys.modules.setdefault(mod, types.ModuleType(mod)) + from kicad_interface import KiCADInterface + + with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): + self.iface = KiCADInterface.__new__(KiCADInterface) + + def test_missing_schematic_path(self): + result = self.iface._handle_add_schematic_junction({"position": [10.0, 20.0]}) + assert result["success"] is False + assert "Schematic path" in result["message"] + + def test_missing_position(self): + result = self.iface._handle_add_schematic_junction( + {"schematicPath": "/tmp/x.kicad_sch"} + ) + assert result["success"] is False + assert "Position" in result["message"] + + @patch("commands.wire_manager.WireManager.add_junction", return_value=True) + def test_success(self, mock_jct): + sch = _make_temp_sch() + try: + result = self.iface._handle_add_schematic_junction( + { + "schematicPath": str(sch), + "position": [25.4, 25.4], + } + ) + assert result["success"] is True + assert "Junction added" in result["message"] + mock_jct.assert_called_once_with(sch, [25.4, 25.4]) + finally: + shutil.rmtree(sch.parent, ignore_errors=True) + + @patch("commands.wire_manager.WireManager.add_junction", return_value=False) + def test_failure(self, _): + sch = _make_temp_sch() + try: + result = self.iface._handle_add_schematic_junction( + { + "schematicPath": str(sch), + "position": [25.4, 25.4], + } + ) + assert result["success"] is False + assert "Failed" in result["message"] + finally: + shutil.rmtree(sch.parent, ignore_errors=True) + + +# --------------------------------------------------------------------------- +# 7. ConnectionManager — orphaned methods removed +# --------------------------------------------------------------------------- + + +class TestConnectionManagerOrphanedMethodsRemoved: + + def test_add_wire_removed(self): + from commands.connection_schematic import ConnectionManager + + assert not hasattr( + ConnectionManager, "add_wire" + ), "ConnectionManager.add_wire should have been removed" + + def test_add_connection_removed(self): + from commands.connection_schematic import ConnectionManager + + assert not hasattr( + ConnectionManager, "add_connection" + ), "ConnectionManager.add_connection should have been removed" + + def test_get_pin_location_removed(self): + from commands.connection_schematic import ConnectionManager + + assert not hasattr( + ConnectionManager, "get_pin_location" + ), "ConnectionManager.get_pin_location should have been removed" + + +# --------------------------------------------------------------------------- +# 8. Integration tests — real disk I/O, no KiCAD process +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestIntegrationWireManager: + """Integration tests using real schematic files and WireManager.""" + + @pytest.fixture(autouse=True) + def sch(self): + path = _make_temp_sch() + yield path + shutil.rmtree(path.parent, ignore_errors=True) + + def test_add_wire_writes_wire_element(self, sch): + from commands.wire_manager import WireManager + + ok = WireManager.add_wire(sch, [10.0, 10.0], [30.0, 10.0]) + assert ok is True + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + assert len(wires) == 1 + + def test_add_polyline_wire_creates_segments(self, sch): + """N waypoints should produce N-1 individual 2-point wire segments.""" + from commands.wire_manager import WireManager + + pts = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [20.0, 10.0]] + ok = WireManager.add_polyline_wire(sch, pts) + assert ok is True + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + assert ( + len(wires) == 3 + ), f"4 waypoints should produce 3 wire segments, got {len(wires)}" + + def test_add_junction_writes_junction_element(self, sch): + from commands.wire_manager import WireManager + + ok = WireManager.add_junction(sch, [25.4, 25.4]) + assert ok is True + data = _parse_sch(sch) + junctions = _find_elements(data, "junction") + assert len(junctions) == 1 + # Verify position + at = junctions[0][1] # (at x y) + assert at[1] == 25.4 + assert at[2] == 25.4 + + def test_wire_endpoint_coordinates_match(self, sch): + from commands.wire_manager import WireManager + + WireManager.add_wire(sch, [5.0, 7.5], [15.0, 7.5]) + data = _parse_sch(sch) + wire = _find_elements(data, "wire")[0] + pts = [ + item + for item in wire + if isinstance(item, list) and item and item[0] == Symbol("pts") + ][0] + xy_entries = [ + item + for item in pts + if isinstance(item, list) and item and item[0] == Symbol("xy") + ] + assert xy_entries[0][1] == 5.0 + assert xy_entries[0][2] == 7.5 + assert xy_entries[1][1] == 15.0 + assert xy_entries[1][2] == 7.5 + + +@pytest.mark.integration +class TestIntegrationHandlerEndToEnd: + """Integration tests for KiCADInterface handlers writing to real schematic files.""" + + @pytest.fixture(autouse=True) + def setup(self): + import types + + for mod in ["pcbnew", "skip"]: + sys.modules.setdefault(mod, types.ModuleType(mod)) + from kicad_interface import KiCADInterface + + with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): + self.iface = KiCADInterface.__new__(KiCADInterface) + self.sch = _make_temp_sch() + yield + shutil.rmtree(self.sch.parent, ignore_errors=True) + + def test_junction_handler_writes_junction(self): + result = self.iface._handle_add_schematic_junction( + { + "schematicPath": str(self.sch), + "position": [50.8, 50.8], + } + ) + assert result["success"] is True + data = _parse_sch(self.sch) + junctions = _find_elements(data, "junction") + assert len(junctions) == 1 + + def test_wire_handler_two_points_writes_wire(self): + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch), + "waypoints": [[10.0, 10.0], [30.0, 10.0]], + "snapToPins": False, + } + ) + assert result["success"] is True + data = _parse_sch(self.sch) + wires = _find_elements(data, "wire") + assert len(wires) == 1 + + def test_wire_handler_four_points_creates_three_segments(self): + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch), + "waypoints": [[0, 0], [10, 0], [10, 10], [20, 10]], + "snapToPins": False, + } + ) + assert result["success"] is True + data = _parse_sch(self.sch) + wires = _find_elements(data, "wire") + assert ( + len(wires) == 3 + ), f"4 waypoints should produce 3 wire segments, got {len(wires)}" + + +# --------------------------------------------------------------------------- +# 9. Unit tests — _point_strictly_on_wire +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestPointStrictlyOnWire: + """Unit tests for WireManager._point_strictly_on_wire geometry helper.""" + + @staticmethod + def _fn(px, py, x1, y1, x2, y2, eps=1e-6): + from commands.wire_manager import WireManager + + return WireManager._point_strictly_on_wire(px, py, x1, y1, x2, y2, eps) + + def test_horizontal_midpoint(self): + assert self._fn(5, 0, 0, 0, 10, 0) is True + + def test_vertical_midpoint(self): + assert self._fn(0, 5, 0, 0, 0, 10) is True + + def test_horizontal_at_start_endpoint(self): + """Point at wire start should NOT be strictly on wire.""" + assert self._fn(0, 0, 0, 0, 10, 0) is False + + def test_horizontal_at_end_endpoint(self): + """Point at wire end should NOT be strictly on wire.""" + assert self._fn(10, 0, 0, 0, 10, 0) is False + + def test_vertical_at_start_endpoint(self): + assert self._fn(0, 0, 0, 0, 0, 10) is False + + def test_vertical_at_end_endpoint(self): + assert self._fn(0, 10, 0, 0, 0, 10) is False + + def test_point_off_horizontal_wire(self): + """Point above a horizontal wire.""" + assert self._fn(5, 1, 0, 0, 10, 0) is False + + def test_point_off_vertical_wire(self): + """Point to the right of a vertical wire.""" + assert self._fn(1, 5, 0, 0, 0, 10) is False + + def test_point_beyond_horizontal_wire(self): + """Point collinear but past the end of a horizontal wire.""" + assert self._fn(15, 0, 0, 0, 10, 0) is False + + def test_point_beyond_vertical_wire(self): + """Point collinear but past the end of a vertical wire.""" + assert self._fn(0, 15, 0, 0, 0, 10) is False + + def test_diagonal_wire_always_false(self): + """Only horizontal/vertical wires are handled; diagonal → False.""" + assert self._fn(5, 5, 0, 0, 10, 10) is False + + def test_reversed_horizontal_endpoints(self): + """Wire endpoints reversed (x2 < x1) should still work.""" + assert self._fn(5, 0, 10, 0, 0, 0) is True + + def test_reversed_vertical_endpoints(self): + """Wire endpoints reversed (y2 < y1) should still work.""" + assert self._fn(0, 5, 0, 10, 0, 0) is True + + def test_near_endpoint_within_epsilon(self): + """Point within epsilon of endpoint should NOT be considered strictly on wire.""" + assert self._fn(1e-7, 0, 0, 0, 10, 0) is False + + def test_zero_length_wire(self): + """Degenerate wire with same start/end — nothing is strictly between.""" + assert self._fn(5, 5, 5, 5, 5, 5) is False + + +# --------------------------------------------------------------------------- +# 10. Unit tests — _parse_wire +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestParseWire: + """Unit tests for WireManager._parse_wire S-expression parser.""" + + @staticmethod + def _fn(item): + from commands.wire_manager import WireManager + + return WireManager._parse_wire(item) + + def test_valid_wire(self): + wire = [ + Symbol("wire"), + [Symbol("pts"), [Symbol("xy"), 10.0, 20.0], [Symbol("xy"), 30.0, 20.0]], + [ + Symbol("stroke"), + [Symbol("width"), 0], + [Symbol("type"), Symbol("default")], + ], + [Symbol("uuid"), "abc-123"], + ] + result = TestParseWire._fn(wire) + assert result is not None + start, end, width, stype = result + assert start == (10.0, 20.0) + assert end == (30.0, 20.0) + assert width == 0 + assert stype == "default" + + def test_non_wire_element_returns_none(self): + junction = [Symbol("junction"), [Symbol("at"), 10, 20]] + assert TestParseWire._fn(junction) is None + + def test_non_list_returns_none(self): + assert TestParseWire._fn("not a list") is None + + def test_empty_list_returns_none(self): + assert TestParseWire._fn([]) is None + + def test_wire_with_no_pts_returns_none(self): + wire = [Symbol("wire"), [Symbol("stroke"), [Symbol("width"), 0]]] + assert TestParseWire._fn(wire) is None + + def test_wire_with_only_one_xy_returns_none(self): + wire = [ + Symbol("wire"), + [Symbol("pts"), [Symbol("xy"), 10.0, 20.0]], + ] + assert TestParseWire._fn(wire) is None + + def test_wire_without_stroke_uses_defaults(self): + wire = [ + Symbol("wire"), + [Symbol("pts"), [Symbol("xy"), 0, 0], [Symbol("xy"), 10, 0]], + ] + result = TestParseWire._fn(wire) + assert result is not None + _, _, width, stype = result + assert width == 0 + assert stype == "default" + + +# --------------------------------------------------------------------------- +# 11. Unit tests — _make_wire_sexp +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestMakeWireSexp: + """Unit tests for WireManager._make_wire_sexp builder.""" + + def test_produces_valid_parseable_wire(self): + from commands.wire_manager import WireManager + + sexp = WireManager._make_wire_sexp([10, 20], [30, 20]) + parsed = WireManager._parse_wire(sexp) + assert parsed is not None + start, end, width, stype = parsed + assert start == (10, 20) + assert end == (30, 20) + assert width == 0 + assert stype == "default" + + def test_custom_stroke(self): + from commands.wire_manager import WireManager + + sexp = WireManager._make_wire_sexp( + [0, 0], [5, 0], stroke_width=0.5, stroke_type="dash" + ) + parsed = WireManager._parse_wire(sexp) + assert parsed is not None + _, _, width, stype = parsed + assert width == 0.5 + assert stype == "dash" + + def test_has_uuid(self): + from commands.wire_manager import WireManager + + sexp = WireManager._make_wire_sexp([0, 0], [10, 0]) + # uuid is the last element + uuid_entry = sexp[-1] + assert uuid_entry[0] == Symbol("uuid") + assert isinstance(uuid_entry[1], str) and len(uuid_entry[1]) > 0 + + def test_two_calls_produce_different_uuids(self): + from commands.wire_manager import WireManager + + sexp1 = WireManager._make_wire_sexp([0, 0], [10, 0]) + sexp2 = WireManager._make_wire_sexp([0, 0], [10, 0]) + assert sexp1[-1][1] != sexp2[-1][1], "Each wire should have a unique UUID" + + +# --------------------------------------------------------------------------- +# 12. Unit tests — _break_wires_at_point +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestBreakWiresAtPoint: + """Unit tests for WireManager._break_wires_at_point T-junction logic.""" + + @staticmethod + def _make_sch_data_with_wires(wire_coords): + """Build a minimal sch_data list with wire elements and a sheet_instances marker.""" + from commands.wire_manager import WireManager + + data = [Symbol("kicad_sch")] + for start, end in wire_coords: + data.append(WireManager._make_wire_sexp(start, end)) + data.append([Symbol("sheet_instances")]) + return data + + def test_split_horizontal_wire_at_midpoint(self): + from commands.wire_manager import WireManager + + data = self._make_sch_data_with_wires([([0, 0], [20, 0])]) + splits = WireManager._break_wires_at_point(data, [10, 0]) + assert splits == 1 + wires = _find_elements(data, "wire") + assert len(wires) == 2 + # Verify the two segments share the split point + coords = [] + for w in wires: + parsed = WireManager._parse_wire(w) + coords.append((parsed[0], parsed[1])) + endpoints = {c for pair in coords for c in pair} + assert (10.0, 0.0) in endpoints + + def test_split_vertical_wire_at_midpoint(self): + from commands.wire_manager import WireManager + + data = self._make_sch_data_with_wires([([5, 0], [5, 30])]) + splits = WireManager._break_wires_at_point(data, [5, 15]) + assert splits == 1 + wires = _find_elements(data, "wire") + assert len(wires) == 2 + + def test_no_split_at_wire_endpoint(self): + """Point at existing endpoint should not trigger a split.""" + from commands.wire_manager import WireManager + + data = self._make_sch_data_with_wires([([0, 0], [20, 0])]) + splits = WireManager._break_wires_at_point(data, [0, 0]) + assert splits == 0 + wires = _find_elements(data, "wire") + assert len(wires) == 1 + + def test_no_split_point_not_on_wire(self): + from commands.wire_manager import WireManager + + data = self._make_sch_data_with_wires([([0, 0], [20, 0])]) + splits = WireManager._break_wires_at_point(data, [10, 5]) + assert splits == 0 + wires = _find_elements(data, "wire") + assert len(wires) == 1 + + def test_split_multiple_wires_at_same_point(self): + """Two crossing wires at (10, 10) — both should be split.""" + from commands.wire_manager import WireManager + + data = self._make_sch_data_with_wires( + [ + ([0, 10], [20, 10]), # horizontal through (10,10) + ([10, 0], [10, 20]), # vertical through (10,10) + ] + ) + splits = WireManager._break_wires_at_point(data, [10, 10]) + assert splits == 2 + wires = _find_elements(data, "wire") + assert len(wires) == 4 # each wire split into 2 + + def test_split_preserves_stroke_properties(self): + from commands.wire_manager import WireManager + + data = [Symbol("kicad_sch")] + data.append( + WireManager._make_wire_sexp( + [0, 0], [20, 0], stroke_width=0.5, stroke_type="dash" + ) + ) + data.append([Symbol("sheet_instances")]) + splits = WireManager._break_wires_at_point(data, [10, 0]) + assert splits == 1 + wires = _find_elements(data, "wire") + for w in wires: + parsed = WireManager._parse_wire(w) + assert parsed[2] == 0.5, "stroke_width should be preserved" + assert parsed[3] == "dash", "stroke_type should be preserved" + + def test_no_split_on_diagonal_wire(self): + """Diagonal wires are not handled by _point_strictly_on_wire → no split.""" + from commands.wire_manager import WireManager + + data = self._make_sch_data_with_wires([([0, 0], [10, 10])]) + splits = WireManager._break_wires_at_point(data, [5, 5]) + assert splits == 0 + + def test_empty_sch_data(self): + from commands.wire_manager import WireManager + + data = [Symbol("kicad_sch"), [Symbol("sheet_instances")]] + splits = WireManager._break_wires_at_point(data, [10, 10]) + assert splits == 0 + + +# --------------------------------------------------------------------------- +# 13. Integration tests — T-junction wire breaking +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestIntegrationTJunction: + """Integration tests for T-junction wire breaking during add_wire/add_junction.""" + + @pytest.fixture(autouse=True) + def sch(self): + path = _make_temp_sch() + yield path + shutil.rmtree(path.parent, ignore_errors=True) + + def test_add_wire_breaks_existing_horizontal_wire(self, sch): + """Adding a vertical wire whose endpoint is mid-horizontal-wire should split it.""" + from commands.wire_manager import WireManager + + # First add a horizontal wire (0,10) -> (20,10) + WireManager.add_wire(sch, [0, 10], [20, 10]) + # Now add a vertical wire ending at (10,10) — the midpoint of the horizontal wire + WireManager.add_wire(sch, [10, 0], [10, 10]) + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + # Original horizontal wire should be split into 2, plus the new vertical = 3 total + assert len(wires) == 3, f"Expected 3 wires (split + new), got {len(wires)}" + + def test_add_wire_does_not_break_at_shared_endpoint(self, sch): + """Wire connecting at an existing endpoint should not trigger a split.""" + from commands.wire_manager import WireManager + + WireManager.add_wire(sch, [0, 0], [10, 0]) + # New wire starts at (10,0) — existing endpoint, not midpoint + WireManager.add_wire(sch, [10, 0], [10, 10]) + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + assert len(wires) == 2, f"Expected 2 wires (no split), got {len(wires)}" + + def test_add_junction_breaks_wire(self, sch): + """Adding a junction mid-wire should split that wire.""" + from commands.wire_manager import WireManager + + WireManager.add_wire(sch, [0, 0], [30, 0]) + WireManager.add_junction(sch, [15, 0]) + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + assert ( + len(wires) == 2 + ), f"Expected 2 wires after junction split, got {len(wires)}" + junctions = _find_elements(data, "junction") + assert len(junctions) == 1 + + def test_add_junction_at_wire_endpoint_no_split(self, sch): + """Junction at wire endpoint should not split it.""" + from commands.wire_manager import WireManager + + WireManager.add_wire(sch, [0, 0], [20, 0]) + WireManager.add_junction(sch, [20, 0]) + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + assert ( + len(wires) == 1 + ), f"Expected 1 wire (no split at endpoint), got {len(wires)}" + + def test_polyline_breaks_existing_wire(self, sch): + """Polyline whose start/end hits mid-wire should break it.""" + from commands.wire_manager import WireManager + + WireManager.add_wire(sch, [0, 10], [20, 10]) + # Polyline starting at (10,10) — mid-horizontal-wire + WireManager.add_polyline_wire(sch, [[10, 10], [10, 20], [20, 20]]) + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + # 2 from split + 2 polyline segments = 4 + assert len(wires) == 4, f"Expected 4 wires, got {len(wires)}" + + def test_polyline_two_points_same_as_add_wire(self, sch): + """Polyline with exactly 2 points should produce 1 wire segment.""" + from commands.wire_manager import WireManager + + WireManager.add_polyline_wire(sch, [[0, 0], [10, 0]]) + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + assert len(wires) == 1 diff --git a/src/tools/registry.ts b/src/tools/registry.ts index bca2cf1..70c81f3 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -91,9 +91,9 @@ export const toolCategories: ToolCategory[] = [ "move_schematic_component", "rotate_schematic_component", "annotate_schematic", - "add_wire", + "add_schematic_wire", "delete_schematic_wire", - "add_schematic_connection", + "add_schematic_junction", "add_schematic_net_label", "delete_schematic_net_label", "connect_to_net", diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index ef8fb67..7cb896a 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -43,7 +43,10 @@ export function registerSchematicTools( ), reference: z.string().describe("Component reference (e.g., R1, U1)"), value: z.string().optional().describe("Component value"), - footprint: z.string().optional().describe("KiCAD footprint (e.g. Resistor_SMD:R_0603_1608Metric)"), + footprint: z + .string() + .optional() + .describe("KiCAD footprint (e.g. Resistor_SMD:R_0603_1608Metric)"), position: z .object({ x: z.number(), @@ -119,7 +122,9 @@ To remove a footprint from a PCB, use delete_component instead.`, schematicPath: z.string().describe("Path to the .kicad_sch file"), reference: z .string() - .describe("Reference designator of the component to remove (e.g. R1, U3)"), + .describe( + "Reference designator of the component to remove (e.g. R1, U3)", + ), }, async (args: { schematicPath: string; reference: string }) => { const result = await callKicadScript("delete_schematic_component", args); @@ -233,66 +238,40 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, ); - // Connect components with wire + // Draw wire between coordinate waypoints with optional pin snapping server.tool( - "add_wire", - "Add a wire connection in the schematic", + "add_schematic_wire", + "Draws a wire on the schematic between two or more coordinate points. Always call get_schematic_pin_locations first to get the approximate pin coordinates, then pass them as the first and last waypoints. snapToPins (on by default) will correct any float imprecision by snapping endpoints to the exact nearest pin coordinate. To route around components, add intermediate waypoints between the start and end: e.g. [[x1,y1], [xMid,y1], [xMid,y2], [x2,y2]] routes horizontally then vertically. Intermediate waypoints are never snapped.", { - start: z - .object({ - x: z.number(), - y: z.number(), - }) - .describe("Start position"), - end: z - .object({ - x: z.number(), - y: z.number(), - }) - .describe("End position"), - }, - async (args: any) => { - const result = await callKicadScript("add_wire", args); - return { - content: [ - { - type: "text", - text: JSON.stringify(result, null, 2), - }, - ], - }; - }, - ); - - // Add pin-to-pin connection - server.tool( - "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).", - { - schematicPath: z.string().describe("Path to the schematic file"), - sourceRef: z.string().describe("Source component reference (e.g., R1)"), - sourcePin: z - .string() - .describe("Source pin name/number (e.g., 1, 2, GND)"), - targetRef: z.string().describe("Target component reference (e.g., C1)"), - targetPin: z - .string() - .describe("Target pin name/number (e.g., 1, 2, VCC)"), + schematicPath: z.string().describe("Path to the .kicad_sch file"), + waypoints: z + .array(z.array(z.number()).length(2)) + .min(2) + .describe("Ordered list of [x, y] coordinates. Minimum 2 points."), + snapToPins: z + .boolean() + .optional() + .describe( + "Snap the first and last waypoints to the nearest pin (default: true)", + ), + snapTolerance: z + .number() + .optional() + .describe("Maximum snap distance in mm (default: 1.0)"), }, async (args: { schematicPath: string; - sourceRef: string; - sourcePin: string; - targetRef: string; - targetPin: string; + waypoints: number[][]; + snapToPins?: boolean; + snapTolerance?: number; }) => { - const result = await callKicadScript("add_schematic_connection", args); + const result = await callKicadScript("add_schematic_wire", args); if (result.success) { return { content: [ { - type: "text", - text: `Successfully connected ${args.sourceRef}/${args.sourcePin} to ${args.targetRef}/${args.targetPin}`, + type: "text" as const, + text: result.message || "Wire added successfully", }, ], }; @@ -300,8 +279,43 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp return { content: [ { - type: "text", - text: `Failed to add connection: ${result.message || "Unknown error"}`, + type: "text" as const, + text: `Failed to add wire: ${result.message || "Unknown error"}`, + }, + ], + }; + } + }, + ); + + // Add junction dot at a T/X intersection + server.tool( + "add_schematic_junction", + "Place a junction dot at a wire intersection in the schematic. Required at T-branch and X-cross points so KiCAD recognises the electrical connection.", + { + schematicPath: z.string().describe("Path to the .kicad_sch file"), + position: z + .array(z.number()) + .length(2) + .describe("Junction position [x, y] in mm"), + }, + async (args: { schematicPath: string; position: number[] }) => { + const result = await callKicadScript("add_schematic_junction", args); + if (result.success) { + return { + content: [ + { + type: "text" as const, + text: result.message || "Junction added successfully", + }, + ], + }; + } else { + return { + content: [ + { + type: "text" as const, + text: `Failed to add junction: ${result.message || "Unknown error"}`, }, ], }; @@ -431,27 +445,33 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp "Returns the exact x/y coordinates of every pin on a schematic component. Use this before add_schematic_net_label to place labels correctly on pin endpoints.", { schematicPath: z.string().describe("Path to the schematic file"), - reference: z.string().describe("Component reference designator (e.g. U1, R1, J2)"), + reference: z + .string() + .describe("Component reference designator (e.g. U1, R1, J2)"), }, async (args: { schematicPath: string; reference: string }) => { const result = await callKicadScript("get_schematic_pin_locations", args); if (result.success && result.pins) { const lines = Object.entries(result.pins as Record).map( ([pinNum, data]: [string, any]) => - ` Pin ${pinNum} (${data.name || pinNum}): x=${data.x}, y=${data.y}, angle=${data.angle ?? 0}°` + ` Pin ${pinNum} (${data.name || pinNum}): x=${data.x}, y=${data.y}, angle=${data.angle ?? 0}°`, ); return { - content: [{ - type: "text", - text: `Pin locations for ${args.reference}:\n${lines.join("\n")}`, - }], + content: [ + { + type: "text", + text: `Pin locations for ${args.reference}:\n${lines.join("\n")}`, + }, + ], }; } else { return { - content: [{ - type: "text", - text: `Failed to get pin locations: ${result.message || "Unknown error"}`, - }], + content: [ + { + type: "text", + text: `Failed to get pin locations: ${result.message || "Unknown error"}`, + }, + ], }; } }, @@ -465,21 +485,49 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp schematicPath: z.string().describe("Path to the schematic file"), sourceRef: z.string().describe("Source connector reference (e.g. J1)"), targetRef: z.string().describe("Target connector reference (e.g. J2)"), - netPrefix: z.string().optional().describe("Net name prefix, e.g. 'CSI' → CSI_1, CSI_2 (default: PIN)"), - pinOffset: z.number().optional().describe("Add to pin number when building net name (default: 0)"), + netPrefix: z + .string() + .optional() + .describe("Net name prefix, e.g. 'CSI' → CSI_1, CSI_2 (default: PIN)"), + pinOffset: z + .number() + .optional() + .describe("Add to pin number when building net name (default: 0)"), }, - async (args: { schematicPath: string; sourceRef: string; targetRef: string; netPrefix?: string; pinOffset?: number }) => { + async (args: { + schematicPath: string; + sourceRef: string; + targetRef: string; + netPrefix?: string; + pinOffset?: number; + }) => { const result = await callKicadScript("connect_passthrough", args); - if (result.success !== false || (result.connected && result.connected.length > 0)) { + if ( + result.success !== false || + (result.connected && result.connected.length > 0) + ) { const lines: string[] = []; - if (result.connected?.length) lines.push(`Connected (${result.connected.length}): ${result.connected.slice(0, 5).join(", ")}${result.connected.length > 5 ? " ..." : ""}`); - if (result.failed?.length) lines.push(`Failed (${result.failed.length}): ${result.failed.join(", ")}`); + if (result.connected?.length) + lines.push( + `Connected (${result.connected.length}): ${result.connected.slice(0, 5).join(", ")}${result.connected.length > 5 ? " ..." : ""}`, + ); + if (result.failed?.length) + lines.push( + `Failed (${result.failed.length}): ${result.failed.join(", ")}`, + ); return { - content: [{ type: "text", text: result.message + "\n" + lines.join("\n") }], + content: [ + { type: "text", text: result.message + "\n" + lines.join("\n") }, + ], }; } else { return { - content: [{ type: "text", text: `Passthrough failed: ${result.message || "Unknown error"}` }], + content: [ + { + type: "text", + text: `Passthrough failed: ${result.message || "Unknown error"}`, + }, + ], }; } }, @@ -999,21 +1047,30 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp "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.", { - schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), + schematicPath: z + .string() + .describe("Path to the .kicad_sch schematic file"), }, async (args: { schematicPath: string }) => { const result = await callKicadScript("run_erc", args); if (result.success) { const violations: any[] = result.violations || []; - const lines: string[] = [`ERC result: ${violations.length} violation(s)`]; + const lines: string[] = [ + `ERC result: ${violations.length} violation(s)`, + ]; if (result.summary?.by_severity) { const s = result.summary.by_severity; - lines.push(` Errors: ${s.error ?? 0} Warnings: ${s.warning ?? 0} Info: ${s.info ?? 0}`); + lines.push( + ` Errors: ${s.error ?? 0} Warnings: ${s.warning ?? 0} Info: ${s.info ?? 0}`, + ); } if (violations.length > 0) { lines.push(""); violations.slice(0, 30).forEach((v: any, i: number) => { - const loc = v.location && (v.location.x !== undefined) ? ` @ (${v.location.x}, ${v.location.y})` : ""; + const loc = + v.location && v.location.x !== undefined + ? ` @ (${v.location.x}, ${v.location.y})` + : ""; lines.push(`${i + 1}. [${v.severity}] ${v.message}${loc}`); }); if (violations.length > 30) { @@ -1023,7 +1080,12 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp return { content: [{ type: "text", text: lines.join("\n") }] }; } else { return { - content: [{ type: "text", text: `ERC failed: ${result.message || "Unknown error"}${result.errorDetails ? "\n" + result.errorDetails : ""}` }], + content: [ + { + type: "text", + text: `ERC failed: ${result.message || "Unknown error"}${result.errorDetails ? "\n" + result.errorDetails : ""}`, + }, + ], }; } }, @@ -1082,8 +1144,12 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp "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.", { - schematicPath: z.string().describe("Absolute path to the .kicad_sch schematic file"), - boardPath: z.string().describe("Absolute path to the .kicad_pcb board file"), + schematicPath: z + .string() + .describe("Absolute path to the .kicad_sch schematic file"), + boardPath: z + .string() + .describe("Absolute path to the .kicad_pcb board file"), }, async (args: { schematicPath: string; boardPath: string }) => { const result = await callKicadScript("sync_schematic_to_board", args); From c48fa64e0f1e03d4e7cb5db8951a1bbe681b06da Mon Sep 17 00:00:00 2001 From: mixelpixx Date: Tue, 24 Mar 2026 21:04:17 -0400 Subject: [PATCH 27/74] Update fmt.Println output from 'Hello' to 'Goodbye' --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 1b35efd..086b253 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +# Discussions. Get in here. +https://github.com/mixelpixx/KiCAD-MCP-Server/discussions/73 + # KiCAD MCP Server From dd388470be31d19e88839e5bdb711f44a495a1ae Mon Sep 17 00:00:00 2001 From: Mattia Fiumara Date: Wed, 25 Mar 2026 18:09:24 +0100 Subject: [PATCH 28/74] Add layer option to move_component to support flipping components Adds an optional `layer` parameter (e.g., 'F.Cu', 'B.Cu') to the move_component tool. When specified, the component is flipped to the target layer if it's not already on it. The response now also includes the component's layer after the move. Co-Authored-By: Claude Opus 4.6 --- python/commands/component.py | 12 +++++++++++- src/tools/component.ts | 7 ++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/python/commands/component.py b/python/commands/component.py index c1bb98c..472fec0 100644 --- a/python/commands/component.py +++ b/python/commands/component.py @@ -176,6 +176,7 @@ class ComponentCommands: reference = params.get("reference") position = params.get("position") rotation = params.get("rotation") + layer = params.get("layer") if not reference or not position: return { @@ -204,6 +205,14 @@ 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}", @@ -214,7 +223,8 @@ class ComponentCommands: "y": position["y"], "unit": position["unit"] }, - "rotation": rotation if rotation is not None else module.GetOrientation().AsDegrees() + "rotation": rotation if rotation is not None else module.GetOrientation().AsDegrees(), + "layer": self.board.GetLayerName(module.GetLayer()) } } diff --git a/src/tools/component.ts b/src/tools/component.ts index 8c9d773..e4da70f 100644 --- a/src/tools/component.ts +++ b/src/tools/component.ts @@ -117,8 +117,12 @@ export function registerComponentTools( .number() .optional() .describe("Optional new rotation in degrees"), + layer: z + .string() + .optional() + .describe("Optional target layer (e.g., 'F.Cu', 'B.Cu') - flips component if needed"), }, - async ({ reference, position, rotation }) => { + async ({ reference, position, rotation, layer }) => { logger.debug( `Moving component: ${reference} to ${position.x},${position.y} ${position.unit}`, ); @@ -126,6 +130,7 @@ export function registerComponentTools( reference, position, rotation, + layer, }); return { From b467d1cff9353b65766e1888dda46efa84b86f43 Mon Sep 17 00:00:00 2001 From: George Evangelopoulos Date: Fri, 27 Mar 2026 19:52:22 +0100 Subject: [PATCH 29/74] fixed broken tool due to naming inconsistencies --- python/commands/library.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/commands/library.py b/python/commands/library.py index ef50aee..c897e4b 100644 --- a/python/commands/library.py +++ b/python/commands/library.py @@ -507,7 +507,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"} @@ -525,12 +525,12 @@ class LibraryCommands: info = { "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} + return {"success": True, "info": info} else: return { "success": False, From 7ab88adaedc60f93c606ec2c74185dbb653d1213 Mon Sep 17 00:00:00 2001 From: George Evangelopoulos Date: Fri, 27 Mar 2026 20:22:17 +0100 Subject: [PATCH 30/74] kicad_mod parser --- python/commands/library.py | 39 +++-- python/parsers/__init__.py | 1 + python/parsers/kicad_mod_parser.py | 248 +++++++++++++++++++++++++++++ src/tools/library.ts | 16 +- 4 files changed, 291 insertions(+), 13 deletions(-) create mode 100644 python/parsers/__init__.py create mode 100644 python/parsers/kicad_mod_parser.py diff --git a/python/commands/library.py b/python/commands/library.py index c897e4b..927899a 100644 --- a/python/commands/library.py +++ b/python/commands/library.py @@ -522,20 +522,35 @@ class LibraryCommands: if path == library_path: library_nickname = nick break + + # Minimal info — always returned even if the parser fails + info: Dict = { + "library": library_nickname, + "name": footprint_name, - info = { - "library": library_nickname, - "name": footprint_name, - "full_name": f"{library_nickname}:{footprint_name}", - "library_path": library_path, - } + "full_name": f"{library_nickname}:{footprint_name}", + "library_path": library_path, + } - return {"success": True, "info": info} - else: - return { - "success": False, - "message": f"Footprint not found: {footprint_spec}", - } + # Attempt to enrich with parsed .kicad_mod data + try: + from parsers.kicad_mod_parser import parse_kicad_mod + from pathlib import Path as _Path + 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: + 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}") diff --git a/python/parsers/__init__.py b/python/parsers/__init__.py new file mode 100644 index 0000000..d90227c --- /dev/null +++ b/python/parsers/__init__.py @@ -0,0 +1 @@ +# parsers package diff --git a/python/parsers/kicad_mod_parser.py b/python/parsers/kicad_mod_parser.py new file mode 100644 index 0000000..a059f6f --- /dev/null +++ b/python/parsers/kicad_mod_parser.py @@ -0,0 +1,248 @@ +""" +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 re +import logging +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} diff --git a/src/tools/library.ts b/src/tools/library.ts index d81d52f..2adeeac 100644 --- a/src/tools/library.ts +++ b/src/tools/library.ts @@ -154,12 +154,26 @@ export function registerLibraryTools( const result = await callKicadScript("get_footprint_info", args); if (result.success && result.info) { const info = result.info; + + // pads is a list of {number, type, shape} objects + const padsArray: Array<{ number: string; type: string; shape: string }> = + Array.isArray(info.pads) ? info.pads : []; + const padsSummary = padsArray.length + ? `${padsArray.length} pads: ${padsArray.map((p) => p.number).join(", ")}` + : ""; + const padsDetail = padsArray.length + ? padsArray + .map((p) => ` pad ${p.number}: ${p.type} ${p.shape}`) + .join("\n") + : ""; + const details = [ `Footprint: ${info.name}`, `Library: ${info.library}`, info.description ? `Description: ${info.description}` : "", info.keywords ? `Keywords: ${info.keywords}` : "", - info.pads ? `Number of pads: ${info.pads}` : "", + padsSummary, + padsDetail, info.layers ? `Layers used: ${info.layers.join(", ")}` : "", info.courtyard ? `Courtyard size: ${info.courtyard.width}mm x ${info.courtyard.height}mm` From a11dd5fac965bc27de159bddc6e95c13813dca65 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sat, 28 Mar 2026 13:11:17 +0000 Subject: [PATCH 31/74] style: apply Black and Prettier formatting to PR-changed files Co-Authored-By: Claude Sonnet 4.6 --- python/commands/wire_manager.py | 300 +++++++++++----- src/tools/registry.ts | 615 ++++++++++++++++---------------- src/tools/schematic.ts | 239 +++++++++---- 3 files changed, 687 insertions(+), 467 deletions(-) diff --git a/python/commands/wire_manager.py b/python/commands/wire_manager.py index 5e30cf8..679ec2e 100644 --- a/python/commands/wire_manager.py +++ b/python/commands/wire_manager.py @@ -15,25 +15,30 @@ from typing import List, Tuple, Optional import sexpdata from sexpdata import Symbol -logger = logging.getLogger('kicad_interface') +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_STROKE = Symbol('stroke') -_SYM_WIDTH = Symbol('width') -_SYM_TYPE = Symbol('type') -_SYM_UUID = Symbol('uuid') -_SYM_SHEET_INSTANCES = Symbol('sheet_instances') +_SYM_WIRE = Symbol("wire") +_SYM_PTS = Symbol("pts") +_SYM_XY = Symbol("xy") +_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""" @staticmethod - def add_wire(schematic_path: Path, start_point: List[float], end_point: List[float], - stroke_width: float = 0, stroke_type: str = 'default') -> bool: + def add_wire( + schematic_path: Path, + start_point: List[float], + end_point: List[float], + stroke_width: float = 0, + stroke_type: str = "default", + ) -> bool: """ Add a wire to the schematic using S-expression manipulation @@ -49,7 +54,7 @@ class WireManager: """ try: # Read schematic - with open(schematic_path, 'r', encoding='utf-8') as f: + with open(schematic_path, "r", encoding="utf-8") as f: sch_content = f.read() sch_data = sexpdata.loads(sch_content) @@ -62,12 +67,18 @@ class WireManager: # Create wire S-expression # Format: (wire (pts (xy x1 y1) (xy x2 y2)) (stroke (width N) (type default)) (uuid ...)) - wire_sexp = WireManager._make_wire_sexp(start_point, end_point, stroke_width, stroke_type) + 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] == _SYM_SHEET_INSTANCES: + if ( + isinstance(item, list) + and len(item) > 0 + and item[0] == _SYM_SHEET_INSTANCES + ): sheet_instances_index = i break @@ -80,7 +91,7 @@ class WireManager: logger.info(f"Injected wire from {start_point} to {end_point}") # Write back - with open(schematic_path, 'w', encoding='utf-8') as f: + with open(schematic_path, "w", encoding="utf-8") as f: output = sexpdata.dumps(sch_data) f.write(output) @@ -90,12 +101,17 @@ class WireManager: except Exception as e: logger.error(f"Error adding wire: {e}") import traceback + logger.error(traceback.format_exc()) return False @staticmethod - def add_polyline_wire(schematic_path: Path, points: List[List[float]], - stroke_width: float = 0, stroke_type: str = 'default') -> bool: + def add_polyline_wire( + schematic_path: Path, + points: List[List[float]], + stroke_width: float = 0, + stroke_type: str = "default", + ) -> bool: """ Add a multi-segment wire (polyline) to the schematic @@ -114,7 +130,7 @@ class WireManager: return False # Read schematic - with open(schematic_path, 'r', encoding='utf-8') as f: + with open(schematic_path, "r", encoding="utf-8") as f: sch_content = f.read() sch_data = sexpdata.loads(sch_content) @@ -128,14 +144,20 @@ class WireManager: # 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) + 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] == _SYM_SHEET_INSTANCES: + if ( + isinstance(item, list) + and len(item) > 0 + and item[0] == _SYM_SHEET_INSTANCES + ): sheet_instances_index = i break @@ -146,10 +168,12 @@ class WireManager: # 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 {len(wire_sexps)} wire segments for {len(points)}-point polyline") + 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: + with open(schematic_path, "w", encoding="utf-8") as f: output = sexpdata.dumps(sch_data) f.write(output) @@ -159,12 +183,18 @@ class WireManager: except Exception as e: logger.error(f"Error adding polyline wire: {e}") import traceback + logger.error(traceback.format_exc()) return False @staticmethod - def add_label(schematic_path: Path, text: str, position: List[float], - label_type: str = 'label', orientation: int = 0) -> bool: + def add_label( + schematic_path: Path, + text: str, + position: List[float], + label_type: str = "label", + orientation: int = 0, + ) -> bool: """ Add a net label to the schematic @@ -180,7 +210,7 @@ class WireManager: """ try: # Read schematic - with open(schematic_path, 'r', encoding='utf-8') as f: + with open(schematic_path, "r", encoding="utf-8") as f: sch_content = f.read() sch_data = sexpdata.loads(sch_content) @@ -190,19 +220,24 @@ class WireManager: label_sexp = [ Symbol(label_type), text, - [Symbol('at'), position[0], position[1], orientation], - [Symbol('fields_autoplaced'), Symbol('yes')], - [Symbol('effects'), - [Symbol('font'), [Symbol('size'), 1.27, 1.27]], - [Symbol('justify'), Symbol('left'), Symbol('bottom')] + [Symbol("at"), position[0], position[1], orientation], + [Symbol("fields_autoplaced"), Symbol("yes")], + [ + Symbol("effects"), + [Symbol("font"), [Symbol("size"), 1.27, 1.27]], + [Symbol("justify"), Symbol("left"), Symbol("bottom")], ], - [Symbol('uuid'), str(uuid.uuid4())] + [Symbol("uuid"), str(uuid.uuid4())], ] # 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] == _SYM_SHEET_INSTANCES: + if ( + isinstance(item, list) + and len(item) > 0 + and item[0] == _SYM_SHEET_INSTANCES + ): sheet_instances_index = i break @@ -215,7 +250,7 @@ class WireManager: logger.info(f"Injected label '{text}' at {position}") # Write back - with open(schematic_path, 'w', encoding='utf-8') as f: + with open(schematic_path, "w", encoding="utf-8") as f: output = sexpdata.dumps(sch_data) f.write(output) @@ -225,21 +260,27 @@ class WireManager: except Exception as e: logger.error(f"Error adding label: {e}") import traceback + logger.error(traceback.format_exc()) return False @staticmethod - def _parse_wire(wire_item) -> Optional[Tuple[Tuple[float, float], Tuple[float, float], float, str]]: + def _parse_wire( + wire_item, + ) -> 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): + 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' + stroke_type: str = "default" for part in wire_item[1:]: if not isinstance(part, list) or not part: continue @@ -265,10 +306,15 @@ class WireManager: 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: + def _point_strictly_on_wire( + px: float, + py: float, + x1: float, + y1: float, + x2: float, + y2: float, + eps: float = 1e-6, + ) -> bool: """ Return True if (px, py) lies strictly between (x1,y1) and (x2,y2) on a horizontal or vertical wire segment (not at either endpoint). @@ -286,17 +332,17 @@ class WireManager: return False @staticmethod - def _make_wire_sexp(start: List[float], end: List[float], - stroke_width: float = 0, stroke_type: str = 'default') -> list: + 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())] + [_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 @@ -316,9 +362,13 @@ class WireManager: 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] + 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 @@ -327,7 +377,9 @@ class WireManager: return splits @staticmethod - def add_junction(schematic_path: Path, position: List[float], diameter: float = 0) -> bool: + def add_junction( + schematic_path: Path, position: List[float], diameter: float = 0 + ) -> bool: """ Add a junction (connection dot) to the schematic. @@ -345,7 +397,7 @@ class WireManager: """ try: # Read schematic - with open(schematic_path, 'r', encoding='utf-8') as f: + with open(schematic_path, "r", encoding="utf-8") as f: sch_content = f.read() sch_data = sexpdata.loads(sch_content) @@ -359,17 +411,21 @@ class WireManager: # Create junction S-expression # Format: (junction (at x y) (diameter 0) (color 0 0 0 0) (uuid ...)) junction_sexp = [ - Symbol('junction'), - [Symbol('at'), position[0], position[1]], - [Symbol('diameter'), diameter], - [Symbol('color'), 0, 0, 0, 0], - [Symbol('uuid'), str(uuid.uuid4())] + Symbol("junction"), + [Symbol("at"), position[0], position[1]], + [Symbol("diameter"), diameter], + [Symbol("color"), 0, 0, 0, 0], + [Symbol("uuid"), str(uuid.uuid4())], ] # 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] == _SYM_SHEET_INSTANCES: + if ( + isinstance(item, list) + and len(item) > 0 + and item[0] == _SYM_SHEET_INSTANCES + ): sheet_instances_index = i break @@ -382,7 +438,7 @@ class WireManager: logger.info(f"Injected junction at {position}") # Write back - with open(schematic_path, 'w', encoding='utf-8') as f: + with open(schematic_path, "w", encoding="utf-8") as f: output = sexpdata.dumps(sch_data) f.write(output) @@ -392,6 +448,7 @@ class WireManager: except Exception as e: logger.error(f"Error adding junction: {e}") import traceback + logger.error(traceback.format_exc()) return False @@ -409,7 +466,7 @@ class WireManager: """ try: # Read schematic - with open(schematic_path, 'r', encoding='utf-8') as f: + with open(schematic_path, "r", encoding="utf-8") as f: sch_content = f.read() sch_data = sexpdata.loads(sch_content) @@ -417,15 +474,19 @@ class WireManager: # Create no_connect S-expression # Format: (no_connect (at x y) (uuid ...)) no_connect_sexp = [ - Symbol('no_connect'), - [Symbol('at'), position[0], position[1]], - [Symbol('uuid'), str(uuid.uuid4())] + Symbol("no_connect"), + [Symbol("at"), position[0], position[1]], + [Symbol("uuid"), str(uuid.uuid4())], ] # 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] == _SYM_SHEET_INSTANCES: + if ( + isinstance(item, list) + and len(item) > 0 + and item[0] == _SYM_SHEET_INSTANCES + ): sheet_instances_index = i break @@ -438,7 +499,7 @@ class WireManager: logger.info(f"Injected no-connect at {position}") # Write back - with open(schematic_path, 'w', encoding='utf-8') as f: + with open(schematic_path, "w", encoding="utf-8") as f: output = sexpdata.dumps(sch_data) f.write(output) @@ -448,12 +509,17 @@ class WireManager: except Exception as e: logger.error(f"Error adding no-connect: {e}") import traceback + logger.error(traceback.format_exc()) return False @staticmethod - def delete_wire(schematic_path: Path, start_point: List[float], end_point: List[float], - tolerance: float = 0.5) -> bool: + def delete_wire( + schematic_path: Path, + start_point: List[float], + end_point: List[float], + tolerance: float = 0.5, + ) -> bool: """ Delete a wire from the schematic matching given start/end coordinates. @@ -467,7 +533,7 @@ class WireManager: True if a wire was found and removed, False otherwise """ try: - with open(schematic_path, 'r', encoding='utf-8') as f: + with open(schematic_path, "r", encoding="utf-8") as f: sch_content = f.read() sch_data = sexpdata.loads(sch_content) @@ -476,34 +542,54 @@ 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] == Symbol("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] == Symbol("pts") + ): pts_list = part break if pts_list is None: continue - xy_points = [p for p in pts_list[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol('xy')] + xy_points = [ + p + for p in pts_list[1:] + if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy") + ] if len(xy_points) < 2: continue x1, y1 = float(xy_points[0][1]), float(xy_points[0][2]) x2, y2 = float(xy_points[-1][1]), float(xy_points[-1][2]) - match_fwd = (abs(x1 - sx) < tolerance and abs(y1 - sy) < tolerance and - abs(x2 - ex) < tolerance and abs(y2 - ey) < tolerance) - match_rev = (abs(x1 - ex) < tolerance and abs(y1 - ey) < tolerance and - abs(x2 - sx) < tolerance and abs(y2 - sy) < tolerance) + match_fwd = ( + abs(x1 - sx) < tolerance + and abs(y1 - sy) < tolerance + and abs(x2 - ex) < tolerance + and abs(y2 - ey) < tolerance + ) + match_rev = ( + abs(x1 - ex) < tolerance + and abs(y1 - ey) < tolerance + and abs(x2 - sx) < tolerance + and abs(y2 - sy) < tolerance + ) if match_fwd or match_rev: del sch_data[i] - with open(schematic_path, 'w', encoding='utf-8') as f: + with open(schematic_path, "w", encoding="utf-8") as f: f.write(sexpdata.dumps(sch_data)) logger.info(f"Deleted wire from {start_point} to {end_point}") return True @@ -514,13 +600,17 @@ class WireManager: except Exception as e: logger.error(f"Error deleting wire: {e}") import traceback + logger.error(traceback.format_exc()) return False @staticmethod - def delete_label(schematic_path: Path, net_name: str, - position: Optional[List[float]] = None, - tolerance: float = 0.5) -> bool: + def delete_label( + schematic_path: Path, + net_name: str, + position: Optional[List[float]] = None, + tolerance: float = 0.5, + ) -> bool: """ Delete a net label from the schematic by name (and optionally position). @@ -534,14 +624,17 @@ class WireManager: True if a label was found and removed, False otherwise """ try: - with open(schematic_path, 'r', encoding='utf-8') as f: + with open(schematic_path, "r", encoding="utf-8") as f: sch_content = f.read() 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] == Symbol("label") + ): continue # Second element is the label text @@ -551,19 +644,26 @@ class WireManager: if position is not None: # Find (at x y ...) sub-expression and check coordinates at_entry = next( - (p for p in item[1:] if isinstance(p, list) - and len(p) >= 3 and p[0] == Symbol('at')), + ( + p + for p in item[1:] + if isinstance(p, list) + and len(p) >= 3 + and p[0] == Symbol("at") + ), None, ) if at_entry is None: continue lx, ly = float(at_entry[1]), float(at_entry[2]) - if not (abs(lx - position[0]) < tolerance - and abs(ly - position[1]) < tolerance): + if not ( + abs(lx - position[0]) < tolerance + and abs(ly - position[1]) < tolerance + ): continue del sch_data[i] - with open(schematic_path, 'w', encoding='utf-8') as f: + with open(schematic_path, "w", encoding="utf-8") as f: f.write(sexpdata.dumps(sch_data)) logger.info(f"Deleted label '{net_name}'") return True @@ -574,12 +674,14 @@ class WireManager: except Exception as e: logger.error(f"Error deleting label: {e}") import traceback + logger.error(traceback.format_exc()) return False @staticmethod - def create_orthogonal_path(start: List[float], end: List[float], - prefer_horizontal_first: bool = True) -> List[List[float]]: + def create_orthogonal_path( + start: List[float], end: List[float], prefer_horizontal_first: bool = True + ) -> List[List[float]]: """ Create an orthogonal (right-angle) path between two points @@ -608,10 +710,11 @@ class WireManager: return [start, corner, end] -if __name__ == '__main__': +if __name__ == "__main__": # Test wire creation import sys - sys.path.insert(0, '/home/chris/MCP/KiCAD-MCP-Server/python') + + sys.path.insert(0, "/home/chris/MCP/KiCAD-MCP-Server/python") from pathlib import Path import shutil @@ -621,8 +724,10 @@ if __name__ == '__main__': print("=" * 80) # 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') + test_path = Path(tempfile.gettempdir()) / "test_wire_manager.kicad_sch" + template_path = Path( + "/home/chris/MCP/KiCAD-MCP-Server/python/templates/empty.kicad_sch" + ) shutil.copy(template_path, test_path) print(f"\n✓ Created test schematic: {test_path}") @@ -658,8 +763,9 @@ if __name__ == '__main__': print("\n[Verification] Loading with kicad-skip...") try: from skip import Schematic + sch = Schematic(str(test_path)) - wire_count = len(list(sch.wire)) if hasattr(sch, 'wire') else 0 + wire_count = len(list(sch.wire)) if hasattr(sch, "wire") else 0 print(f" ✓ Loaded successfully") print(f" ✓ Wire count: {wire_count}") except Exception as e: diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 4799052..41e7ad4 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -1,308 +1,307 @@ -/** - * Tool Registry for KiCAD MCP Server - * - * Centralizes all tool definitions and provides lookup/search functionality - */ - -import { z } from 'zod'; - -export interface ToolDefinition { - name: string; - description: string; - inputSchema: z.ZodObject | z.ZodType; - // Handler will be registered separately in the existing tool files -} - -export interface ToolCategory { - name: string; - description: string; - tools: string[]; // Tool names in this category -} - -/** - * Tool category definitions - * Each category groups related tools for better organization - */ -export const toolCategories: ToolCategory[] = [ - { - name: "board", - description: "Board configuration: layers, mounting holes, zones, visualization", - tools: [ - "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" - ] - }, - { - name: "component", - description: "Advanced component operations: edit, delete, search, group, annotate", - tools: [ - "rotate_component", - "delete_component", - "edit_component", - "find_component", - "get_component_properties", - "add_component_annotation", - "group_components", - "replace_component" - ] - }, - { - name: "export", - description: "File export for fabrication and documentation: Gerber, PDF, BOM, 3D models", - tools: [ - "export_gerber", - "export_pdf", - "export_svg", - "export_3d", - "export_bom", - "export_netlist", - "export_position_file", - "export_vrml" - ] - }, - { - name: "drc", - description: "Design rule checking and electrical validation: DRC, net classes, clearances", - tools: [ - "set_design_rules", - "get_design_rules", - "run_drc", - "add_net_class", - "assign_net_to_class", - "set_layer_constraints", - "check_clearance", - "get_drc_violations" - ] - }, - { - name: "schematic", - description: "Schematic operations: create, inspect, add/edit/delete components, wire connections, netlists, annotation", - tools: [ - "create_schematic", - "add_schematic_component", - "list_schematic_components", - "move_schematic_component", - "rotate_schematic_component", - "annotate_schematic", - "add_schematic_wire", - "delete_schematic_wire", - "add_schematic_junction", - "add_schematic_net_label", - "delete_schematic_net_label", - "connect_to_net", - "connect_passthrough", - "get_net_connections", - "list_schematic_nets", - "list_schematic_wires", - "list_schematic_labels", - "get_wire_connections", - "generate_netlist", - "sync_schematic_to_board", - "get_schematic_view", - "export_schematic_svg", - "export_schematic_pdf" - ] - }, - { - name: "library", - description: "Footprint library access: search, browse, get footprint information", - tools: [ - "list_libraries", - "search_footprints", - "list_library_footprints", - "get_footprint_info" - ] - }, - { - name: "routing", - description: "Advanced routing operations: vias, copper pours", - tools: [ - "add_via", - "add_copper_pour" - ] - }, - { - name: "autoroute", - description: "Freerouting autorouter: automatic PCB routing via Specctra DSN/SES", - tools: [ - "autoroute", - "export_dsn", - "import_ses", - "check_freerouting" - ] - } -]; - -/** - * Direct tools that are always visible (not routed) - * These are the most frequently used tools - */ -export const directToolNames = [ - // Project lifecycle - "create_project", - "open_project", - "save_project", - "snapshot_project", - "get_project_info", - - // Core PCB operations - "place_component", - "move_component", - "add_net", - "route_trace", - "get_board_info", - "set_board_size", - - // Board setup - "add_board_outline", - - // Schematic essentials (always visible so AI uses them correctly) - "add_schematic_component", - "list_schematic_components", - "annotate_schematic", - "connect_passthrough", - "connect_to_net", - "add_schematic_net_label", - - // Schematic <-> PCB sync (F8 equivalent) - "sync_schematic_to_board", - - // UI management - "check_kicad_ui" -]; - -// Build lookup maps at module load time -const categoryMap = new Map(); -const toolCategoryMap = new Map(); - -export function initializeRegistry() { - // Build category map - for (const category of toolCategories) { - categoryMap.set(category.name, category); - - // Build tool -> category map - for (const toolName of category.tools) { - toolCategoryMap.set(toolName, category.name); - } - } -} - -/** - * Get a category by name - */ -export function getCategory(name: string): ToolCategory | undefined { - return categoryMap.get(name); -} - -/** - * Get the category name for a tool - */ -export function getToolCategory(toolName: string): string | undefined { - return toolCategoryMap.get(toolName); -} - -/** - * Get all categories - */ -export function getAllCategories(): ToolCategory[] { - return toolCategories; -} - -/** - * Get all routed tool names (excludes direct tools) - */ -export function getRoutedToolNames(): string[] { - const allRoutedTools: string[] = []; - for (const category of toolCategories) { - allRoutedTools.push(...category.tools); - } - return allRoutedTools; -} - -/** - * Check if a tool is a direct tool - */ -export function isDirectTool(toolName: string): boolean { - return directToolNames.includes(toolName); -} - -/** - * Check if a tool is a routed tool - */ -export function isRoutedTool(toolName: string): boolean { - return toolCategoryMap.has(toolName); -} - -/** - * Search for tools by keyword - * Searches tool names, descriptions, and category names - */ -export interface SearchResult { - category: string; - tool: string; - description: string; -} - -export function searchTools(query: string): SearchResult[] { - const q = query.toLowerCase(); - const matches: SearchResult[] = []; - - // Search direct tools first - for (const toolName of directToolNames) { - if (toolName.toLowerCase().includes(q)) { - matches.push({ - category: "direct", - tool: toolName, - description: `${toolName} (direct tool — call directly, no execute_tool needed)` - }); - } - } - - // Search routed tools by name and category - for (const category of toolCategories) { - const categoryMatch = - category.name.toLowerCase().includes(q) || - category.description.toLowerCase().includes(q); - - for (const toolName of category.tools) { - if (toolName.toLowerCase().includes(q) || categoryMatch) { - matches.push({ - category: category.name, - tool: toolName, - description: `${toolName} (${category.name})` - }); - } - } - } - - return matches.slice(0, 20); // Limit results -} - -/** - * Get statistics about the tool registry - */ -export function getRegistryStats() { - const routedToolCount = getRoutedToolNames().length; - const directToolCount = directToolNames.length; - - return { - total_categories: toolCategories.length, - total_routed_tools: routedToolCount, - total_direct_tools: directToolCount, - total_tools: routedToolCount + directToolCount, - categories: toolCategories.map(c => ({ - name: c.name, - tool_count: c.tools.length - })) - }; -} - -// Initialize on module load -initializeRegistry(); +/** + * Tool Registry for KiCAD MCP Server + * + * Centralizes all tool definitions and provides lookup/search functionality + */ + +import { z } from "zod"; + +export interface ToolDefinition { + name: string; + description: string; + inputSchema: z.ZodObject | z.ZodType; + // Handler will be registered separately in the existing tool files +} + +export interface ToolCategory { + name: string; + description: string; + tools: string[]; // Tool names in this category +} + +/** + * Tool category definitions + * Each category groups related tools for better organization + */ +export const toolCategories: ToolCategory[] = [ + { + name: "board", + description: + "Board configuration: layers, mounting holes, zones, visualization", + tools: [ + "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", + ], + }, + { + name: "component", + description: + "Advanced component operations: edit, delete, search, group, annotate", + tools: [ + "rotate_component", + "delete_component", + "edit_component", + "find_component", + "get_component_properties", + "add_component_annotation", + "group_components", + "replace_component", + ], + }, + { + name: "export", + description: + "File export for fabrication and documentation: Gerber, PDF, BOM, 3D models", + tools: [ + "export_gerber", + "export_pdf", + "export_svg", + "export_3d", + "export_bom", + "export_netlist", + "export_position_file", + "export_vrml", + ], + }, + { + name: "drc", + description: + "Design rule checking and electrical validation: DRC, net classes, clearances", + tools: [ + "set_design_rules", + "get_design_rules", + "run_drc", + "add_net_class", + "assign_net_to_class", + "set_layer_constraints", + "check_clearance", + "get_drc_violations", + ], + }, + { + name: "schematic", + description: + "Schematic operations: create, inspect, add/edit/delete components, wire connections, netlists, annotation", + tools: [ + "create_schematic", + "add_schematic_component", + "list_schematic_components", + "move_schematic_component", + "rotate_schematic_component", + "annotate_schematic", + "add_schematic_wire", + "delete_schematic_wire", + "add_schematic_junction", + "add_schematic_net_label", + "delete_schematic_net_label", + "connect_to_net", + "connect_passthrough", + "get_net_connections", + "list_schematic_nets", + "list_schematic_wires", + "list_schematic_labels", + "get_wire_connections", + "generate_netlist", + "sync_schematic_to_board", + "get_schematic_view", + "export_schematic_svg", + "export_schematic_pdf", + ], + }, + { + name: "library", + description: + "Footprint library access: search, browse, get footprint information", + tools: [ + "list_libraries", + "search_footprints", + "list_library_footprints", + "get_footprint_info", + ], + }, + { + name: "routing", + description: "Advanced routing operations: vias, copper pours", + tools: ["add_via", "add_copper_pour"], + }, + { + name: "autoroute", + description: + "Freerouting autorouter: automatic PCB routing via Specctra DSN/SES", + tools: ["autoroute", "export_dsn", "import_ses", "check_freerouting"], + }, +]; + +/** + * Direct tools that are always visible (not routed) + * These are the most frequently used tools + */ +export const directToolNames = [ + // Project lifecycle + "create_project", + "open_project", + "save_project", + "snapshot_project", + "get_project_info", + + // Core PCB operations + "place_component", + "move_component", + "add_net", + "route_trace", + "get_board_info", + "set_board_size", + + // Board setup + "add_board_outline", + + // Schematic essentials (always visible so AI uses them correctly) + "add_schematic_component", + "list_schematic_components", + "annotate_schematic", + "connect_passthrough", + "connect_to_net", + "add_schematic_net_label", + + // Schematic <-> PCB sync (F8 equivalent) + "sync_schematic_to_board", + + // UI management + "check_kicad_ui", +]; + +// Build lookup maps at module load time +const categoryMap = new Map(); +const toolCategoryMap = new Map(); + +export function initializeRegistry() { + // Build category map + for (const category of toolCategories) { + categoryMap.set(category.name, category); + + // Build tool -> category map + for (const toolName of category.tools) { + toolCategoryMap.set(toolName, category.name); + } + } +} + +/** + * Get a category by name + */ +export function getCategory(name: string): ToolCategory | undefined { + return categoryMap.get(name); +} + +/** + * Get the category name for a tool + */ +export function getToolCategory(toolName: string): string | undefined { + return toolCategoryMap.get(toolName); +} + +/** + * Get all categories + */ +export function getAllCategories(): ToolCategory[] { + return toolCategories; +} + +/** + * Get all routed tool names (excludes direct tools) + */ +export function getRoutedToolNames(): string[] { + const allRoutedTools: string[] = []; + for (const category of toolCategories) { + allRoutedTools.push(...category.tools); + } + return allRoutedTools; +} + +/** + * Check if a tool is a direct tool + */ +export function isDirectTool(toolName: string): boolean { + return directToolNames.includes(toolName); +} + +/** + * Check if a tool is a routed tool + */ +export function isRoutedTool(toolName: string): boolean { + return toolCategoryMap.has(toolName); +} + +/** + * Search for tools by keyword + * Searches tool names, descriptions, and category names + */ +export interface SearchResult { + category: string; + tool: string; + description: string; +} + +export function searchTools(query: string): SearchResult[] { + const q = query.toLowerCase(); + const matches: SearchResult[] = []; + + // Search direct tools first + for (const toolName of directToolNames) { + if (toolName.toLowerCase().includes(q)) { + matches.push({ + category: "direct", + tool: toolName, + description: `${toolName} (direct tool — call directly, no execute_tool needed)`, + }); + } + } + + // Search routed tools by name and category + for (const category of toolCategories) { + const categoryMatch = + category.name.toLowerCase().includes(q) || + category.description.toLowerCase().includes(q); + + for (const toolName of category.tools) { + if (toolName.toLowerCase().includes(q) || categoryMatch) { + matches.push({ + category: category.name, + tool: toolName, + description: `${toolName} (${category.name})`, + }); + } + } + } + + return matches.slice(0, 20); // Limit results +} + +/** + * Get statistics about the tool registry + */ +export function getRegistryStats() { + const routedToolCount = getRoutedToolNames().length; + const directToolCount = directToolNames.length; + + return { + total_categories: toolCategories.length, + total_routed_tools: routedToolCount, + total_direct_tools: directToolCount, + total_tools: routedToolCount + directToolCount, + categories: toolCategories.map((c) => ({ + name: c.name, + tool_count: c.tools.length, + })), + }; +} + +// Initialize on module load +initializeRegistry(); diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index 1fcf905..a021f3f 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -161,15 +161,37 @@ preserves the component's position and UUID. Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_component.`, { schematicPath: z.string().describe("Path to the .kicad_sch file"), - reference: z.string().describe("Current reference designator of the component (e.g. R1, U3)"), - footprint: z.string().optional().describe("New KiCAD footprint string (e.g. Resistor_SMD:R_0603_1608Metric)"), - value: z.string().optional().describe("New value string (e.g. 10k, 100nF)"), - newReference: z.string().optional().describe("Rename the reference designator (e.g. R1 → R10)"), - fieldPositions: z.record(z.object({ - x: z.number(), - y: z.number(), - angle: z.number().optional().default(0), - })).optional().describe("Reposition field labels: map of field name to {x, y, angle} (e.g. {\"Reference\": {\"x\": 12.5, \"y\": 17.0}})"), + reference: z + .string() + .describe( + "Current reference designator of the component (e.g. R1, U3)", + ), + footprint: z + .string() + .optional() + .describe( + "New KiCAD footprint string (e.g. Resistor_SMD:R_0603_1608Metric)", + ), + value: z + .string() + .optional() + .describe("New value string (e.g. 10k, 100nF)"), + newReference: z + .string() + .optional() + .describe("Rename the reference designator (e.g. R1 → R10)"), + fieldPositions: z + .record( + z.object({ + x: z.number(), + y: z.number(), + angle: z.number().optional().default(0), + }), + ) + .optional() + .describe( + 'Reposition field labels: map of field name to {x, y, angle} (e.g. {"Reference": {"x": 12.5, "y": 17.0}})', + ), }, async (args: { schematicPath: string; @@ -210,7 +232,9 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp "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.", { schematicPath: z.string().describe("Path to the .kicad_sch file"), - reference: z.string().describe("Component reference designator (e.g. R1, U1)"), + reference: z + .string() + .describe("Component reference designator (e.g. R1, U1)"), }, async (args: { schematicPath: string; reference: string }) => { const result = await callKicadScript("get_schematic_component", args); @@ -220,20 +244,24 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp : "unknown"; const fieldLines = Object.entries(result.fields ?? {}).map( ([name, f]: [string, any]) => - ` ${name}: "${f.value}" @ (${f.x}, ${f.y}, angle=${f.angle}°)` + ` ${name}: "${f.value}" @ (${f.x}, ${f.y}, angle=${f.angle}°)`, ); return { - content: [{ - type: "text", - text: `Component ${result.reference} at ${pos}\nFields:\n${fieldLines.join("\n")}`, - }], + content: [ + { + type: "text", + text: `Component ${result.reference} at ${pos}\nFields:\n${fieldLines.join("\n")}`, + }, + ], }; } return { - content: [{ - type: "text", - text: `Failed to get component: ${result.message || "Unknown error"}`, - }], + content: [ + { + type: "text", + text: `Failed to get component: ${result.message || "Unknown error"}`, + }, + ], }; }, ); @@ -580,8 +608,14 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp schematicPath: z.string().describe("Path to the .kicad_sch file"), filter: z .object({ - libId: z.string().optional().describe("Filter by library ID (e.g., 'Device:R')"), - referencePrefix: z.string().optional().describe("Filter by reference prefix (e.g., 'R', 'C', 'U')"), + libId: z + .string() + .optional() + .describe("Filter by library ID (e.g., 'Device:R')"), + referencePrefix: z + .string() + .optional() + .describe("Filter by reference prefix (e.g., 'R', 'C', 'U')"), }) .optional() .describe("Optional filters"), @@ -595,7 +629,9 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp const comps = result.components || []; if (comps.length === 0) { return { - content: [{ type: "text", text: "No components found in schematic." }], + content: [ + { type: "text", text: "No components found in schematic." }, + ], }; } const lines = comps.map( @@ -656,7 +692,10 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp } return { content: [ - { type: "text", text: `Failed to list nets: ${result.message || "Unknown error"}` }, + { + type: "text", + text: `Failed to list nets: ${result.message || "Unknown error"}`, + }, ], isError: true, }; @@ -694,7 +733,10 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp } return { content: [ - { type: "text", text: `Failed to list wires: ${result.message || "Unknown error"}` }, + { + type: "text", + text: `Failed to list wires: ${result.message || "Unknown error"}`, + }, ], isError: true, }; @@ -732,7 +774,10 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp } return { content: [ - { type: "text", text: `Failed to list labels: ${result.message || "Unknown error"}` }, + { + type: "text", + text: `Failed to list labels: ${result.message || "Unknown error"}`, + }, ], isError: true, }; @@ -789,10 +834,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp schematicPath: z.string().describe("Path to the .kicad_sch file"), reference: z.string().describe("Reference designator (e.g., R1, U1)"), angle: z.number().describe("Rotation angle in degrees (0, 90, 180, 270)"), - mirror: z - .enum(["x", "y"]) - .optional() - .describe("Optional mirror axis"), + mirror: z.enum(["x", "y"]).optional().describe("Optional mirror axis"), }, async (args: { schematicPath: string; @@ -1036,8 +1078,14 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp .enum(["png", "svg"]) .optional() .describe("Output format (default: png)"), - width: z.number().optional().describe("Image width in pixels (default: 1200)"), - height: z.number().optional().describe("Image height in pixels (default: 900)"), + width: z + .number() + .optional() + .describe("Image width in pixels (default: 1200)"), + height: z + .number() + .optional() + .describe("Image height in pixels (default: 900)"), }, async (args: { schematicPath: string; @@ -1207,19 +1255,35 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp "get_schematic_view_region", "Export a cropped region of the schematic as an image (PNG or SVG). Specify bounding box coordinates in schematic mm. Useful for zooming into a specific area to inspect wiring or layout.", { - schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), + schematicPath: z + .string() + .describe("Path to the .kicad_sch schematic file"), x1: z.number().describe("Left X coordinate of the region in mm"), y1: z.number().describe("Top Y coordinate of the region in mm"), x2: z.number().describe("Right X coordinate of the region in mm"), y2: z.number().describe("Bottom Y coordinate of the region in mm"), - format: z.enum(["png", "svg"]).optional().describe("Output image format (default: png)"), - width: z.number().optional().describe("Output image width in pixels (default: 800)"), - height: z.number().optional().describe("Output image height in pixels (default: 600)"), + format: z + .enum(["png", "svg"]) + .optional() + .describe("Output image format (default: png)"), + width: z + .number() + .optional() + .describe("Output image width in pixels (default: 800)"), + height: z + .number() + .optional() + .describe("Output image height in pixels (default: 600)"), }, async (args: { schematicPath: string; - x1: number; y1: number; x2: number; y2: number; - format?: string; width?: number; height?: number; + x1: number; + y1: number; + x2: number; + y2: number; + format?: string; + width?: number; + height?: number; }) => { const result = await callKicadScript("get_schematic_view_region", args); if (result.success && result.imageData) { @@ -1227,27 +1291,40 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp return { content: [{ type: "text", text: result.imageData }] }; } return { - content: [{ - type: "image", - data: result.imageData, - mimeType: "image/png", - }], + content: [ + { + type: "image", + data: result.imageData, + mimeType: "image/png", + }, + ], }; } return { - content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }], + content: [ + { + type: "text", + text: `Failed: ${result.message || "Unknown error"}`, + }, + ], }; }, ); - // Find overlapping elements server.tool( "find_overlapping_elements", "Detect spatially overlapping symbols, wires, and labels in the schematic. Finds duplicate power symbols at the same position, collinear overlapping wires, and labels stacked on top of each other.", { - schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), - tolerance: z.number().optional().describe("Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection. (default: 0.5)"), + schematicPath: z + .string() + .describe("Path to the .kicad_sch schematic file"), + tolerance: z + .number() + .optional() + .describe( + "Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection. (default: 0.5)", + ), }, async (args: { schematicPath: string; tolerance?: number }) => { const result = await callKicadScript("find_overlapping_elements", args); @@ -1259,25 +1336,36 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp if (syms.length) { lines.push(`\nOverlapping symbols (${syms.length}):`); syms.slice(0, 20).forEach((o: any) => { - lines.push(` ${o.element1.reference} ↔ ${o.element2.reference} (${o.distance}mm) [${o.type}]`); + lines.push( + ` ${o.element1.reference} ↔ ${o.element2.reference} (${o.distance}mm) [${o.type}]`, + ); }); } if (lbls.length) { lines.push(`\nOverlapping labels (${lbls.length}):`); lbls.slice(0, 20).forEach((o: any) => { - lines.push(` "${o.element1.name}" ↔ "${o.element2.name}" (${o.distance}mm)`); + lines.push( + ` "${o.element1.name}" ↔ "${o.element2.name}" (${o.distance}mm)`, + ); }); } if (wires.length) { lines.push(`\nOverlapping wires (${wires.length}):`); wires.slice(0, 20).forEach((o: any) => { - lines.push(` wire @ (${o.wire1.start.x},${o.wire1.start.y})→(${o.wire1.end.x},${o.wire1.end.y}) overlaps with another`); + lines.push( + ` wire @ (${o.wire1.start.x},${o.wire1.start.y})→(${o.wire1.end.x},${o.wire1.end.y}) overlaps with another`, + ); }); } return { content: [{ type: "text", text: lines.join("\n") }] }; } return { - content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }], + content: [ + { + type: "text", + text: `Failed: ${result.message || "Unknown error"}`, + }, + ], }; }, ); @@ -1287,7 +1375,9 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp "get_elements_in_region", "List all symbols, wires, and labels within a rectangular region of the schematic. Useful for understanding what is in a specific area before modifying it.", { - schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), + schematicPath: z + .string() + .describe("Path to the .kicad_sch schematic file"), x1: z.number().describe("Left X coordinate of the region in mm"), y1: z.number().describe("Top Y coordinate of the region in mm"), x2: z.number().describe("Right X coordinate of the region in mm"), @@ -1295,39 +1385,56 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, async (args: { schematicPath: string; - x1: number; y1: number; x2: number; y2: number; + x1: number; + y1: number; + x2: number; + y2: number; }) => { const result = await callKicadScript("get_elements_in_region", args); if (result.success) { const c = result.counts; - const lines = [`Region (${args.x1},${args.y1})→(${args.x2},${args.y2}): ${c.symbols} symbols, ${c.wires} wires, ${c.labels} labels`]; + const lines = [ + `Region (${args.x1},${args.y1})→(${args.x2},${args.y2}): ${c.symbols} symbols, ${c.wires} wires, ${c.labels} labels`, + ]; const syms: any[] = result.symbols || []; if (syms.length) { lines.push("\nSymbols:"); syms.forEach((s: any) => { const pinCount = s.pins ? Object.keys(s.pins).length : 0; - lines.push(` ${s.reference} (${s.libId}) @ (${s.position.x}, ${s.position.y}) [${pinCount} pins]`); + lines.push( + ` ${s.reference} (${s.libId}) @ (${s.position.x}, ${s.position.y}) [${pinCount} pins]`, + ); }); } const wires: any[] = result.wires || []; if (wires.length) { lines.push(`\nWires (${wires.length}):`); wires.slice(0, 30).forEach((w: any) => { - lines.push(` (${w.start.x},${w.start.y}) → (${w.end.x},${w.end.y})`); + lines.push( + ` (${w.start.x},${w.start.y}) → (${w.end.x},${w.end.y})`, + ); }); - if (wires.length > 30) lines.push(` ... and ${wires.length - 30} more`); + if (wires.length > 30) + lines.push(` ... and ${wires.length - 30} more`); } const labels: any[] = result.labels || []; if (labels.length) { lines.push(`\nLabels (${labels.length}):`); labels.forEach((l: any) => { - lines.push(` "${l.name}" [${l.type}] @ (${l.position.x}, ${l.position.y})`); + lines.push( + ` "${l.name}" [${l.type}] @ (${l.position.x}, ${l.position.y})`, + ); }); } return { content: [{ type: "text", text: lines.join("\n") }] }; } return { - content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }], + content: [ + { + type: "text", + text: `Failed: ${result.message || "Unknown error"}`, + }, + ], }; }, ); @@ -1337,7 +1444,9 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp "find_wires_crossing_symbols", "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.", { - schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), + schematicPath: z + .string() + .describe("Path to the .kicad_sch schematic file"), }, async (args: { schematicPath: string }) => { const result = await callKicadScript("find_wires_crossing_symbols", args); @@ -1346,14 +1455,20 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp const lines = [`Found ${collisions.length} wire(s) crossing symbols:`]; collisions.slice(0, 30).forEach((c: any, i: number) => { lines.push( - ` ${i + 1}. Wire (${c.wire.start.x},${c.wire.start.y})→(${c.wire.end.x},${c.wire.end.y}) crosses ${c.component.reference} (${c.component.libId})` + ` ${i + 1}. Wire (${c.wire.start.x},${c.wire.start.y})→(${c.wire.end.x},${c.wire.end.y}) crosses ${c.component.reference} (${c.component.libId})`, ); }); - if (collisions.length > 30) lines.push(` ... and ${collisions.length - 30} more`); + if (collisions.length > 30) + lines.push(` ... and ${collisions.length - 30} more`); return { content: [{ type: "text", text: lines.join("\n") }] }; } return { - content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }], + content: [ + { + type: "text", + text: `Failed: ${result.message || "Unknown error"}`, + }, + ], }; }, ); From 038beb6d266b1345820ab3261d9939eec9a3a746 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 29 Mar 2026 10:57:22 +0100 Subject: [PATCH 32/74] refactor: use module-level Symbol constants in delete_wire and delete_label Replace inline Symbol() allocations with the existing _SYM_WIRE/_SYM_PTS/_SYM_XY constants and new _SYM_AT/_SYM_LABEL constants for consistency. Co-Authored-By: Claude Sonnet 4.6 --- python/commands/wire_manager.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/python/commands/wire_manager.py b/python/commands/wire_manager.py index 679ec2e..7ef7214 100644 --- a/python/commands/wire_manager.py +++ b/python/commands/wire_manager.py @@ -21,6 +21,8 @@ logger = logging.getLogger("kicad_interface") _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") @@ -543,20 +545,14 @@ class WireManager: for i, item in enumerate(sch_data): if not ( - isinstance(item, list) - and len(item) > 0 - and item[0] == Symbol("wire") + 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 @@ -566,7 +562,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 @@ -631,9 +627,7 @@ class WireManager: for i, item in enumerate(sch_data): if not ( - isinstance(item, list) - and len(item) > 0 - and item[0] == Symbol("label") + isinstance(item, list) and len(item) > 0 and item[0] == _SYM_LABEL ): continue @@ -647,9 +641,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, ) From 7d53272cb1114c48ee093b42947b6dfc569134f0 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 29 Mar 2026 12:28:27 +0100 Subject: [PATCH 33/74] fix: replace hardcoded contributor paths in __main__ blocks Replace /home/chris/... absolute paths with Path(__file__)-relative equivalents in wire_manager.py and pin_locator.py so the test scripts work on any machine. Co-Authored-By: Claude Sonnet 4.6 --- python/commands/pin_locator.py | 10 ++++++---- python/commands/wire_manager.py | 8 +++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/python/commands/pin_locator.py b/python/commands/pin_locator.py index 857c76e..794b5ee 100644 --- a/python/commands/pin_locator.py +++ b/python/commands/pin_locator.py @@ -418,21 +418,23 @@ if __name__ == "__main__": # Test pin location discovery 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") print("=" * 80) # 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) diff --git a/python/commands/wire_manager.py b/python/commands/wire_manager.py index 7ef7214..1fba738 100644 --- a/python/commands/wire_manager.py +++ b/python/commands/wire_manager.py @@ -706,20 +706,18 @@ 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 + sys.path.insert(0, str(Path(__file__).parent.parent)) + print("=" * 80) print("WIRE MANAGER TEST") print("=" * 80) # 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}") From 8b0ed30ee3d494f2dff527a233304cfaa1b07f58 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 29 Mar 2026 12:28:30 +0100 Subject: [PATCH 34/74] style: revert Prettier formatting in TS files Co-Authored-By: Claude Sonnet 4.6 --- src/tools/registry.ts | 615 +++++++++++++++++++++-------------------- src/tools/schematic.ts | 239 +++++----------- 2 files changed, 370 insertions(+), 484 deletions(-) diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 41e7ad4..4799052 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -1,307 +1,308 @@ -/** - * Tool Registry for KiCAD MCP Server - * - * Centralizes all tool definitions and provides lookup/search functionality - */ - -import { z } from "zod"; - -export interface ToolDefinition { - name: string; - description: string; - inputSchema: z.ZodObject | z.ZodType; - // Handler will be registered separately in the existing tool files -} - -export interface ToolCategory { - name: string; - description: string; - tools: string[]; // Tool names in this category -} - -/** - * Tool category definitions - * Each category groups related tools for better organization - */ -export const toolCategories: ToolCategory[] = [ - { - name: "board", - description: - "Board configuration: layers, mounting holes, zones, visualization", - tools: [ - "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", - ], - }, - { - name: "component", - description: - "Advanced component operations: edit, delete, search, group, annotate", - tools: [ - "rotate_component", - "delete_component", - "edit_component", - "find_component", - "get_component_properties", - "add_component_annotation", - "group_components", - "replace_component", - ], - }, - { - name: "export", - description: - "File export for fabrication and documentation: Gerber, PDF, BOM, 3D models", - tools: [ - "export_gerber", - "export_pdf", - "export_svg", - "export_3d", - "export_bom", - "export_netlist", - "export_position_file", - "export_vrml", - ], - }, - { - name: "drc", - description: - "Design rule checking and electrical validation: DRC, net classes, clearances", - tools: [ - "set_design_rules", - "get_design_rules", - "run_drc", - "add_net_class", - "assign_net_to_class", - "set_layer_constraints", - "check_clearance", - "get_drc_violations", - ], - }, - { - name: "schematic", - description: - "Schematic operations: create, inspect, add/edit/delete components, wire connections, netlists, annotation", - tools: [ - "create_schematic", - "add_schematic_component", - "list_schematic_components", - "move_schematic_component", - "rotate_schematic_component", - "annotate_schematic", - "add_schematic_wire", - "delete_schematic_wire", - "add_schematic_junction", - "add_schematic_net_label", - "delete_schematic_net_label", - "connect_to_net", - "connect_passthrough", - "get_net_connections", - "list_schematic_nets", - "list_schematic_wires", - "list_schematic_labels", - "get_wire_connections", - "generate_netlist", - "sync_schematic_to_board", - "get_schematic_view", - "export_schematic_svg", - "export_schematic_pdf", - ], - }, - { - name: "library", - description: - "Footprint library access: search, browse, get footprint information", - tools: [ - "list_libraries", - "search_footprints", - "list_library_footprints", - "get_footprint_info", - ], - }, - { - name: "routing", - description: "Advanced routing operations: vias, copper pours", - tools: ["add_via", "add_copper_pour"], - }, - { - name: "autoroute", - description: - "Freerouting autorouter: automatic PCB routing via Specctra DSN/SES", - tools: ["autoroute", "export_dsn", "import_ses", "check_freerouting"], - }, -]; - -/** - * Direct tools that are always visible (not routed) - * These are the most frequently used tools - */ -export const directToolNames = [ - // Project lifecycle - "create_project", - "open_project", - "save_project", - "snapshot_project", - "get_project_info", - - // Core PCB operations - "place_component", - "move_component", - "add_net", - "route_trace", - "get_board_info", - "set_board_size", - - // Board setup - "add_board_outline", - - // Schematic essentials (always visible so AI uses them correctly) - "add_schematic_component", - "list_schematic_components", - "annotate_schematic", - "connect_passthrough", - "connect_to_net", - "add_schematic_net_label", - - // Schematic <-> PCB sync (F8 equivalent) - "sync_schematic_to_board", - - // UI management - "check_kicad_ui", -]; - -// Build lookup maps at module load time -const categoryMap = new Map(); -const toolCategoryMap = new Map(); - -export function initializeRegistry() { - // Build category map - for (const category of toolCategories) { - categoryMap.set(category.name, category); - - // Build tool -> category map - for (const toolName of category.tools) { - toolCategoryMap.set(toolName, category.name); - } - } -} - -/** - * Get a category by name - */ -export function getCategory(name: string): ToolCategory | undefined { - return categoryMap.get(name); -} - -/** - * Get the category name for a tool - */ -export function getToolCategory(toolName: string): string | undefined { - return toolCategoryMap.get(toolName); -} - -/** - * Get all categories - */ -export function getAllCategories(): ToolCategory[] { - return toolCategories; -} - -/** - * Get all routed tool names (excludes direct tools) - */ -export function getRoutedToolNames(): string[] { - const allRoutedTools: string[] = []; - for (const category of toolCategories) { - allRoutedTools.push(...category.tools); - } - return allRoutedTools; -} - -/** - * Check if a tool is a direct tool - */ -export function isDirectTool(toolName: string): boolean { - return directToolNames.includes(toolName); -} - -/** - * Check if a tool is a routed tool - */ -export function isRoutedTool(toolName: string): boolean { - return toolCategoryMap.has(toolName); -} - -/** - * Search for tools by keyword - * Searches tool names, descriptions, and category names - */ -export interface SearchResult { - category: string; - tool: string; - description: string; -} - -export function searchTools(query: string): SearchResult[] { - const q = query.toLowerCase(); - const matches: SearchResult[] = []; - - // Search direct tools first - for (const toolName of directToolNames) { - if (toolName.toLowerCase().includes(q)) { - matches.push({ - category: "direct", - tool: toolName, - description: `${toolName} (direct tool — call directly, no execute_tool needed)`, - }); - } - } - - // Search routed tools by name and category - for (const category of toolCategories) { - const categoryMatch = - category.name.toLowerCase().includes(q) || - category.description.toLowerCase().includes(q); - - for (const toolName of category.tools) { - if (toolName.toLowerCase().includes(q) || categoryMatch) { - matches.push({ - category: category.name, - tool: toolName, - description: `${toolName} (${category.name})`, - }); - } - } - } - - return matches.slice(0, 20); // Limit results -} - -/** - * Get statistics about the tool registry - */ -export function getRegistryStats() { - const routedToolCount = getRoutedToolNames().length; - const directToolCount = directToolNames.length; - - return { - total_categories: toolCategories.length, - total_routed_tools: routedToolCount, - total_direct_tools: directToolCount, - total_tools: routedToolCount + directToolCount, - categories: toolCategories.map((c) => ({ - name: c.name, - tool_count: c.tools.length, - })), - }; -} - -// Initialize on module load -initializeRegistry(); +/** + * Tool Registry for KiCAD MCP Server + * + * Centralizes all tool definitions and provides lookup/search functionality + */ + +import { z } from 'zod'; + +export interface ToolDefinition { + name: string; + description: string; + inputSchema: z.ZodObject | z.ZodType; + // Handler will be registered separately in the existing tool files +} + +export interface ToolCategory { + name: string; + description: string; + tools: string[]; // Tool names in this category +} + +/** + * Tool category definitions + * Each category groups related tools for better organization + */ +export const toolCategories: ToolCategory[] = [ + { + name: "board", + description: "Board configuration: layers, mounting holes, zones, visualization", + tools: [ + "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" + ] + }, + { + name: "component", + description: "Advanced component operations: edit, delete, search, group, annotate", + tools: [ + "rotate_component", + "delete_component", + "edit_component", + "find_component", + "get_component_properties", + "add_component_annotation", + "group_components", + "replace_component" + ] + }, + { + name: "export", + description: "File export for fabrication and documentation: Gerber, PDF, BOM, 3D models", + tools: [ + "export_gerber", + "export_pdf", + "export_svg", + "export_3d", + "export_bom", + "export_netlist", + "export_position_file", + "export_vrml" + ] + }, + { + name: "drc", + description: "Design rule checking and electrical validation: DRC, net classes, clearances", + tools: [ + "set_design_rules", + "get_design_rules", + "run_drc", + "add_net_class", + "assign_net_to_class", + "set_layer_constraints", + "check_clearance", + "get_drc_violations" + ] + }, + { + name: "schematic", + description: "Schematic operations: create, inspect, add/edit/delete components, wire connections, netlists, annotation", + tools: [ + "create_schematic", + "add_schematic_component", + "list_schematic_components", + "move_schematic_component", + "rotate_schematic_component", + "annotate_schematic", + "add_schematic_wire", + "delete_schematic_wire", + "add_schematic_junction", + "add_schematic_net_label", + "delete_schematic_net_label", + "connect_to_net", + "connect_passthrough", + "get_net_connections", + "list_schematic_nets", + "list_schematic_wires", + "list_schematic_labels", + "get_wire_connections", + "generate_netlist", + "sync_schematic_to_board", + "get_schematic_view", + "export_schematic_svg", + "export_schematic_pdf" + ] + }, + { + name: "library", + description: "Footprint library access: search, browse, get footprint information", + tools: [ + "list_libraries", + "search_footprints", + "list_library_footprints", + "get_footprint_info" + ] + }, + { + name: "routing", + description: "Advanced routing operations: vias, copper pours", + tools: [ + "add_via", + "add_copper_pour" + ] + }, + { + name: "autoroute", + description: "Freerouting autorouter: automatic PCB routing via Specctra DSN/SES", + tools: [ + "autoroute", + "export_dsn", + "import_ses", + "check_freerouting" + ] + } +]; + +/** + * Direct tools that are always visible (not routed) + * These are the most frequently used tools + */ +export const directToolNames = [ + // Project lifecycle + "create_project", + "open_project", + "save_project", + "snapshot_project", + "get_project_info", + + // Core PCB operations + "place_component", + "move_component", + "add_net", + "route_trace", + "get_board_info", + "set_board_size", + + // Board setup + "add_board_outline", + + // Schematic essentials (always visible so AI uses them correctly) + "add_schematic_component", + "list_schematic_components", + "annotate_schematic", + "connect_passthrough", + "connect_to_net", + "add_schematic_net_label", + + // Schematic <-> PCB sync (F8 equivalent) + "sync_schematic_to_board", + + // UI management + "check_kicad_ui" +]; + +// Build lookup maps at module load time +const categoryMap = new Map(); +const toolCategoryMap = new Map(); + +export function initializeRegistry() { + // Build category map + for (const category of toolCategories) { + categoryMap.set(category.name, category); + + // Build tool -> category map + for (const toolName of category.tools) { + toolCategoryMap.set(toolName, category.name); + } + } +} + +/** + * Get a category by name + */ +export function getCategory(name: string): ToolCategory | undefined { + return categoryMap.get(name); +} + +/** + * Get the category name for a tool + */ +export function getToolCategory(toolName: string): string | undefined { + return toolCategoryMap.get(toolName); +} + +/** + * Get all categories + */ +export function getAllCategories(): ToolCategory[] { + return toolCategories; +} + +/** + * Get all routed tool names (excludes direct tools) + */ +export function getRoutedToolNames(): string[] { + const allRoutedTools: string[] = []; + for (const category of toolCategories) { + allRoutedTools.push(...category.tools); + } + return allRoutedTools; +} + +/** + * Check if a tool is a direct tool + */ +export function isDirectTool(toolName: string): boolean { + return directToolNames.includes(toolName); +} + +/** + * Check if a tool is a routed tool + */ +export function isRoutedTool(toolName: string): boolean { + return toolCategoryMap.has(toolName); +} + +/** + * Search for tools by keyword + * Searches tool names, descriptions, and category names + */ +export interface SearchResult { + category: string; + tool: string; + description: string; +} + +export function searchTools(query: string): SearchResult[] { + const q = query.toLowerCase(); + const matches: SearchResult[] = []; + + // Search direct tools first + for (const toolName of directToolNames) { + if (toolName.toLowerCase().includes(q)) { + matches.push({ + category: "direct", + tool: toolName, + description: `${toolName} (direct tool — call directly, no execute_tool needed)` + }); + } + } + + // Search routed tools by name and category + for (const category of toolCategories) { + const categoryMatch = + category.name.toLowerCase().includes(q) || + category.description.toLowerCase().includes(q); + + for (const toolName of category.tools) { + if (toolName.toLowerCase().includes(q) || categoryMatch) { + matches.push({ + category: category.name, + tool: toolName, + description: `${toolName} (${category.name})` + }); + } + } + } + + return matches.slice(0, 20); // Limit results +} + +/** + * Get statistics about the tool registry + */ +export function getRegistryStats() { + const routedToolCount = getRoutedToolNames().length; + const directToolCount = directToolNames.length; + + return { + total_categories: toolCategories.length, + total_routed_tools: routedToolCount, + total_direct_tools: directToolCount, + total_tools: routedToolCount + directToolCount, + categories: toolCategories.map(c => ({ + name: c.name, + tool_count: c.tools.length + })) + }; +} + +// Initialize on module load +initializeRegistry(); diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index a021f3f..1fcf905 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -161,37 +161,15 @@ preserves the component's position and UUID. Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_component.`, { schematicPath: z.string().describe("Path to the .kicad_sch file"), - reference: z - .string() - .describe( - "Current reference designator of the component (e.g. R1, U3)", - ), - footprint: z - .string() - .optional() - .describe( - "New KiCAD footprint string (e.g. Resistor_SMD:R_0603_1608Metric)", - ), - value: z - .string() - .optional() - .describe("New value string (e.g. 10k, 100nF)"), - newReference: z - .string() - .optional() - .describe("Rename the reference designator (e.g. R1 → R10)"), - fieldPositions: z - .record( - z.object({ - x: z.number(), - y: z.number(), - angle: z.number().optional().default(0), - }), - ) - .optional() - .describe( - 'Reposition field labels: map of field name to {x, y, angle} (e.g. {"Reference": {"x": 12.5, "y": 17.0}})', - ), + reference: z.string().describe("Current reference designator of the component (e.g. R1, U3)"), + footprint: z.string().optional().describe("New KiCAD footprint string (e.g. Resistor_SMD:R_0603_1608Metric)"), + value: z.string().optional().describe("New value string (e.g. 10k, 100nF)"), + newReference: z.string().optional().describe("Rename the reference designator (e.g. R1 → R10)"), + fieldPositions: z.record(z.object({ + x: z.number(), + y: z.number(), + angle: z.number().optional().default(0), + })).optional().describe("Reposition field labels: map of field name to {x, y, angle} (e.g. {\"Reference\": {\"x\": 12.5, \"y\": 17.0}})"), }, async (args: { schematicPath: string; @@ -232,9 +210,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp "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.", { schematicPath: z.string().describe("Path to the .kicad_sch file"), - reference: z - .string() - .describe("Component reference designator (e.g. R1, U1)"), + reference: z.string().describe("Component reference designator (e.g. R1, U1)"), }, async (args: { schematicPath: string; reference: string }) => { const result = await callKicadScript("get_schematic_component", args); @@ -244,24 +220,20 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp : "unknown"; const fieldLines = Object.entries(result.fields ?? {}).map( ([name, f]: [string, any]) => - ` ${name}: "${f.value}" @ (${f.x}, ${f.y}, angle=${f.angle}°)`, + ` ${name}: "${f.value}" @ (${f.x}, ${f.y}, angle=${f.angle}°)` ); return { - content: [ - { - type: "text", - text: `Component ${result.reference} at ${pos}\nFields:\n${fieldLines.join("\n")}`, - }, - ], + content: [{ + type: "text", + text: `Component ${result.reference} at ${pos}\nFields:\n${fieldLines.join("\n")}`, + }], }; } return { - content: [ - { - type: "text", - text: `Failed to get component: ${result.message || "Unknown error"}`, - }, - ], + content: [{ + type: "text", + text: `Failed to get component: ${result.message || "Unknown error"}`, + }], }; }, ); @@ -608,14 +580,8 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp schematicPath: z.string().describe("Path to the .kicad_sch file"), filter: z .object({ - libId: z - .string() - .optional() - .describe("Filter by library ID (e.g., 'Device:R')"), - referencePrefix: z - .string() - .optional() - .describe("Filter by reference prefix (e.g., 'R', 'C', 'U')"), + libId: z.string().optional().describe("Filter by library ID (e.g., 'Device:R')"), + referencePrefix: z.string().optional().describe("Filter by reference prefix (e.g., 'R', 'C', 'U')"), }) .optional() .describe("Optional filters"), @@ -629,9 +595,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp const comps = result.components || []; if (comps.length === 0) { return { - content: [ - { type: "text", text: "No components found in schematic." }, - ], + content: [{ type: "text", text: "No components found in schematic." }], }; } const lines = comps.map( @@ -692,10 +656,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp } return { content: [ - { - type: "text", - text: `Failed to list nets: ${result.message || "Unknown error"}`, - }, + { type: "text", text: `Failed to list nets: ${result.message || "Unknown error"}` }, ], isError: true, }; @@ -733,10 +694,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp } return { content: [ - { - type: "text", - text: `Failed to list wires: ${result.message || "Unknown error"}`, - }, + { type: "text", text: `Failed to list wires: ${result.message || "Unknown error"}` }, ], isError: true, }; @@ -774,10 +732,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp } return { content: [ - { - type: "text", - text: `Failed to list labels: ${result.message || "Unknown error"}`, - }, + { type: "text", text: `Failed to list labels: ${result.message || "Unknown error"}` }, ], isError: true, }; @@ -834,7 +789,10 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp schematicPath: z.string().describe("Path to the .kicad_sch file"), reference: z.string().describe("Reference designator (e.g., R1, U1)"), angle: z.number().describe("Rotation angle in degrees (0, 90, 180, 270)"), - mirror: z.enum(["x", "y"]).optional().describe("Optional mirror axis"), + mirror: z + .enum(["x", "y"]) + .optional() + .describe("Optional mirror axis"), }, async (args: { schematicPath: string; @@ -1078,14 +1036,8 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp .enum(["png", "svg"]) .optional() .describe("Output format (default: png)"), - width: z - .number() - .optional() - .describe("Image width in pixels (default: 1200)"), - height: z - .number() - .optional() - .describe("Image height in pixels (default: 900)"), + width: z.number().optional().describe("Image width in pixels (default: 1200)"), + height: z.number().optional().describe("Image height in pixels (default: 900)"), }, async (args: { schematicPath: string; @@ -1255,35 +1207,19 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp "get_schematic_view_region", "Export a cropped region of the schematic as an image (PNG or SVG). Specify bounding box coordinates in schematic mm. Useful for zooming into a specific area to inspect wiring or layout.", { - schematicPath: z - .string() - .describe("Path to the .kicad_sch schematic file"), + schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), x1: z.number().describe("Left X coordinate of the region in mm"), y1: z.number().describe("Top Y coordinate of the region in mm"), x2: z.number().describe("Right X coordinate of the region in mm"), y2: z.number().describe("Bottom Y coordinate of the region in mm"), - format: z - .enum(["png", "svg"]) - .optional() - .describe("Output image format (default: png)"), - width: z - .number() - .optional() - .describe("Output image width in pixels (default: 800)"), - height: z - .number() - .optional() - .describe("Output image height in pixels (default: 600)"), + format: z.enum(["png", "svg"]).optional().describe("Output image format (default: png)"), + width: z.number().optional().describe("Output image width in pixels (default: 800)"), + height: z.number().optional().describe("Output image height in pixels (default: 600)"), }, async (args: { schematicPath: string; - x1: number; - y1: number; - x2: number; - y2: number; - format?: string; - width?: number; - height?: number; + x1: number; y1: number; x2: number; y2: number; + format?: string; width?: number; height?: number; }) => { const result = await callKicadScript("get_schematic_view_region", args); if (result.success && result.imageData) { @@ -1291,40 +1227,27 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp return { content: [{ type: "text", text: result.imageData }] }; } return { - content: [ - { - type: "image", - data: result.imageData, - mimeType: "image/png", - }, - ], + content: [{ + type: "image", + data: result.imageData, + mimeType: "image/png", + }], }; } return { - content: [ - { - type: "text", - text: `Failed: ${result.message || "Unknown error"}`, - }, - ], + content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }], }; }, ); + // Find overlapping elements server.tool( "find_overlapping_elements", "Detect spatially overlapping symbols, wires, and labels in the schematic. Finds duplicate power symbols at the same position, collinear overlapping wires, and labels stacked on top of each other.", { - schematicPath: z - .string() - .describe("Path to the .kicad_sch schematic file"), - tolerance: z - .number() - .optional() - .describe( - "Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection. (default: 0.5)", - ), + schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), + tolerance: z.number().optional().describe("Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection. (default: 0.5)"), }, async (args: { schematicPath: string; tolerance?: number }) => { const result = await callKicadScript("find_overlapping_elements", args); @@ -1336,36 +1259,25 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp if (syms.length) { lines.push(`\nOverlapping symbols (${syms.length}):`); syms.slice(0, 20).forEach((o: any) => { - lines.push( - ` ${o.element1.reference} ↔ ${o.element2.reference} (${o.distance}mm) [${o.type}]`, - ); + lines.push(` ${o.element1.reference} ↔ ${o.element2.reference} (${o.distance}mm) [${o.type}]`); }); } if (lbls.length) { lines.push(`\nOverlapping labels (${lbls.length}):`); lbls.slice(0, 20).forEach((o: any) => { - lines.push( - ` "${o.element1.name}" ↔ "${o.element2.name}" (${o.distance}mm)`, - ); + lines.push(` "${o.element1.name}" ↔ "${o.element2.name}" (${o.distance}mm)`); }); } if (wires.length) { lines.push(`\nOverlapping wires (${wires.length}):`); wires.slice(0, 20).forEach((o: any) => { - lines.push( - ` wire @ (${o.wire1.start.x},${o.wire1.start.y})→(${o.wire1.end.x},${o.wire1.end.y}) overlaps with another`, - ); + lines.push(` wire @ (${o.wire1.start.x},${o.wire1.start.y})→(${o.wire1.end.x},${o.wire1.end.y}) overlaps with another`); }); } return { content: [{ type: "text", text: lines.join("\n") }] }; } return { - content: [ - { - type: "text", - text: `Failed: ${result.message || "Unknown error"}`, - }, - ], + content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }], }; }, ); @@ -1375,9 +1287,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp "get_elements_in_region", "List all symbols, wires, and labels within a rectangular region of the schematic. Useful for understanding what is in a specific area before modifying it.", { - schematicPath: z - .string() - .describe("Path to the .kicad_sch schematic file"), + schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), x1: z.number().describe("Left X coordinate of the region in mm"), y1: z.number().describe("Top Y coordinate of the region in mm"), x2: z.number().describe("Right X coordinate of the region in mm"), @@ -1385,56 +1295,39 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, async (args: { schematicPath: string; - x1: number; - y1: number; - x2: number; - y2: number; + x1: number; y1: number; x2: number; y2: number; }) => { const result = await callKicadScript("get_elements_in_region", args); if (result.success) { const c = result.counts; - const lines = [ - `Region (${args.x1},${args.y1})→(${args.x2},${args.y2}): ${c.symbols} symbols, ${c.wires} wires, ${c.labels} labels`, - ]; + const lines = [`Region (${args.x1},${args.y1})→(${args.x2},${args.y2}): ${c.symbols} symbols, ${c.wires} wires, ${c.labels} labels`]; const syms: any[] = result.symbols || []; if (syms.length) { lines.push("\nSymbols:"); syms.forEach((s: any) => { const pinCount = s.pins ? Object.keys(s.pins).length : 0; - lines.push( - ` ${s.reference} (${s.libId}) @ (${s.position.x}, ${s.position.y}) [${pinCount} pins]`, - ); + lines.push(` ${s.reference} (${s.libId}) @ (${s.position.x}, ${s.position.y}) [${pinCount} pins]`); }); } const wires: any[] = result.wires || []; if (wires.length) { lines.push(`\nWires (${wires.length}):`); wires.slice(0, 30).forEach((w: any) => { - lines.push( - ` (${w.start.x},${w.start.y}) → (${w.end.x},${w.end.y})`, - ); + lines.push(` (${w.start.x},${w.start.y}) → (${w.end.x},${w.end.y})`); }); - if (wires.length > 30) - lines.push(` ... and ${wires.length - 30} more`); + if (wires.length > 30) lines.push(` ... and ${wires.length - 30} more`); } const labels: any[] = result.labels || []; if (labels.length) { lines.push(`\nLabels (${labels.length}):`); labels.forEach((l: any) => { - lines.push( - ` "${l.name}" [${l.type}] @ (${l.position.x}, ${l.position.y})`, - ); + lines.push(` "${l.name}" [${l.type}] @ (${l.position.x}, ${l.position.y})`); }); } return { content: [{ type: "text", text: lines.join("\n") }] }; } return { - content: [ - { - type: "text", - text: `Failed: ${result.message || "Unknown error"}`, - }, - ], + content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }], }; }, ); @@ -1444,9 +1337,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp "find_wires_crossing_symbols", "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.", { - schematicPath: z - .string() - .describe("Path to the .kicad_sch schematic file"), + schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), }, async (args: { schematicPath: string }) => { const result = await callKicadScript("find_wires_crossing_symbols", args); @@ -1455,20 +1346,14 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp const lines = [`Found ${collisions.length} wire(s) crossing symbols:`]; collisions.slice(0, 30).forEach((c: any, i: number) => { lines.push( - ` ${i + 1}. Wire (${c.wire.start.x},${c.wire.start.y})→(${c.wire.end.x},${c.wire.end.y}) crosses ${c.component.reference} (${c.component.libId})`, + ` ${i + 1}. Wire (${c.wire.start.x},${c.wire.start.y})→(${c.wire.end.x},${c.wire.end.y}) crosses ${c.component.reference} (${c.component.libId})` ); }); - if (collisions.length > 30) - lines.push(` ... and ${collisions.length - 30} more`); + if (collisions.length > 30) lines.push(` ... and ${collisions.length - 30} more`); return { content: [{ type: "text", text: lines.join("\n") }] }; } return { - content: [ - { - type: "text", - text: `Failed: ${result.message || "Unknown error"}`, - }, - ], + content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }], }; }, ); From 3e849576983a5c250af9a929d72c5c81fb121d07 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 29 Mar 2026 12:38:17 +0100 Subject: [PATCH 35/74] style: apply Black formatting to pin_locator.py Co-Authored-By: Claude Sonnet 4.6 --- python/commands/pin_locator.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/python/commands/pin_locator.py b/python/commands/pin_locator.py index 794b5ee..040cbbd 100644 --- a/python/commands/pin_locator.py +++ b/python/commands/pin_locator.py @@ -220,14 +220,20 @@ class PinLocator: symbol_at = target_symbol.at.value symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0 - 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: return None pins = self.get_symbol_pins(schematic_path, lib_id) if pin_number not in pins: matched_num = next( - (num for num, data in pins.items() if data.get("name") == pin_number), + ( + num + for num, data in pins.items() + if data.get("name") == pin_number + ), None, ) if matched_num: @@ -305,11 +311,17 @@ class PinLocator: if pin_number not in pins: # Try matching by pin name (e.g. "VCC1", "SDA", "GND") matched_num = next( - (num for num, data in pins.items() if data.get("name") == pin_number), + ( + num + for num, data in pins.items() + if data.get("name") == pin_number + ), 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( From eee5bfb9ed5cb4397200eb9c9286ec3502f7491a Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 29 Mar 2026 12:58:36 +0100 Subject: [PATCH 36/74] chore: set up pre-commit framework with general hooks Add .pre-commit-config.yaml with pre-commit-hooks v5.0.0 (trailing whitespace, end-of-file fixer, yaml/json checks, large file guard, merge conflict detection). Add minimal pyproject.toml. Auto-fix trailing whitespace and missing end-of-file newlines across the codebase. Co-Authored-By: Claude Opus 4.6 --- .pre-commit-config.yaml | 82 +++------------- README.md | 6 -- docs/mcp-router-guide.md | 56 +++++------ pyproject.toml | 4 + python/commands/board/__init__.py | 20 ++-- python/commands/board/layers.py | 4 +- python/commands/board/view.py | 8 +- python/commands/component.py | 134 +++++++++++++-------------- python/commands/library.py | 2 +- python/commands/library_schematic.py | 26 +++--- src/config.ts | 10 +- src/index.ts | 24 ++--- src/kicad-server.ts | 98 ++++++++++---------- src/logger.ts | 20 ++-- src/prompts/component.ts | 4 +- src/prompts/design.ts | 4 +- src/prompts/routing.ts | 4 +- src/resources/board.ts | 46 ++++----- src/resources/component.ts | 28 +++--- src/resources/index.ts | 2 +- src/resources/library.ts | 14 +-- src/resources/project.ts | 34 +++---- src/tools/board.ts | 27 +++--- src/tools/component.txt | 6 +- src/tools/design-rules.ts | 22 ++--- src/tools/export.ts | 22 ++--- 26 files changed, 322 insertions(+), 385 deletions(-) create mode 100644 pyproject.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2c72da3..a65cd00 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,71 +1,11 @@ -# 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 - hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - - id: check-json - - id: check-added-large-files - args: [--maxkb=500] - - id: check-merge-conflict - - id: detect-private-key - - # Python security checks - - repo: https://github.com/PyCQA/bandit - rev: 1.7.5 - hooks: - - id: bandit - args: [-c, pyproject.toml] - files: ^python/ - - # Markdown linting - - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.37.0 - hooks: - - id: markdownlint - args: [--fix] +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + 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'] + - id: check-merge-conflict diff --git a/README.md b/README.md index 086b253..1c88ddb 100644 --- a/README.md +++ b/README.md @@ -986,9 +986,3 @@ If you use this project in your research or publication, please cite: version = {2.2.3} } ``` - - - - - - diff --git a/docs/mcp-router-guide.md b/docs/mcp-router-guide.md index 39c83c5..bb1ae93 100644 --- a/docs/mcp-router-guide.md +++ b/docs/mcp-router-guide.md @@ -106,12 +106,12 @@ export const toolCategories: ToolCategory[] = [ inputSchema: { type: "object", properties: { - output_dir: { - type: "string", - description: "Output directory path" + output_dir: { + type: "string", + description: "Output directory path" }, - layers: { - type: "array", + layers: { + type: "array", items: { type: "string" }, description: "Layers to export (default: all copper + silkscreen + mask)" }, @@ -160,9 +160,9 @@ export const toolCategories: ToolCategory[] = [ inputSchema: { type: "object", properties: { - report_all: { - type: "boolean", - description: "Report all violations or stop at first" + report_all: { + type: "boolean", + description: "Report all violations or stop at first" } } }, @@ -313,17 +313,17 @@ These are the tools that enable discovery and execution. ```typescript // src/tools/router.ts -import { - getAllCategories, - getCategory, - getTool, - searchTools +import { + getAllCategories, + getCategory, + getTool, + searchTools } from "./registry.js"; export const routerTools = { list_tool_categories: { name: "list_tool_categories", - description: + description: "List all available tool categories. Use this to discover what operations " + "are available beyond the basic tools exposed directly.", inputSchema: { @@ -475,8 +475,8 @@ export const directTools: ToolDefinition[] = [ properties: { name: { type: "string", description: "Project name" }, path: { type: "string", description: "Directory path for project" }, - template: { - type: "string", + template: { + type: "string", description: "Optional template to use", enum: ["blank", "arduino", "raspberry-pi"] } @@ -804,19 +804,19 @@ Include: const categories = [ // Core operations (might be direct tools instead) { name: "project", description: "Project lifecycle: create, open, save, close" }, - + // Domain-specific operations { name: "analysis", description: "Analyze and inspect: find patterns, validate, check" }, { name: "modification", description: "Modify and transform: edit, rename, restructure" }, { name: "navigation", description: "Navigate and search: find, list, filter, locate" }, - + // Output operations { name: "export", description: "Export and generate: reports, files, documentation" }, { name: "import", description: "Import from external sources: files, formats, APIs" }, - + // Configuration { name: "config", description: "Configuration and settings: preferences, rules, templates" }, - + // Advanced/specialized { name: "advanced", description: "Advanced operations: batch processing, automation, scripting" }, ]; @@ -962,11 +962,11 @@ 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, +import { + searchTools, + getCategory, getTool, - getAllCategories + getAllCategories } from "../src/tools/registry.js"; import { routerTools } from "../src/tools/router.js"; @@ -997,16 +997,16 @@ describe("Router Tools", () => { }); it("get_category_tools returns tools for valid category", async () => { - const result = await routerTools.get_category_tools.handler({ - category: "export" + const result = await routerTools.get_category_tools.handler({ + category: "export" }); expect(result.tools).toBeDefined(); expect(result.tools.length).toBeGreaterThan(0); }); it("get_category_tools returns error for invalid category", async () => { - const result = await routerTools.get_category_tools.handler({ - category: "nonexistent" + const result = await routerTools.get_category_tools.handler({ + category: "nonexistent" }); expect(result.error).toBeDefined(); }); diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..71f7950 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "kicad-mcp-server" +version = "2.1.0" +requires-python = ">=3.10" diff --git a/python/commands/board/__init__.py b/python/commands/board/__init__.py index 08b974e..7a14e61 100644 --- a/python/commands/board/__init__.py +++ b/python/commands/board/__init__.py @@ -20,57 +20,57 @@ class BoardCommands: def __init__(self, board: Optional[pcbnew.BOARD] = None): """Initialize with optional board instance""" self.board = board - + # Initialize specialized command classes self.size_commands = BoardSizeCommands(board) self.layer_commands = BoardLayerCommands(board) self.outline_commands = BoardOutlineCommands(board) self.view_commands = BoardViewCommands(board) - + # Delegate board size commands def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]: """Set the size of the PCB board""" self.size_commands.board = self.board return self.size_commands.set_board_size(params) - + # Delegate layer commands def add_layer(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add a new layer to the PCB""" self.layer_commands.board = self.board return self.layer_commands.add_layer(params) - + def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]: """Set the active layer for PCB operations""" self.layer_commands.board = self.board return self.layer_commands.set_active_layer(params) - + def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get a list of all layers in the PCB""" self.layer_commands.board = self.board return self.layer_commands.get_layer_list(params) - + # Delegate board outline commands def add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add a board outline to the PCB""" self.outline_commands.board = self.board return self.outline_commands.add_board_outline(params) - + def add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add a mounting hole to the PCB""" self.outline_commands.board = self.board return self.outline_commands.add_mounting_hole(params) - + def add_text(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add text annotation to the PCB""" self.outline_commands.board = self.board return self.outline_commands.add_text(params) - + # Delegate view commands def get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get information about the current board""" self.view_commands.board = self.board return self.view_commands.get_board_info(params) - + def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get a 2D image of the PCB""" self.view_commands.board = self.board diff --git a/python/commands/board/layers.py b/python/commands/board/layers.py index 1e407ec..f80f345 100644 --- a/python/commands/board/layers.py +++ b/python/commands/board/layers.py @@ -65,7 +65,7 @@ class BoardLayerCommands: # Set layer properties layer_stack.SetLayerName(layer_id, name) layer_stack.SetLayerType(layer_id, self._get_layer_type(layer_type)) - + # Enable the layer self.board.SetLayerEnabled(layer_id, True) @@ -168,7 +168,7 @@ class BoardLayerCommands: "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""" type_map = { diff --git a/python/commands/board/view.py b/python/commands/board/view.py index aaad991..4aa5bfe 100644 --- a/python/commands/board/view.py +++ b/python/commands/board/view.py @@ -90,7 +90,7 @@ class BoardViewCommands: # Create plot controller plotter = pcbnew.PLOT_CONTROLLER(self.board) - + # Set up plot options plot_opts = plotter.GetPlotOptions() plot_opts.SetOutputDirectory(os.path.dirname(self.board.GetFileName())) @@ -100,7 +100,7 @@ class BoardViewCommands: plot_opts.SetPlotFrameRef(False) plot_opts.SetPlotValue(True) plot_opts.SetPlotReference(True) - + # Plot to SVG first (for vector output) # Note: KiCAD 9.0 prepends the project name to the filename, so we use GetPlotFileName() to get the actual path plotter.OpenPlotfile("temp_view", pcbnew.PLOT_FORMAT_SVG, "Temporary View") @@ -139,7 +139,7 @@ class BoardViewCommands: from cairosvg import svg2png png_data = svg2png(url=temp_svg, output_width=width, output_height=height) os.remove(temp_svg) - + if format == "jpg": # Convert PNG to JPG img = Image.open(io.BytesIO(png_data)) @@ -165,7 +165,7 @@ class BoardViewCommands: "message": "Failed to get board 2D view", "errorDetails": str(e) } - + def _get_layer_type_name(self, type_id: int) -> str: """Convert KiCAD layer type constant to name""" type_map = { diff --git a/python/commands/component.py b/python/commands/component.py index 472fec0..f707f10 100644 --- a/python/commands/component.py +++ b/python/commands/component.py @@ -752,14 +752,14 @@ class ComponentCommands: count = params.get("count") reference_prefix = params.get("referencePrefix", "U") value = params.get("value") - + if not component_id or not count: return { "success": False, "message": "Missing parameters", "errorDetails": "componentId and count are required" } - + if pattern == "grid": start_position = params.get("startPosition") rows = params.get("rows") @@ -768,21 +768,21 @@ class ComponentCommands: spacing_y = params.get("spacingY") rotation = params.get("rotation", 0) layer = params.get("layer", "F.Cu") - + if not start_position or not rows or not columns or not spacing_x or not spacing_y: return { "success": False, "message": "Missing grid parameters", "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" } - + placed_components = self._place_grid_array( component_id, start_position, @@ -795,7 +795,7 @@ class ComponentCommands: rotation, layer ) - + elif pattern == "circular": center = params.get("center") radius = params.get("radius") @@ -803,14 +803,14 @@ class ComponentCommands: angle_step = params.get("angleStep") rotation_offset = params.get("rotationOffset", 0) layer = params.get("layer", "F.Cu") - + if not center or not radius or not angle_step: return { "success": False, "message": "Missing circular parameters", "errorDetails": "For circular pattern, center, radius, and angleStep are required" } - + placed_components = self._place_circular_array( component_id, center, @@ -823,7 +823,7 @@ class ComponentCommands: rotation_offset, layer ) - + else: return { "success": False, @@ -844,7 +844,7 @@ class ComponentCommands: "message": "Failed to place component array", "errorDetails": str(e) } - + def align_components(self, params: Dict[str, Any]) -> Dict[str, Any]: """Align multiple components along a line or distribute them evenly""" try: @@ -859,14 +859,14 @@ class ComponentCommands: alignment = params.get("alignment", "horizontal") # horizontal, vertical, or edge distribution = params.get("distribution", "none") # none, equal, or spacing spacing = params.get("spacing") - + if not references or len(references) < 2: return { "success": False, "message": "Missing references", "errorDetails": "At least two component references are required" } - + # Find all referenced components components = [] for ref in references: @@ -878,7 +878,7 @@ class ComponentCommands: "errorDetails": f"Could not find component: {ref}" } components.append(module) - + # Perform alignment based on selected option if alignment == "horizontal": self._align_components_horizontally(components, distribution, spacing) @@ -929,7 +929,7 @@ class ComponentCommands: "message": "Failed to align components", "errorDetails": str(e) } - + def duplicate_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """Duplicate an existing component""" try: @@ -944,14 +944,14 @@ class ComponentCommands: new_reference = params.get("newReference") position = params.get("position") rotation = params.get("rotation") - + if not reference or not new_reference: return { "success": False, "message": "Missing parameters", "errorDetails": "reference and newReference are required" } - + # Find the source component source = self.board.FindFootprintByReference(reference) if not source: @@ -960,7 +960,7 @@ class ComponentCommands: "message": "Component not found", "errorDetails": f"Could not find component: {reference}" } - + # Check if new reference already exists if self.board.FindFootprintByReference(new_reference): return { @@ -968,7 +968,7 @@ class ComponentCommands: "message": "Reference already exists", "errorDetails": f"A component with reference {new_reference} already exists" } - + # Create new footprint with the same properties new_module = pcbnew.FOOTPRINT(self.board) # For KiCAD 9.x compatibility, use SetFPID instead of SetFootprintName @@ -976,13 +976,13 @@ class ComponentCommands: new_module.SetValue(source.GetValue()) new_module.SetReference(new_reference) new_module.SetLayer(source.GetLayer()) - + # Copy pads and other items for pad in source.Pads(): new_pad = pcbnew.PAD(new_module) new_pad.Copy(pad) new_module.Add(new_pad) - + # Set position if provided, otherwise use offset from original if position: scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 @@ -993,17 +993,17 @@ class ComponentCommands: # Offset by 5mm source_pos = source.GetPosition() new_module.SetPosition(pcbnew.VECTOR2I(source_pos.x + 5000000, source_pos.y)) - + # Set rotation if provided, otherwise use same as original if rotation is not None: rotation_angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T) new_module.SetOrientation(rotation_angle) else: new_module.SetOrientation(source.GetOrientation()) - + # Add to board self.board.Add(new_module) - + # Get final position in mm pos = new_module.GetPosition() @@ -1031,32 +1031,32 @@ class ComponentCommands: "message": "Failed to duplicate component", "errorDetails": str(e) } - - def _place_grid_array(self, component_id: str, start_position: 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 = [] - + # Convert spacing to nm unit = start_position.get("unit", "mm") scale = 1000000 if unit == "mm" else 25400000 # mm or inch to nm spacing_x_nm = int(spacing_x * scale) spacing_y_nm = int(spacing_y * scale) - + # Get layer ID layer_id = self.board.GetLayerID(layer) - + for row in range(rows): for col in range(columns): # Calculate position x = start_position["x"] + (col * spacing_x) y = start_position["y"] + (row * spacing_y) - + # Generate reference index = row * columns + col + 1 component_reference = f"{reference_prefix}{index}" - + # Place component result = self.place_component({ "componentId": component_id, @@ -1066,37 +1066,37 @@ class ComponentCommands: "rotation": rotation, "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, + + 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 = [] - + # Get unit unit = center.get("unit", "mm") - + for i in range(count): # Calculate angle for this component angle = angle_start + (i * angle_step) angle_rad = math.radians(angle) - + # Calculate position x = center["x"] + (radius * math.cos(angle_rad)) y = center["y"] + (radius * math.sin(angle_rad)) - + # Generate reference component_reference = f"{reference_prefix}{i+1}" - + # Calculate rotation (pointing outward from center) component_rotation = angle + rotation_offset - + # Place component result = self.place_component({ "componentId": component_id, @@ -1106,114 +1106,114 @@ class ComponentCommands: "rotation": component_rotation, "layer": layer }) - + if result["success"]: placed.append(result["component"]) - + return placed - - def _align_components_horizontally(self, components: List[pcbnew.FOOTPRINT], + + 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 - + # Find the average Y coordinate y_sum = sum(module.GetPosition().y for module in components) y_avg = y_sum // len(components) - + # Sort components by X position components.sort(key=lambda m: m.GetPosition().x) - + # Set Y coordinate for all components for module in components: pos = module.GetPosition() module.SetPosition(pcbnew.VECTOR2I(pos.x, y_avg)) - + # Handle distribution if requested if distribution == "equal" and len(components) > 1: # Get leftmost and rightmost X coordinates x_min = components[0].GetPosition().x x_max = components[-1].GetPosition().x - + # Calculate equal spacing total_space = x_max - x_min spacing_nm = total_space // (len(components) - 1) - + # Set X positions with equal spacing for i in range(1, len(components) - 1): pos = components[i].GetPosition() new_x = x_min + (i * spacing_nm) components[i].SetPosition(pcbnew.VECTOR2I(new_x, pos.y)) - + elif distribution == "spacing" and spacing is not None: # Convert spacing to nanometers spacing_nm = int(spacing * 1000000) # assuming mm - + # Set X positions with the specified spacing x_current = components[0].GetPosition().x for i in range(1, len(components)): pos = components[i].GetPosition() x_current += spacing_nm components[i].SetPosition(pcbnew.VECTOR2I(x_current, pos.y)) - - def _align_components_vertically(self, components: List[pcbnew.FOOTPRINT], + + 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 - + # Find the average X coordinate x_sum = sum(module.GetPosition().x for module in components) x_avg = x_sum // len(components) - + # Sort components by Y position components.sort(key=lambda m: m.GetPosition().y) - + # Set X coordinate for all components for module in components: pos = module.GetPosition() module.SetPosition(pcbnew.VECTOR2I(x_avg, pos.y)) - + # Handle distribution if requested if distribution == "equal" and len(components) > 1: # Get topmost and bottommost Y coordinates y_min = components[0].GetPosition().y y_max = components[-1].GetPosition().y - + # Calculate equal spacing total_space = y_max - y_min spacing_nm = total_space // (len(components) - 1) - + # Set Y positions with equal spacing for i in range(1, len(components) - 1): pos = components[i].GetPosition() new_y = y_min + (i * spacing_nm) components[i].SetPosition(pcbnew.VECTOR2I(pos.x, new_y)) - + elif distribution == "spacing" and spacing is not None: # Convert spacing to nanometers spacing_nm = int(spacing * 1000000) # assuming mm - + # Set Y positions with the specified spacing y_current = components[0].GetPosition().y for i in range(1, len(components)): pos = components[i].GetPosition() y_current += spacing_nm components[i].SetPosition(pcbnew.VECTOR2I(pos.x, y_current)) - + def _align_components_to_edge(self, components: List[pcbnew.FOOTPRINT], edge: str) -> None: """Align components to the specified edge of the board""" if not components: return - + # Get board bounds board_box = self.board.GetBoardEdgesBoundingBox() left = board_box.GetLeft() right = board_box.GetRight() top = board_box.GetTop() bottom = board_box.GetBottom() - + # Align based on specified edge if edge == "left": for module in components: diff --git a/python/commands/library.py b/python/commands/library.py index 927899a..c1d1582 100644 --- a/python/commands/library.py +++ b/python/commands/library.py @@ -522,7 +522,7 @@ class LibraryCommands: if path == library_path: library_nickname = nick break - + # Minimal info — always returned even if the parser fails info: Dict = { "library": library_nickname, diff --git a/python/commands/library_schematic.py b/python/commands/library_schematic.py index 66fb5d5..45c2ae9 100644 --- a/python/commands/library_schematic.py +++ b/python/commands/library_schematic.py @@ -31,7 +31,7 @@ class LibraryManager: # 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 ''}") - + # Return both full paths and library names return {"paths": libraries, "names": library_names} @@ -43,7 +43,7 @@ class LibraryManager: # without loading each one. We might need to implement this using KiCAD's Python API # directly, or by using a different approach. # For now, this is a placeholder implementation. - + # 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 @@ -73,23 +73,23 @@ class LibraryManager: # 1. Getting a list of all libraries using list_available_libraries # 2. For each library, getting a list of all symbols # 3. Filtering symbols based on the query - + # For now, this is a placeholder implementation libraries = LibraryManager.list_available_libraries(search_paths) - + results = [] print(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}") return [] - + @staticmethod def get_default_symbol_for_component_type(component_type, search_paths=None): """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 - + # Define common mappings from component type to library/symbol common_mappings = { "resistor": {"library": "Device", "symbol": "R"}, @@ -103,19 +103,19 @@ class LibraryManager: "microcontroller": {"library": "MCU_Module", "symbol": "Arduino_UNO_R3"}, # Add more common components as needed } - + # Normalize input to lowercase component_type_lower = component_type.lower() - + # Try direct match first if component_type_lower in common_mappings: return common_mappings[component_type_lower] - + # Try partial matches for key, value in common_mappings.items(): if component_type_lower in key or key in component_type_lower: return value - + # Default fallback return {"library": "Device", "symbol": "R"} @@ -127,15 +127,15 @@ if __name__ == '__main__': first_lib = libraries["paths"][0] lib_name = libraries["names"][0] print(f"Testing with first library: {lib_name} ({first_lib})") - + # List symbols in the first library symbols = LibraryManager.list_library_symbols(first_lib) # This will report that it requires advanced implementation - + # Get default symbol for a component type resistor_sym = LibraryManager.get_default_symbol_for_component_type("resistor") print(f"Default symbol for resistor: {resistor_sym['library']}/{resistor_sym['symbol']}") - + # Try a partial match cap_sym = LibraryManager.get_default_symbol_for_component_type("cap") print(f"Default symbol for 'cap': {cap_sym['library']}/{cap_sym['symbol']}") diff --git a/src/config.ts b/src/config.ts index 7148515..38e6fb6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -36,7 +36,7 @@ export type Config = z.infer; /** * Load configuration from file - * + * * @param configPath Path to the configuration file (optional) * @returns Loaded and validated configuration */ @@ -44,22 +44,22 @@ export async function loadConfig(configPath?: string): Promise { try { // Determine which config file to load const filePath = configPath || DEFAULT_CONFIG_PATH; - + // Check if file exists if (!existsSync(filePath)) { logger.warn(`Configuration file not found: ${filePath}, using defaults`); return ConfigSchema.parse({}); } - + // Read and parse configuration const configData = await readFile(filePath, 'utf-8'); const config = JSON.parse(configData); - + // Validate configuration return ConfigSchema.parse(config); } catch (error) { logger.error(`Error loading configuration: ${error}`); - + // Return default configuration return ConfigSchema.parse({}); } diff --git a/src/index.ts b/src/index.ts index 6f31054..05bbe7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,27 +21,27 @@ async function main() { // Parse command line arguments const args = process.argv.slice(2); const options = parseCommandLineArgs(args); - + // Load configuration const config = await loadConfig(options.configPath); - + // Path to the Python script that interfaces with KiCAD const kicadScriptPath = join(dirname(__dirname), 'python', 'kicad_interface.py'); - + // Create the server const server = new KiCADMcpServer( kicadScriptPath, config.logLevel ); - + // Start the server await server.start(); - + // Setup graceful shutdown setupGracefulShutdown(server); - + logger.info('KiCAD MCP server started with STDIO transport'); - + } catch (error) { logger.error(`Failed to start KiCAD MCP server: ${error}`); process.exit(1); @@ -53,14 +53,14 @@ async function main() { */ function parseCommandLineArgs(args: string[]) { let configPath = undefined; - + for (let i = 0; i < args.length; i++) { if (args[i] === '--config' && i + 1 < args.length) { configPath = args[i + 1]; i++; } } - + return { configPath }; } @@ -73,18 +73,18 @@ function setupGracefulShutdown(server: KiCADMcpServer) { logger.info('Received SIGINT signal. Shutting down...'); await shutdownServer(server); }); - + process.on('SIGTERM', async () => { logger.info('Received SIGTERM signal. Shutting down...'); await shutdownServer(server); }); - + // Handle uncaught exceptions process.on('uncaughtException', async (error) => { logger.error(`Uncaught exception: ${error}`); await shutdownServer(server); }); - + // Handle unhandled promise rejections process.on('unhandledRejection', async (reason) => { logger.error(`Unhandled promise rejection: ${reason}`); diff --git a/src/kicad-server.ts b/src/kicad-server.ts index 348d1c5..dd633c4 100644 --- a/src/kicad-server.ts +++ b/src/kicad-server.ts @@ -26,12 +26,12 @@ class KiCADServer { // Set absolute path to the Python KiCAD interface script // Using a hardcoded path to avoid cwd() issues when running from Cline this.kicadScriptPath = 'c:/repo/KiCAD-MCP/python/kicad_interface.py'; - + // Check if script exists if (!existsSync(this.kicadScriptPath)) { throw new Error(`KiCAD interface script not found: ${this.kicadScriptPath}`); } - + // Initialize the server this.server = new Server( { @@ -46,7 +46,7 @@ class KiCADServer { } } ); - + // Initialize handler with direct pass-through to Python KiCAD interface // We don't register TypeScript tools since we'll handle everything in Python @@ -96,7 +96,7 @@ class KiCADServer { properties: {} } }, - + // Board tools { name: 'set_board_size', @@ -129,7 +129,7 @@ class KiCADServer { } } }, - + // Component tools { name: 'place_component', @@ -147,7 +147,7 @@ class KiCADServer { required: ['componentId', 'position'] } }, - + // Routing tools { name: 'add_net', @@ -176,7 +176,7 @@ class KiCADServer { required: ['start', 'end'] } }, - + // Schematic tools { name: 'create_schematic', @@ -209,8 +209,8 @@ class KiCADServer { type: 'object', properties: { schematicPath: { type: 'string', description: 'Path to the schematic file' }, - component: { - type: 'object', + component: { + type: 'object', description: 'Component definition', properties: { type: { type: 'string', description: 'Component type (e.g., R, C, LED)' }, @@ -235,15 +235,15 @@ class KiCADServer { type: 'object', properties: { schematicPath: { type: 'string', description: 'Path to the schematic file' }, - startPoint: { - type: 'array', + startPoint: { + type: 'array', description: 'Starting point coordinates [x, y]', items: { type: 'number' }, minItems: 2, maxItems: 2 }, - endPoint: { - type: 'array', + endPoint: { + type: 'array', description: 'Ending point coordinates [x, y]', items: { type: 'number' }, minItems: 2, @@ -259,8 +259,8 @@ class KiCADServer { inputSchema: { type: 'object', properties: { - searchPaths: { - type: 'array', + searchPaths: { + type: 'array', description: 'Optional search paths for libraries', items: { type: 'string' } } @@ -286,7 +286,7 @@ class KiCADServer { this.server.setRequestHandler(CallToolRequestSchema, async (request: any) => { const toolName = request.params.name; const args = request.params.arguments || {}; - + // Pass all commands directly to KiCAD Python interface try { return await this.callKicadScript(toolName, args); @@ -300,11 +300,11 @@ class KiCADServer { async start() { try { console.error('Starting KiCAD MCP server...'); - + // Start the Python process for KiCAD scripting console.error(`Starting Python process with script: ${this.kicadScriptPath}`); const pythonExe = 'C:\\Program Files\\KiCad\\9.0\\bin\\python.exe'; - + console.error(`Using Python executable: ${pythonExe}`); this.pythonProcess = spawn(pythonExe, [this.kicadScriptPath], { stdio: ['pipe', 'pipe', 'pipe'], @@ -313,30 +313,30 @@ class KiCADServer { PYTHONPATH: 'C:/Program Files/KiCad/9.0/lib/python3/dist-packages' } }); - + // Listen for process exit this.pythonProcess.on('exit', (code, signal) => { console.error(`Python process exited with code ${code} and signal ${signal}`); this.pythonProcess = null; }); - + // Listen for process errors this.pythonProcess.on('error', (err) => { console.error(`Python process error: ${err.message}`); }); - + // Set up error logging for stderr if (this.pythonProcess.stderr) { this.pythonProcess.stderr.on('data', (data: Buffer) => { console.error(`Python stderr: ${data.toString()}`); }); } - + // Connect to transport const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('KiCAD MCP server running'); - + // Keep the process running process.on('SIGINT', () => { if (this.pythonProcess) { @@ -345,7 +345,7 @@ class KiCADServer { this.server.close().catch(console.error); process.exit(0); }); - + } catch (error: unknown) { if (error instanceof Error) { console.error('Failed to start MCP server:', error.message); @@ -364,73 +364,73 @@ class KiCADServer { reject(new Error("Python process for KiCAD scripting is not running")); return; } - + // Add request to queue this.requestQueue.push({ request: { command, params }, resolve, reject }); - + // Process the queue if not already processing if (!this.processingRequest) { this.processNextRequest(); } }); } - + private processNextRequest(): void { // If no more requests or already processing, return if (this.requestQueue.length === 0 || this.processingRequest) { return; } - + // Set processing flag this.processingRequest = true; - + // Get the next request const { request, resolve, reject } = this.requestQueue.shift()!; - + try { console.error(`Processing KiCAD command: ${request.command}`); - + // Format the command and parameters as JSON const requestStr = JSON.stringify(request); - + // Set up response handling let responseData = ''; - + // Clear any previous listeners if (this.pythonProcess?.stdout) { this.pythonProcess.stdout.removeAllListeners('data'); } - + // Set up new listeners if (this.pythonProcess?.stdout) { this.pythonProcess.stdout.on('data', (data: Buffer) => { const chunk = data.toString(); console.error(`Received data chunk: ${chunk.length} bytes`); responseData += chunk; - + // Check if we have a complete response try { // Try to parse the response as JSON const result = JSON.parse(responseData); - + // If we get here, we have a valid JSON response console.error(`Completed KiCAD command: ${request.command} with result: ${JSON.stringify(result)}`); - + // Reset processing flag this.processingRequest = false; - + // Process next request if any setTimeout(() => this.processNextRequest(), 0); - + // Clear listeners if (this.pythonProcess?.stdout) { this.pythonProcess.stdout.removeAllListeners('data'); } - + // Resolve with the expected MCP tool response format if (result.success) { resolve({ @@ -457,38 +457,38 @@ class KiCADServer { } }); } - + // Set a timeout const timeout = setTimeout(() => { console.error(`Command timeout: ${request.command}`); - + // Clear listeners if (this.pythonProcess?.stdout) { this.pythonProcess.stdout.removeAllListeners('data'); } - + // Reset processing flag this.processingRequest = false; - + // Process next request setTimeout(() => this.processNextRequest(), 0); - + // Reject the promise reject(new Error(`Command timeout: ${request.command}`)); }, 30000); // 30 seconds timeout - + // Write the request to the Python process console.error(`Sending request: ${requestStr}`); this.pythonProcess?.stdin?.write(requestStr + '\n'); } catch (error) { console.error(`Error processing request: ${error}`); - + // Reset processing flag this.processingRequest = false; - + // Process next request setTimeout(() => this.processNextRequest(), 0); - + // Reject the promise reject(error); } diff --git a/src/logger.ts b/src/logger.ts index f97e7c1..cb1cef7 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -18,7 +18,7 @@ const DEFAULT_LOG_DIR = join(os.homedir(), '.kicad-mcp', 'logs'); class Logger { private logLevel: LogLevel = 'info'; private logDir: string = DEFAULT_LOG_DIR; - + /** * Set the log level * @param level Log level to set @@ -26,20 +26,20 @@ class Logger { setLogLevel(level: LogLevel): void { this.logLevel = level; } - + /** * Set the log directory * @param dir Directory to store log files */ setLogDir(dir: string): void { this.logDir = dir; - + // Ensure log directory exists if (!existsSync(this.logDir)) { mkdirSync(this.logDir, { recursive: true }); } } - + /** * Log an error message * @param message Message to log @@ -47,7 +47,7 @@ class Logger { error(message: string): void { this.log('error', message); } - + /** * Log a warning message * @param message Message to log @@ -57,7 +57,7 @@ class Logger { this.log('warn', message); } } - + /** * Log an info message * @param message Message to log @@ -67,7 +67,7 @@ class Logger { this.log('info', message); } } - + /** * Log a debug message * @param message Message to log @@ -77,7 +77,7 @@ class Logger { this.log('debug', message); } } - + /** * Log a message with the specified level * @param level Log level @@ -93,14 +93,14 @@ class Logger { // Log to console.error (stderr) only - stdout is reserved for MCP protocol // All log levels go to stderr to avoid corrupting STDIO MCP transport console.error(formattedMessage); - + // Log to file try { // Ensure log directory exists if (!existsSync(this.logDir)) { mkdirSync(this.logDir, { recursive: true }); } - + const logFile = join(this.logDir, `kicad-mcp-${new Date().toISOString().split('T')[0]}.log`); appendFileSync(logFile, formattedMessage + '\n'); } catch (error) { diff --git a/src/prompts/component.ts b/src/prompts/component.ts index 7798bb2..4379609 100644 --- a/src/prompts/component.ts +++ b/src/prompts/component.ts @@ -1,6 +1,6 @@ /** * Component prompts for KiCAD MCP server - * + * * These prompts guide the LLM in providing assistance with component-related tasks * in KiCAD PCB design. */ @@ -11,7 +11,7 @@ import { logger } from '../logger.js'; /** * Register component prompts with the MCP server - * + * * @param server MCP server instance */ export function registerComponentPrompts(server: McpServer): void { diff --git a/src/prompts/design.ts b/src/prompts/design.ts index cc6cc7b..5c2f5cb 100644 --- a/src/prompts/design.ts +++ b/src/prompts/design.ts @@ -1,6 +1,6 @@ /** * Design prompts for KiCAD MCP server - * + * * These prompts guide the LLM in providing assistance with general PCB design tasks * in KiCAD. */ @@ -11,7 +11,7 @@ import { logger } from '../logger.js'; /** * Register design prompts with the MCP server - * + * * @param server MCP server instance */ export function registerDesignPrompts(server: McpServer): void { diff --git a/src/prompts/routing.ts b/src/prompts/routing.ts index b4e949b..cd8eb92 100644 --- a/src/prompts/routing.ts +++ b/src/prompts/routing.ts @@ -1,6 +1,6 @@ /** * Routing prompts for KiCAD MCP server - * + * * These prompts guide the LLM in providing assistance with routing-related tasks * in KiCAD PCB design. */ @@ -11,7 +11,7 @@ import { logger } from '../logger.js'; /** * Register routing prompts with the MCP server - * + * * @param server MCP server instance */ export function registerRoutingPrompts(server: McpServer): void { diff --git a/src/resources/board.ts b/src/resources/board.ts index 8b0bf1b..ce5d1f3 100644 --- a/src/resources/board.ts +++ b/src/resources/board.ts @@ -1,6 +1,6 @@ /** * Board resources for KiCAD MCP server - * + * * These resources provide information about the PCB board * to the LLM, enabling better context-aware assistance. */ @@ -15,7 +15,7 @@ type CommandFunction = (command: string, params: Record) => Pro /** * Register board resources with the MCP server - * + * * @param server MCP server instance * @param callKicadScript Function to call KiCAD script commands */ @@ -31,7 +31,7 @@ export function registerBoardResources(server: McpServer, callKicadScript: Comma async (uri) => { logger.debug('Retrieving board information'); const result = await callKicadScript("get_board_info", {}); - + if (!result.success) { logger.error(`Failed to retrieve board information: ${result.errorDetails}`); return { @@ -45,7 +45,7 @@ export function registerBoardResources(server: McpServer, callKicadScript: Comma }] }; } - + logger.debug('Successfully retrieved board information'); return { contents: [{ @@ -66,7 +66,7 @@ export function registerBoardResources(server: McpServer, callKicadScript: Comma async (uri) => { logger.debug('Retrieving layer list'); const result = await callKicadScript("get_layer_list", {}); - + if (!result.success) { logger.error(`Failed to retrieve layer list: ${result.errorDetails}`); return { @@ -80,7 +80,7 @@ export function registerBoardResources(server: McpServer, callKicadScript: Comma }] }; } - + logger.debug(`Successfully retrieved ${result.layers?.length || 0} layers`); return { contents: [{ @@ -107,10 +107,10 @@ export function registerBoardResources(server: McpServer, callKicadScript: Comma }), async (uri, params) => { const unit = params.unit || 'mm'; - + logger.debug(`Retrieving board extents in ${unit}`); const result = await callKicadScript("get_board_extents", { unit }); - + if (!result.success) { logger.error(`Failed to retrieve board extents: ${result.errorDetails}`); return { @@ -124,7 +124,7 @@ export function registerBoardResources(server: McpServer, callKicadScript: Comma }] }; } - + logger.debug('Successfully retrieved board extents'); return { contents: [{ @@ -156,7 +156,7 @@ export function registerBoardResources(server: McpServer, callKicadScript: Comma const height = params.height ? parseInt(params.height as string) : undefined; // Handle layers parameter - could be string or array const layers = typeof params.layers === 'string' ? params.layers.split(',') : params.layers; - + logger.debug('Retrieving 2D board view'); const result = await callKicadScript("get_board_2d_view", { layers, @@ -164,7 +164,7 @@ export function registerBoardResources(server: McpServer, callKicadScript: Comma height, format }); - + if (!result.success) { logger.error(`Failed to retrieve 2D board view: ${result.errorDetails}`); return { @@ -178,9 +178,9 @@ export function registerBoardResources(server: McpServer, callKicadScript: Comma }] }; } - + logger.debug('Successfully retrieved 2D board view'); - + if (format === 'svg') { return { contents: [{ @@ -219,14 +219,14 @@ export function registerBoardResources(server: McpServer, callKicadScript: Comma const angle = params.angle || 'isometric'; const width = params.width ? parseInt(params.width as string) : undefined; const height = params.height ? parseInt(params.height as string) : undefined; - + logger.debug(`Retrieving 3D board view from ${angle} angle`); const result = await callKicadScript("get_board_3d_view", { width, height, angle }); - + if (!result.success) { logger.error(`Failed to retrieve 3D board view: ${result.errorDetails}`); return { @@ -240,7 +240,7 @@ export function registerBoardResources(server: McpServer, callKicadScript: Comma }] }; } - + logger.debug('Successfully retrieved 3D board view'); return { contents: [{ @@ -260,7 +260,7 @@ export function registerBoardResources(server: McpServer, callKicadScript: Comma "kicad://board/statistics", async (uri) => { logger.debug('Generating board statistics'); - + // Get board info const boardResult = await callKicadScript("get_board_info", {}); if (!boardResult.success) { @@ -276,7 +276,7 @@ export function registerBoardResources(server: McpServer, callKicadScript: Comma }] }; } - + // Get component list const componentsResult = await callKicadScript("get_component_list", {}); if (!componentsResult.success) { @@ -292,7 +292,7 @@ export function registerBoardResources(server: McpServer, callKicadScript: Comma }] }; } - + // Get nets list const netsResult = await callKicadScript("get_nets_list", {}); if (!netsResult.success) { @@ -308,7 +308,7 @@ export function registerBoardResources(server: McpServer, callKicadScript: Comma }] }; } - + // Combine all information into statistics const statistics = { board: { @@ -324,7 +324,7 @@ export function registerBoardResources(server: McpServer, callKicadScript: Comma count: netsResult.nets?.length || 0 } }; - + logger.debug('Successfully generated board statistics'); return { contents: [{ @@ -344,11 +344,11 @@ export function registerBoardResources(server: McpServer, callKicadScript: Comma */ function countComponentTypes(components: any[]): Record { const typeCounts: Record = {}; - + for (const component of components) { const type = component.value?.split(' ')[0] || 'Unknown'; typeCounts[type] = (typeCounts[type] || 0) + 1; } - + return typeCounts; } diff --git a/src/resources/component.ts b/src/resources/component.ts index 7e7c789..947c4a1 100644 --- a/src/resources/component.ts +++ b/src/resources/component.ts @@ -1,6 +1,6 @@ /** * Component resources for KiCAD MCP server - * + * * These resources provide information about components on the PCB * to the LLM, enabling better context-aware assistance. */ @@ -13,7 +13,7 @@ type CommandFunction = (command: string, params: Record) => Pro /** * Register component resources with the MCP server - * + * * @param server MCP server instance * @param callKicadScript Function to call KiCAD script commands */ @@ -29,7 +29,7 @@ export function registerComponentResources(server: McpServer, callKicadScript: C async (uri) => { logger.debug('Retrieving component list'); const result = await callKicadScript("get_component_list", {}); - + if (!result.success) { logger.error(`Failed to retrieve component list: ${result.errorDetails}`); return { @@ -43,7 +43,7 @@ export function registerComponentResources(server: McpServer, callKicadScript: C }] }; } - + logger.debug(`Successfully retrieved ${result.components?.length || 0} components`); return { contents: [{ @@ -69,7 +69,7 @@ export function registerComponentResources(server: McpServer, callKicadScript: C const result = await callKicadScript("get_component_properties", { reference }); - + if (!result.success) { logger.error(`Failed to retrieve component details: ${result.errorDetails}`); return { @@ -83,7 +83,7 @@ export function registerComponentResources(server: McpServer, callKicadScript: C }] }; } - + logger.debug(`Successfully retrieved details for component: ${reference}`); return { contents: [{ @@ -109,7 +109,7 @@ export function registerComponentResources(server: McpServer, callKicadScript: C const result = await callKicadScript("get_component_connections", { reference }); - + if (!result.success) { logger.error(`Failed to retrieve component connections: ${result.errorDetails}`); return { @@ -123,7 +123,7 @@ export function registerComponentResources(server: McpServer, callKicadScript: C }] }; } - + logger.debug(`Successfully retrieved connections for component: ${reference}`); return { contents: [{ @@ -144,7 +144,7 @@ export function registerComponentResources(server: McpServer, callKicadScript: C async (uri) => { logger.debug('Retrieving component placement information'); const result = await callKicadScript("get_component_placement", {}); - + if (!result.success) { logger.error(`Failed to retrieve component placement: ${result.errorDetails}`); return { @@ -158,7 +158,7 @@ export function registerComponentResources(server: McpServer, callKicadScript: C }] }; } - + logger.debug('Successfully retrieved component placement information'); return { contents: [{ @@ -179,7 +179,7 @@ export function registerComponentResources(server: McpServer, callKicadScript: C async (uri) => { logger.debug('Retrieving component groups'); const result = await callKicadScript("get_component_groups", {}); - + if (!result.success) { logger.error(`Failed to retrieve component groups: ${result.errorDetails}`); return { @@ -193,7 +193,7 @@ export function registerComponentResources(server: McpServer, callKicadScript: C }] }; } - + logger.debug(`Successfully retrieved ${result.groups?.length || 0} component groups`); return { contents: [{ @@ -219,7 +219,7 @@ export function registerComponentResources(server: McpServer, callKicadScript: C const result = await callKicadScript("get_component_visualization", { reference }); - + if (!result.success) { logger.error(`Failed to generate component visualization: ${result.errorDetails}`); return { @@ -233,7 +233,7 @@ export function registerComponentResources(server: McpServer, callKicadScript: C }] }; } - + logger.debug(`Successfully generated visualization for component: ${reference}`); return { contents: [{ diff --git a/src/resources/index.ts b/src/resources/index.ts index e443af9..c025fc3 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -1,6 +1,6 @@ /** * Resources index for KiCAD MCP server - * + * * Exports all resource registration functions */ diff --git a/src/resources/library.ts b/src/resources/library.ts index 0a101ab..1b7acde 100644 --- a/src/resources/library.ts +++ b/src/resources/library.ts @@ -1,6 +1,6 @@ /** * Library resources for KiCAD MCP server - * + * * These resources provide information about KiCAD component libraries * to the LLM, enabling better context-aware assistance. */ @@ -14,7 +14,7 @@ type CommandFunction = (command: string, params: Record) => Pro /** * Register library resources with the MCP server - * + * * @param server MCP server instance * @param callKicadScript Function to call KiCAD script commands */ @@ -39,7 +39,7 @@ export function registerLibraryResources(server: McpServer, callKicadScript: Com const limit = Number(params.limit) || undefined; logger.debug(`Retrieving component library${filter ? ` with filter: ${filter}` : ''}${library ? ` from library: ${library}` : ''}`); - + const result = await callKicadScript("get_component_library", { filter, library, @@ -117,7 +117,7 @@ export function registerLibraryResources(server: McpServer, callKicadScript: Com async (uri, params) => { const { componentId, library } = params; logger.debug(`Retrieving details for component: ${componentId}${library ? ` from library: ${library}` : ''}`); - + const result = await callKicadScript("get_component_details", { componentId, library @@ -159,7 +159,7 @@ export function registerLibraryResources(server: McpServer, callKicadScript: Com async (uri, params) => { const { componentId, footprint } = params; logger.debug(`Retrieving footprint for component: ${componentId}${footprint ? ` (${footprint})` : ''}`); - + const result = await callKicadScript("get_component_footprint", { componentId, footprint @@ -201,7 +201,7 @@ export function registerLibraryResources(server: McpServer, callKicadScript: Com async (uri, params) => { const { componentId } = params; logger.debug(`Retrieving symbol for component: ${componentId}`); - + const result = await callKicadScript("get_component_symbol", { componentId }); @@ -255,7 +255,7 @@ export function registerLibraryResources(server: McpServer, callKicadScript: Com async (uri, params) => { const { componentId, footprint } = params; logger.debug(`Retrieving 3D model for component: ${componentId}${footprint ? ` (${footprint})` : ''}`); - + const result = await callKicadScript("get_component_3d_model", { componentId, footprint diff --git a/src/resources/project.ts b/src/resources/project.ts index a2fbde4..e2226ce 100644 --- a/src/resources/project.ts +++ b/src/resources/project.ts @@ -1,6 +1,6 @@ /** * Project resources for KiCAD MCP server - * + * * These resources provide information about the KiCAD project * to the LLM, enabling better context-aware assistance. */ @@ -13,7 +13,7 @@ type CommandFunction = (command: string, params: Record) => Pro /** * Register project resources with the MCP server - * + * * @param server MCP server instance * @param callKicadScript Function to call KiCAD script commands */ @@ -29,7 +29,7 @@ export function registerProjectResources(server: McpServer, callKicadScript: Com async (uri) => { logger.debug('Retrieving project information'); const result = await callKicadScript("get_project_info", {}); - + if (!result.success) { logger.error(`Failed to retrieve project information: ${result.errorDetails}`); return { @@ -43,7 +43,7 @@ export function registerProjectResources(server: McpServer, callKicadScript: Com }] }; } - + logger.debug('Successfully retrieved project information'); return { contents: [{ @@ -64,7 +64,7 @@ export function registerProjectResources(server: McpServer, callKicadScript: Com async (uri) => { logger.debug('Retrieving project properties'); const result = await callKicadScript("get_project_properties", {}); - + if (!result.success) { logger.error(`Failed to retrieve project properties: ${result.errorDetails}`); return { @@ -78,7 +78,7 @@ export function registerProjectResources(server: McpServer, callKicadScript: Com }] }; } - + logger.debug('Successfully retrieved project properties'); return { contents: [{ @@ -99,7 +99,7 @@ export function registerProjectResources(server: McpServer, callKicadScript: Com async (uri) => { logger.debug('Retrieving project files'); const result = await callKicadScript("get_project_files", {}); - + if (!result.success) { logger.error(`Failed to retrieve project files: ${result.errorDetails}`); return { @@ -113,7 +113,7 @@ export function registerProjectResources(server: McpServer, callKicadScript: Com }] }; } - + logger.debug(`Successfully retrieved ${result.files?.length || 0} project files`); return { contents: [{ @@ -134,7 +134,7 @@ export function registerProjectResources(server: McpServer, callKicadScript: Com async (uri) => { logger.debug('Retrieving project status'); const result = await callKicadScript("get_project_status", {}); - + if (!result.success) { logger.error(`Failed to retrieve project status: ${result.errorDetails}`); return { @@ -148,7 +148,7 @@ export function registerProjectResources(server: McpServer, callKicadScript: Com }] }; } - + logger.debug('Successfully retrieved project status'); return { contents: [{ @@ -168,7 +168,7 @@ export function registerProjectResources(server: McpServer, callKicadScript: Com "kicad://project/summary", async (uri) => { logger.debug('Generating project summary'); - + // Get project info const infoResult = await callKicadScript("get_project_info", {}); if (!infoResult.success) { @@ -184,7 +184,7 @@ export function registerProjectResources(server: McpServer, callKicadScript: Com }] }; } - + // Get board info const boardResult = await callKicadScript("get_board_info", {}); if (!boardResult.success) { @@ -200,7 +200,7 @@ export function registerProjectResources(server: McpServer, callKicadScript: Com }] }; } - + // Get component list const componentsResult = await callKicadScript("get_component_list", {}); if (!componentsResult.success) { @@ -216,7 +216,7 @@ export function registerProjectResources(server: McpServer, callKicadScript: Com }] }; } - + // Combine all information into a summary const summary = { project: infoResult.project, @@ -230,7 +230,7 @@ export function registerProjectResources(server: McpServer, callKicadScript: Com types: countComponentTypes(componentsResult.components || []) } }; - + logger.debug('Successfully generated project summary'); return { contents: [{ @@ -250,11 +250,11 @@ export function registerProjectResources(server: McpServer, callKicadScript: Com */ function countComponentTypes(components: any[]): Record { const typeCounts: Record = {}; - + for (const component of components) { const type = component.value?.split(' ')[0] || 'Unknown'; typeCounts[type] = (typeCounts[type] || 0) + 1; } - + return typeCounts; } diff --git a/src/tools/board.ts b/src/tools/board.ts index 1fc4ef8..975781d 100644 --- a/src/tools/board.ts +++ b/src/tools/board.ts @@ -1,6 +1,6 @@ /** * Board management tools for KiCAD MCP server - * + * * These tools handle board setup, layer management, and board properties */ @@ -13,13 +13,13 @@ type CommandFunction = (command: string, params: Record) => Pro /** * Register board management tools with the MCP server - * + * * @param server MCP server instance * @param callKicadScript Function to call KiCAD script commands */ export function registerBoardTools(server: McpServer, callKicadScript: CommandFunction): void { logger.info('Registering board management tools'); - + // ------------------------------------------------------ // Set Board Size Tool // ------------------------------------------------------ @@ -37,7 +37,7 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu height, unit }); - + return { content: [{ type: "text", @@ -70,7 +70,7 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu position, number }); - + return { content: [{ type: "text", @@ -91,7 +91,7 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu async ({ layer }) => { logger.debug(`Setting active layer to: ${layer}`); const result = await callKicadScript("set_active_layer", { layer }); - + return { content: [{ type: "text", @@ -110,7 +110,7 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu async () => { logger.debug('Getting board information'); const result = await callKicadScript("get_board_info", {}); - + return { content: [{ type: "text", @@ -129,7 +129,7 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu async () => { logger.debug('Getting layer list'); const result = await callKicadScript("get_layer_list", {}); - + return { content: [{ type: "text", @@ -205,7 +205,7 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu diameter, padDiameter }); - + return { content: [{ type: "text", @@ -244,7 +244,7 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu rotation, style }); - + return { content: [{ type: "text", @@ -284,7 +284,7 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu minWidth, padConnection }); - + return { content: [{ type: "text", @@ -305,7 +305,7 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu async ({ unit }) => { logger.debug('Getting board extents'); const result = await callKicadScript("get_board_extents", { unit }); - + return { content: [{ type: "text", @@ -334,7 +334,7 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu height, format }); - + return { content: [{ type: "text", @@ -382,4 +382,3 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu }, ); } - diff --git a/src/tools/component.txt b/src/tools/component.txt index 9757ea8..c9c322f 100644 --- a/src/tools/component.txt +++ b/src/tools/component.txt @@ -11,16 +11,16 @@ type CommandFunction = (command: string, params: any) => Promise; /** * Register component management tools with the MCP server - * + * * @param server MCP server instance * @param callKicadScript Function to call KiCAD script commands */ export function registerComponentTools(server: McpServer, callKicadScript: CommandFunction): void { logger.info('Registering component management tools'); - + // ------------------------------------------------------ // Place Component Tool // ------------------------------------------------------ server.registerTool({ name: "place_component", - description: "Places a component on the PCB at the specified location", \ No newline at end of file + description: "Places a component on the PCB at the specified location", diff --git a/src/tools/design-rules.ts b/src/tools/design-rules.ts index 034837b..2f028d4 100644 --- a/src/tools/design-rules.ts +++ b/src/tools/design-rules.ts @@ -1,6 +1,6 @@ /** * Design rules tools for KiCAD MCP server - * + * * These tools handle design rule checking and configuration */ @@ -13,13 +13,13 @@ type CommandFunction = (command: string, params: Record) => Pro /** * Register design rule tools with the MCP server - * + * * @param server MCP server instance * @param callKicadScript Function to call KiCAD script commands */ export function registerDesignRuleTools(server: McpServer, callKicadScript: CommandFunction): void { logger.info('Registering design rule tools'); - + // ------------------------------------------------------ // Set Design Rules Tool // ------------------------------------------------------ @@ -44,7 +44,7 @@ export function registerDesignRuleTools(server: McpServer, callKicadScript: Comm async (params) => { logger.debug('Setting design rules'); const result = await callKicadScript("set_design_rules", params); - + return { content: [{ type: "text", @@ -63,7 +63,7 @@ export function registerDesignRuleTools(server: McpServer, callKicadScript: Comm async () => { logger.debug('Getting design rules'); const result = await callKicadScript("get_design_rules", {}); - + return { content: [{ type: "text", @@ -84,7 +84,7 @@ export function registerDesignRuleTools(server: McpServer, callKicadScript: Comm async ({ reportPath }) => { logger.debug('Running DRC check'); const result = await callKicadScript("run_drc", { reportPath }); - + return { content: [{ type: "text", @@ -127,7 +127,7 @@ export function registerDesignRuleTools(server: McpServer, callKicadScript: Comm diff_pair_gap, nets }); - + return { content: [{ type: "text", @@ -152,7 +152,7 @@ export function registerDesignRuleTools(server: McpServer, callKicadScript: Comm net, netClass }); - + return { content: [{ type: "text", @@ -183,7 +183,7 @@ export function registerDesignRuleTools(server: McpServer, callKicadScript: Comm minViaDiameter, minViaDrill }); - + return { content: [{ type: "text", @@ -226,7 +226,7 @@ export function registerDesignRuleTools(server: McpServer, callKicadScript: Comm item1, item2 }); - + return { content: [{ type: "text", @@ -247,7 +247,7 @@ export function registerDesignRuleTools(server: McpServer, callKicadScript: Comm async ({ severity }) => { logger.debug('Getting DRC violations'); const result = await callKicadScript("get_drc_violations", { severity }); - + return { content: [{ type: "text", diff --git a/src/tools/export.ts b/src/tools/export.ts index a179b33..e51beff 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -1,6 +1,6 @@ /** * Export tools for KiCAD MCP server - * + * * These tools handle exporting PCB data to various formats */ @@ -13,13 +13,13 @@ type CommandFunction = (command: string, params: Record) => Pro /** * Register export tools with the MCP server - * + * * @param server MCP server instance * @param callKicadScript Function to call KiCAD script commands */ export function registerExportTools(server: McpServer, callKicadScript: CommandFunction): void { logger.info('Registering export tools'); - + // ------------------------------------------------------ // Export Gerber Tool // ------------------------------------------------------ @@ -43,7 +43,7 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF generateMapFile, useAuxOrigin }); - + return { content: [{ type: "text", @@ -74,7 +74,7 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF frameReference, pageSize }); - + return { content: [{ type: "text", @@ -103,7 +103,7 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF blackAndWhite, includeComponents }); - + return { content: [{ type: "text", @@ -136,7 +136,7 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF includeSolderMask, includeSilkscreen }); - + return { content: [{ type: "text", @@ -165,7 +165,7 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF groupByValue, includeAttributes }); - + return { content: [{ type: "text", @@ -190,7 +190,7 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF outputPath, format }); - + return { content: [{ type: "text", @@ -219,7 +219,7 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF units, side }); - + return { content: [{ type: "text", @@ -246,7 +246,7 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF includeComponents, useRelativePaths }); - + return { content: [{ type: "text", From 75cead0860fde94d3aa73c595a08e98e70b61ff5 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 29 Mar 2026 13:01:08 +0100 Subject: [PATCH 37/74] style: apply Black formatting to all Python files Add [tool.black] config to pyproject.toml and Black hook to .pre-commit-config.yaml (rev 26.3.1), then auto-format all Python source and test files with line-length=100, target-version=py310. Co-Authored-By: Claude Opus 4.6 --- .pre-commit-config.yaml | 7 + pyproject.toml | 4 + python/commands/__init__.py | 12 +- python/commands/board.py | 2 +- python/commands/board/__init__.py | 13 +- python/commands/board/layers.py | 70 ++- python/commands/board/outline.py | 28 +- python/commands/board/size.py | 38 +- python/commands/board/view.py | 159 ++++--- python/commands/component.py | 410 +++++++++--------- python/commands/component_schematic.py | 52 +-- python/commands/connection_schematic.py | 52 +-- python/commands/datasheet_manager.py | 16 +- python/commands/design_rules.py | 32 +- python/commands/dynamic_symbol_loader.py | 59 +-- python/commands/export.py | 21 +- python/commands/footprint.py | 65 ++- python/commands/freerouting.py | 164 +++---- python/commands/jlcpcb.py | 74 ++-- python/commands/jlcpcb_parts.py | 245 +++++------ python/commands/jlcsearch.py | 34 +- python/commands/library.py | 31 +- python/commands/library_schematic.py | 27 +- python/commands/library_symbol.py | 162 +++---- python/commands/pin_locator.py | 60 +-- python/commands/project.py | 12 +- python/commands/routing.py | 109 +++-- python/commands/schematic.py | 19 +- python/commands/schematic_analysis.py | 54 +-- python/commands/svg_import.py | 274 +++++++----- python/commands/symbol_creator.py | 106 +++-- python/commands/wire_connectivity.py | 12 +- python/commands/wire_manager.py | 55 +-- python/kicad_api/__init__.py | 4 +- python/kicad_api/base.py | 12 +- python/kicad_api/factory.py | 37 +- python/kicad_api/ipc_backend.py | 305 ++++++------- python/kicad_api/swig_backend.py | 13 +- python/kicad_interface.py | 275 +++--------- python/parsers/kicad_mod_parser.py | 30 +- python/resources/__init__.py | 2 +- python/resources/resource_definitions.py | 263 ++++++----- python/schemas/__init__.py | 2 +- python/test_ipc_backend.py | 98 ++--- .../tests/test_delete_schematic_component.py | 16 +- python/tests/test_freerouting.py | 267 +++++------- python/tests/test_schematic_analysis.py | 36 +- python/tests/test_schematic_tools.py | 49 +-- python/tests/test_wire_connectivity.py | 12 +- python/tests/test_wire_junction_changes.py | 69 +-- python/utils/kicad_process.py | 226 +++++----- python/utils/platform_helper.py | 45 +- tests/test_platform_helper.py | 2 + 53 files changed, 1847 insertions(+), 2394 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a65cd00..d66144d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,3 +9,10 @@ repos: - id: check-added-large-files args: ['--maxkb=500'] - id: check-merge-conflict + + - repo: https://github.com/psf/black + rev: 26.3.1 + hooks: + - id: black + language_version: python3 + args: [--config=pyproject.toml] diff --git a/pyproject.toml b/pyproject.toml index 71f7950..1bbf96e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,3 +2,7 @@ name = "kicad-mcp-server" version = "2.1.0" requires-python = ">=3.10" + +[tool.black] +line-length = 100 +target-version = ["py310"] diff --git a/python/commands/__init__.py b/python/commands/__init__.py index b645122..6d69317 100644 --- a/python/commands/__init__.py +++ b/python/commands/__init__.py @@ -10,10 +10,10 @@ from .design_rules import DesignRuleCommands from .export import ExportCommands __all__ = [ - 'ProjectCommands', - 'BoardCommands', - 'ComponentCommands', - 'RoutingCommands', - 'DesignRuleCommands', - 'ExportCommands' + "ProjectCommands", + "BoardCommands", + "ComponentCommands", + "RoutingCommands", + "DesignRuleCommands", + "ExportCommands", ] diff --git a/python/commands/board.py b/python/commands/board.py index c8202ee..26109f6 100644 --- a/python/commands/board.py +++ b/python/commands/board.py @@ -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"] diff --git a/python/commands/board/__init__.py b/python/commands/board/__init__.py index 7a14e61..9c23957 100644 --- a/python/commands/board/__init__.py +++ b/python/commands/board/__init__.py @@ -12,7 +12,8 @@ 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""" @@ -75,8 +76,8 @@ class BoardCommands: """Get a 2D image of the PCB""" self.view_commands.board = self.board return self.view_commands.get_board_2d_view(params) - - def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get the bounding box extents of the board""" - self.view_commands.board = self.board - return self.view_commands.get_board_extents(params) + + def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get the bounding box extents of the board""" + self.view_commands.board = self.board + return self.view_commands.get_board_extents(params) diff --git a/python/commands/board/layers.py b/python/commands/board/layers.py index f80f345..0219a5d 100644 --- a/python/commands/board/layers.py +++ b/python/commands/board/layers.py @@ -6,7 +6,8 @@ import pcbnew import logging from typing import Dict, Any, Optional -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") + class BoardLayerCommands: """Handles board layer operations""" @@ -22,7 +23,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 +35,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 +48,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 +60,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 +73,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 +87,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 +95,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 +104,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 +113,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 +121,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 +131,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({ - "name": self.board.GetLayerName(layer_id), - "type": self._get_layer_type_name(self.board.GetLayerType(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 - }) + layers.append( + { + "name": self.board.GetLayerName(layer_id), + "type": self._get_layer_type_name(self.board.GetLayerType(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 +159,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 +169,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") diff --git a/python/commands/board/outline.py b/python/commands/board/outline.py index 52bf289..05fe16d 100644 --- a/python/commands/board/outline.py +++ b/python/commands/board/outline.py @@ -224,9 +224,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 +250,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 +307,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 +366,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 +388,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) diff --git a/python/commands/board/size.py b/python/commands/board/size.py index a4f9e2e..3d9fd4d 100644 --- a/python/commands/board/size.py +++ b/python/commands/board/size.py @@ -6,7 +6,8 @@ import pcbnew import logging from typing import Dict, Any, Optional -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") + class BoardSizeCommands: """Handles board size operations""" @@ -22,7 +23,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 +34,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({ - "shape": "rectangle", - "centerX": width / 2, # Center X - "centerY": height / 2, # Center Y - "width": width, - "height": height, - "unit": unit - }) + result = outline_commands.add_board_outline( + { + "shape": "rectangle", + "centerX": width / 2, # Center X + "centerY": height / 2, # Center Y + "width": width, + "height": height, + "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)} diff --git a/python/commands/board/view.py b/python/commands/board/view.py index 4aa5bfe..9e35610 100644 --- a/python/commands/board/view.py +++ b/python/commands/board/view.py @@ -10,7 +10,8 @@ from PIL import Image import io import base64 -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") + class BoardViewCommands: """Handles board viewing operations""" @@ -26,7 +27,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 +43,24 @@ class BoardViewCommands: layers = [] for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): if self.board.IsLayerEnabled(layer_id): - layers.append({ - "name": self.board.GetLayerName(layer_id), - "type": self._get_layer_type_name(self.board.GetLayerType(layer_id)), - "id": layer_id - }) + layers.append( + { + "name": self.board.GetLayerName(layer_id), + "type": self._get_layer_type_name(self.board.GetLayerType(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 +68,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 +78,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 +125,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 +140,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 +159,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,61 +168,58 @@ 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") - - def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get the bounding box extents of the board""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first" - } - - # Get unit preference (default to mm) - unit = params.get("unit", "mm") - scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch - - # Get board bounding box - board_box = self.board.GetBoardEdgesBoundingBox() - - # Extract bounds in nanometers, then convert - left = board_box.GetLeft() / scale - top = board_box.GetTop() / scale - right = board_box.GetRight() / scale - bottom = board_box.GetBottom() / scale - width = board_box.GetWidth() / scale - height = board_box.GetHeight() / scale - - # Get center point - center_x = board_box.GetCenter().x / scale - center_y = board_box.GetCenter().y / scale - - return { - "success": True, - "extents": { - "left": left, - "top": top, - "right": right, - "bottom": bottom, - "width": width, - "height": height, - "center": { - "x": center_x, - "y": center_y - }, - "unit": unit - } - } - - except Exception as e: - logger.error(f"Error getting board extents: {str(e)}") - return { - "success": False, - "message": "Failed to get board extents", - "errorDetails": str(e) - } + + def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get the bounding box extents of the board""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + # Get unit preference (default to mm) + unit = params.get("unit", "mm") + scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch + + # Get board bounding box + board_box = self.board.GetBoardEdgesBoundingBox() + + # Extract bounds in nanometers, then convert + left = board_box.GetLeft() / scale + top = board_box.GetTop() / scale + right = board_box.GetRight() / scale + bottom = board_box.GetBottom() / scale + width = board_box.GetWidth() / scale + height = board_box.GetHeight() / scale + + # Get center point + center_x = board_box.GetCenter().x / scale + center_y = board_box.GetCenter().y / scale + + return { + "success": True, + "extents": { + "left": left, + "top": top, + "right": right, + "bottom": bottom, + "width": width, + "height": height, + "center": {"x": center_x, "y": center_y}, + "unit": unit, + }, + } + + except Exception as e: + logger.error(f"Error getting board extents: {str(e)}") + return { + "success": False, + "message": "Failed to get board extents", + "errorDetails": str(e), + } diff --git a/python/commands/component.py b/python/commands/component.py index f707f10..e7eb029 100644 --- a/python/commands/component.py +++ b/python/commands/component.py @@ -10,12 +10,15 @@ from typing import Dict, Any, Optional, List, Tuple import base64 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 +30,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 +46,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 +58,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 +82,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 +91,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 +149,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 +160,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,7 +170,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") @@ -182,7 +182,7 @@ class ComponentCommands: return { "success": False, "message": "Missing parameters", - "errorDetails": "reference and position are required" + "errorDetails": "reference and position are required", } # Find the component @@ -191,7 +191,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 @@ -218,23 +218,17 @@ class ComponentCommands: "message": f"Moved component: {reference}", "component": { "reference": reference, - "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()) - } + "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()), + }, } 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""" @@ -243,7 +237,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") @@ -253,7 +247,7 @@ class ComponentCommands: return { "success": False, "message": "Missing parameters", - "errorDetails": "reference and angle are required" + "errorDetails": "reference and angle are required", } # Find the component @@ -262,7 +256,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 @@ -272,10 +266,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: @@ -283,7 +274,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]: @@ -293,7 +284,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") @@ -301,7 +292,7 @@ class ComponentCommands: return { "success": False, "message": "Missing reference", - "errorDetails": "reference parameter is required" + "errorDetails": "reference parameter is required", } # Find the component @@ -310,23 +301,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]: @@ -336,7 +324,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") @@ -348,7 +336,7 @@ class ComponentCommands: return { "success": False, "message": "Missing reference", - "errorDetails": "reference parameter is required" + "errorDetails": "reference parameter is required", } # Find the component @@ -357,7 +345,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 @@ -385,17 +373,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""" @@ -404,7 +388,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") @@ -412,7 +396,7 @@ class ComponentCommands: return { "success": False, "message": "Missing reference", - "errorDetails": "reference parameter is required" + "errorDetails": "reference parameter is required", } # Find the component @@ -421,7 +405,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 @@ -435,19 +419,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: @@ -455,7 +435,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]: @@ -465,7 +445,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 = [] @@ -474,30 +454,25 @@ class ComponentCommands: x_mm = pos.x / 1000000 y_mm = pos.y / 1000000 - components.append({ - "reference": module.GetReference(), - "value": module.GetValue(), - "footprint": module.GetFPIDAsString(), - "position": { - "x": x_mm, - "y": y_mm, - "unit": "mm" - }, - "rotation": module.GetOrientation().AsDegrees(), - "layer": self.board.GetLayerName(module.GetLayer()) - }) + components.append( + { + "reference": module.GetReference(), + "value": module.GetValue(), + "footprint": module.GetFPIDAsString(), + "position": {"x": x_mm, "y": y_mm, "unit": "mm"}, + "rotation": module.GetOrientation().AsDegrees(), + "layer": self.board.GetLayerName(module.GetLayer()), + } + ) - return { - "success": True, - "components": components - } + 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]: @@ -507,7 +482,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 @@ -519,7 +494,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 = [] @@ -539,31 +514,25 @@ class ComponentCommands: if match: pos = module.GetPosition() - matches.append({ - "reference": module.GetReference(), - "value": module.GetValue(), - "footprint": module.GetFPIDAsString(), - "position": { - "x": pos.x / 1000000, - "y": pos.y / 1000000, - "unit": "mm" - }, - "rotation": module.GetOrientation().AsDegrees(), - "layer": self.board.GetLayerName(module.GetLayer()) - }) + matches.append( + { + "reference": module.GetReference(), + "value": module.GetValue(), + "footprint": module.GetFPIDAsString(), + "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 - } + 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]: @@ -573,7 +542,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") @@ -581,7 +550,7 @@ class ComponentCommands: return { "success": False, "message": "Missing reference", - "errorDetails": "reference parameter is required" + "errorDetails": "reference parameter is required", } # Find the component @@ -590,7 +559,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 = [] @@ -606,7 +575,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") @@ -615,29 +584,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({ - "name": pad.GetName(), - "number": pad.GetNumber(), - "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 - }) + pads.append( + { + "name": pad.GetName(), + "number": pad.GetNumber(), + "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 + ), + } + ) # Get component position for reference comp_pos = module.GetPosition() @@ -648,10 +613,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: @@ -659,7 +624,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]: @@ -669,7 +634,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") @@ -679,13 +644,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 @@ -694,7 +659,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 @@ -705,7 +670,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() @@ -715,18 +680,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: @@ -734,7 +691,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]: @@ -744,7 +701,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") @@ -757,7 +714,7 @@ class ComponentCommands: return { "success": False, "message": "Missing parameters", - "errorDetails": "componentId and count are required" + "errorDetails": "componentId and count are required", } if pattern == "grid": @@ -773,14 +730,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( @@ -793,7 +750,7 @@ class ComponentCommands: reference_prefix, value, rotation, - layer + layer, ) elif pattern == "circular": @@ -808,7 +765,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( @@ -821,20 +778,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: @@ -842,7 +799,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]: @@ -852,7 +809,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", []) @@ -864,7 +821,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 @@ -875,7 +832,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) @@ -890,36 +847,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({ - "reference": module.GetReference(), - "position": { - "x": pos.x / 1000000, - "y": pos.y / 1000000, - "unit": "mm" - }, - "rotation": module.GetOrientation().AsDegrees() - }) + aligned_components.append( + { + "reference": module.GetReference(), + "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: @@ -927,7 +882,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]: @@ -937,7 +892,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") @@ -949,7 +904,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 @@ -958,7 +913,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 @@ -966,7 +921,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 @@ -1014,14 +969,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: @@ -1029,12 +980,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 = [] @@ -1058,24 +1019,35 @@ class ComponentCommands: component_reference = f"{reference_prefix}{index}" # 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 - }) + result = self.place_component( + { + "componentId": component_id, + "position": {"x": x, "y": y, "unit": unit}, + "reference": component_reference, + "value": value, + "rotation": rotation, + "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 = [] @@ -1098,22 +1070,25 @@ class ComponentCommands: component_rotation = angle + rotation_offset # 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 - }) + result = self.place_component( + { + "componentId": component_id, + "position": {"x": x, "y": y, "unit": unit}, + "reference": component_reference, + "value": value, + "rotation": component_rotation, + "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 @@ -1157,8 +1132,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 diff --git a/python/commands/component_schematic.py b/python/commands/component_schematic.py index 7be8c4c..23fb4d6 100644 --- a/python/commands/component_schematic.py +++ b/python/commands/component_schematic.py @@ -13,9 +13,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 @@ -135,32 +133,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) @@ -198,9 +186,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 +195,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 +) @@ -303,9 +287,7 @@ class ComponentManager: 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): """Update component properties by reference designator""" try: symbol_to_update = None @@ -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") diff --git a/python/commands/connection_schematic.py b/python/commands/connection_schematic.py index 1b4ef86..24f08fc 100644 --- a/python/commands/connection_schematic.py +++ b/python/commands/connection_schematic.py @@ -48,9 +48,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: @@ -58,9 +56,7 @@ class ConnectionManager: return None @staticmethod - def connect_to_net( - schematic_path: Path, component_ref: str, pin_name: str, net_name: str - ): + def connect_to_net(schematic_path: Path, component_ref: str, pin_name: str, net_name: str): """ Connect a component pin to a named net using a wire stub and label @@ -93,9 +89,7 @@ class ConnectionManager: # 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 - ) + pin_angle_deg = locator.get_pin_angle(schematic_path, component_ref, pin_name) or 0 except Exception: pin_angle_deg = 0 import math as _math @@ -172,9 +166,7 @@ class ConnectionManager: connected = [] failed = [] - for pin_num in sorted( - src_pins.keys(), key=lambda x: int(x) if x.isdigit() else 0 - ): + 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}" @@ -200,15 +192,11 @@ class ConnectionManager: failed.append(f"{target_ref}/{pin_num} (pin not found)") continue - connected.append( - f"{source_ref}/{pin_num} <-> {target_ref}/{pin_num} [{net_name}]" - ) + connected.append(f"{source_ref}/{pin_num} <-> {target_ref}/{pin_num} [{net_name}]") except Exception as e: failed.append(f"{source_ref}/{pin_num}: {e}") - logger.info( - f"connect_passthrough: {len(connected)} connected, {len(failed)} failed" - ) + logger.info(f"connect_passthrough: {len(connected)} connected, {len(failed)} failed") return {"connected": connected, "failed": failed} @staticmethod @@ -256,9 +244,7 @@ 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"): @@ -272,9 +258,7 @@ class ConnectionManager: 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 @@ -334,18 +318,14 @@ 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 for wire_pt in connected_wire_points: if points_coincide(pin_loc, list(wire_pt)): - connections.append( - {"component": ref, "pin": pin_num} - ) + connections.append({"component": ref, "pin": pin_num}) break # Pin found, no need to check more wire points except Exception as e: @@ -364,9 +344,7 @@ class ConnectionManager: # Check if symbol is near any wire point (within 10mm) for wire_pt in connected_wire_points: - dist = ( - (symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2 - ) ** 0.5 + dist = ((symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2) ** 0.5 if dist < 10.0: # 10mm proximity threshold connections.append({"component": ref, "pin": "unknown"}) break # Only add once per component @@ -419,9 +397,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 @@ -444,9 +420,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" diff --git a/python/commands/datasheet_manager.py b/python/commands/datasheet_manager.py index 5fa051f..9e72429 100644 --- a/python/commands/datasheet_manager.py +++ b/python/commands/datasheet_manager.py @@ -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, diff --git a/python/commands/design_rules.py b/python/commands/design_rules.py index 71eb7aa..766a573 100644 --- a/python/commands/design_rules.py +++ b/python/commands/design_rules.py @@ -58,13 +58,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 +73,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: @@ -216,9 +206,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 +285,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 +439,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 diff --git a/python/commands/dynamic_symbol_loader.py b/python/commands/dynamic_symbol_loader.py index 96f0df2..c102394 100644 --- a/python/commands/dynamic_symbol_loader.py +++ b/python/commands/dynamic_symbol_loader.py @@ -46,7 +46,12 @@ class DynamicSymbolLoader: Path.home() / "Documents" / "KiCad" / "10.0" / "3rdparty" / "symbols", Path.home() / "Documents" / "KiCad" / "9.0" / "3rdparty" / "symbols", ] - for env_var in ["KICAD10_SYMBOL_DIR", "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])) @@ -83,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: @@ -208,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. @@ -255,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 … @@ -280,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. @@ -311,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 @@ -322,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 @@ -362,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 = [] @@ -399,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 @@ -457,9 +442,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( diff --git a/python/commands/export.py b/python/commands/export.py index d15e533..f7a2fd9 100644 --- a/python/commands/export.py +++ b/python/commands/export.py @@ -105,22 +105,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 +230,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, @@ -395,9 +387,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: @@ -696,6 +686,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") diff --git a/python/commands/footprint.py b/python/commands/footprint.py index 97777d9..5398672 100644 --- a/python/commands/footprint.py +++ b/python/commands/footprint.py @@ -16,7 +16,7 @@ from typing import Any, Dict, List, Optional logger = logging.getLogger("kicad_interface") -KICAD9_FORMAT_VERSION = "20250114" # .kicad_sch schematic files +KICAD9_FORMAT_VERSION = "20250114" # .kicad_sch schematic files KICAD9_FOOTPRINT_VERSION = "20241229" # .kicad_mod footprint files @@ -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,32 @@ 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, - block, - count=1 + r'(pad\s+"[^"]*"\s+\w+\s+)\w+', lambda m: m.group(1) + shape, block, count=1 ) if n: changes.append(f"shape→{shape}") @@ -280,7 +275,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 +424,7 @@ class FootprintCreator: # Internal helpers # # ------------------------------------------------------------------ # + def _esc(s: str) -> str: """Escape double-quotes inside S-Expression string values.""" return s.replace('"', '\\"') @@ -436,6 +432,7 @@ def _esc(s: str) -> str: def _new_uuid() -> str: import uuid + return str(uuid.uuid4()) @@ -445,8 +442,8 @@ _DEFAULT_THT_LAYERS = ["*.Cu", "*.Mask"] def _pad_lines(pad: Dict[str, Any]) -> List[str]: number = str(pad.get("number", "1")) - ptype = pad.get("type", "smd").lower() # smd | thru_hole | np_thru_hole - shape = pad.get("shape", "rect").lower() # rect | circle | oval | roundrect + ptype = pad.get("type", "smd").lower() # smd | thru_hole | np_thru_hole + shape = pad.get("shape", "rect").lower() # rect | circle | oval | roundrect at = pad.get("at", {"x": 0.0, "y": 0.0}) size = pad.get("size", {"w": 1.0, "h": 1.0}) drill = pad.get("drill", None) @@ -462,7 +459,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 +493,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" )", "", ] diff --git a/python/commands/freerouting.py b/python/commands/freerouting.py index a3b2268..39318d6 100644 --- a/python/commands/freerouting.py +++ b/python/commands/freerouting.py @@ -22,9 +22,7 @@ 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" @@ -102,21 +100,36 @@ def _build_freerouting_cmd( 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() 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), ] @@ -126,9 +139,7 @@ class FreeroutingCommands: def __init__(self, board=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 +163,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 +200,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 +244,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 +264,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 +283,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 +291,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 +306,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 +321,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 +336,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 +359,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 +380,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 +408,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 +439,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 +450,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 +464,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 +484,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 +500,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 diff --git a/python/commands/jlcpcb.py b/python/commands/jlcpcb.py index f93e40a..8856848 100644 --- a/python/commands/jlcpcb.py +++ b/python/commands/jlcpcb.py @@ -18,7 +18,7 @@ import json from typing import Optional, Dict, List, Callable from pathlib import Path -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") class JLCPCBClient: @@ -31,7 +31,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 +45,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 +96,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 +113,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 +125,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 +149,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 +168,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 +203,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 +251,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 +276,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 +287,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: diff --git a/python/commands/jlcpcb_parts.py b/python/commands/jlcpcb_parts.py index 249bdbb..ff36424 100644 --- a/python/commands/jlcpcb_parts.py +++ b/python/commands/jlcpcb_parts.py @@ -13,7 +13,7 @@ from pathlib import Path from typing import List, Dict, Optional from datetime import datetime -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") class JLCPCBPartsManager: @@ -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,7 +85,7 @@ class JLCPCBPartsManager: manufacturer, content=components ) - ''') + """) self.conn.commit() logger.info(f"Initialized JLCPCB parts database at {self.db_path}") @@ -103,32 +105,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 - library_type, # library_type - part.get('describe'), # description - part.get('dataManualUrl'), # datasheet - part.get('stockCount', 0), # stock - price_json, # price_json - int(datetime.now().timestamp()) # last_updated - )) + """, + ( + 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 + price_json, # price_json + int(datetime.now().timestamp()), # last_updated + ), + ) imported += 1 @@ -140,10 +145,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,16 +156,16 @@ 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): """ @@ -178,56 +183,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 - 0, # solder_joints (not in jlcsearch) - part.get('manufacturer', ''), # manufacturer - library_type, # library_type - description, # description - '', # datasheet (not in jlcsearch) - part.get('stock', 0), # stock - price_json, # price_json - int(datetime.now().timestamp()) # last_updated - )) + """, + ( + lcsc, # lcsc with C prefix + 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 + library_type, # library_type + description, # description + "", # datasheet (not in jlcsearch) + part.get("stock", 0), # stock + price_json, # price_json + int(datetime.now().timestamp()), # last_updated + ), + ) imported += 1 @@ -239,10 +247,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 +263,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 @@ -283,15 +291,14 @@ class JLCPCBPartsManager: # 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() + f"{term}*" if not term.endswith("*") else term for term in query.strip().split() ) - sql_parts.append(''' + sql_parts.append(""" AND lcsc IN ( SELECT lcsc FROM components_fts WHERE components_fts MATCH ? ) - ''') + """) params.append(fts_query) if category: @@ -343,11 +350,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 @@ -356,23 +363,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]: @@ -390,43 +399,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 @@ -457,24 +445,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 + 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) @@ -488,7 +473,7 @@ class JLCPCBPartsManager: self.conn.close() -if __name__ == '__main__': +if __name__ == "__main__": # Test the parts manager logging.basicConfig(level=logging.INFO) @@ -503,8 +488,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']})" + ) diff --git a/python/commands/jlcsearch.py b/python/commands/jlcsearch.py index b6e4584..b51088a 100644 --- a/python/commands/jlcsearch.py +++ b/python/commands/jlcsearch.py @@ -10,7 +10,7 @@ import requests from typing import Optional, Dict, List, Callable import time -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") class JLCSearchClient: @@ -28,11 +28,7 @@ class JLCSearchClient: 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 ) -> List[Dict]: """ Search components in JLCSearch database @@ -48,11 +44,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 +63,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 @@ -100,7 +94,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 @@ -141,9 +137,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 +159,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 +209,7 @@ def test_jlcsearch_connection() -> bool: return False -if __name__ == '__main__': +if __name__ == "__main__": # Test the JLCSearch client logging.basicConfig(level=logging.INFO) diff --git a/python/commands/library.py b/python/commands/library.py index c1d1582..678a7c2 100644 --- a/python/commands/library.py +++ b/python/commands/library.py @@ -116,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}") @@ -221,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: @@ -352,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}") @@ -461,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] @@ -527,7 +516,6 @@ class LibraryCommands: info: Dict = { "library": library_nickname, "name": footprint_name, - "full_name": f"{library_nickname}:{footprint_name}", "library_path": library_path, } @@ -536,19 +524,24 @@ class LibraryCommands: try: from parsers.kicad_mod_parser import parse_kicad_mod from pathlib import Path as _Path + 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["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: - logger.warning(f"get_footprint_info: parser returned nothing for {mod_file}, using minimal info") + 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") + logger.warning( + f"get_footprint_info: parser error ({parse_err}), using minimal info" + ) return {"success": True, "info": info} diff --git a/python/commands/library_schematic.py b/python/commands/library_schematic.py index 45c2ae9..b9c8fd4 100644 --- a/python/commands/library_schematic.py +++ b/python/commands/library_schematic.py @@ -1,8 +1,10 @@ from skip import Schematic + # Symbol class might not be directly importable in the current version import os import glob + class LibraryManager: """Manage symbol libraries""" @@ -14,9 +16,11 @@ class LibraryManager: # This would need to be configured for the specific environment search_paths = [ "C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym", # Windows path pattern - "/usr/share/kicad/symbols/*.kicad_sym", # Linux 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 = [] @@ -30,7 +34,9 @@ class LibraryManager: # 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 ''}") + print( + 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} @@ -47,7 +53,9 @@ 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.") + print( + 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}") @@ -59,7 +67,9 @@ class LibraryManager: 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.") + print( + 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}") @@ -78,7 +88,9 @@ class LibraryManager: libraries = LibraryManager.list_available_libraries(search_paths) results = [] - print(f"Searched for symbols matching '{query}'. This requires advanced implementation.") + print( + 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}") @@ -119,7 +131,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() diff --git a/python/commands/library_symbol.py b/python/commands/library_symbol.py index 4de88f8..434a622 100644 --- a/python/commands/library_symbol.py +++ b/python/commands/library_symbol.py @@ -12,26 +12,27 @@ 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" - value: str = "" # Value property - description: str = "" # Description property - footprint: str = "" # Footprint reference if present - lcsc_id: str = "" # LCSC property if present + + name: str # Symbol name (without library prefix) + library: str # Library nickname + full_ref: str # "Library:SymbolName" + value: str = "" # Value property + description: str = "" # Description property + footprint: str = "" # Footprint reference if present + lcsc_id: str = "" # LCSC property if present manufacturer: str = "" # Manufacturer property - mpn: str = "" # Part/MPN property - category: str = "" # Category property - datasheet: str = "" # Datasheet URL - stock: str = "" # Stock (from JLCPCB libs) - price: str = "" # Price (from JLCPCB libs) - lib_class: str = "" # Basic/Preferred/Extended + mpn: str = "" # Part/MPN property + category: str = "" # Category property + datasheet: str = "" # Datasheet URL + stock: str = "" # Stock (from JLCPCB libs) + price: str = "" # Price (from JLCPCB libs) + lib_class: str = "" # Basic/Preferred/Extended class SymbolLibraryManager: @@ -107,7 +108,7 @@ 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 @@ -155,25 +156,25 @@ class SymbolLibraryManager: # Common KiCAD environment variables env_vars = { - '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(), + "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) @@ -199,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): @@ -219,12 +220,12 @@ class SymbolLibraryManager: ] # Check environment variable - 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']) + 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): @@ -246,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 @@ -261,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 @@ -280,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) @@ -351,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 @@ -370,7 +373,9 @@ class SymbolLibraryManager: libraries_to_search = 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) @@ -495,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: @@ -513,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") @@ -527,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: @@ -554,10 +545,10 @@ class SymbolLibraryCommands: "success": False, "message": f"Library '{library}' not found in sym-lib-table", "errorDetails": f"Library '{library}' is not registered in your KiCad symbol library table. " - f"Found {len(available_libs)} libraries. " - f"Please add this library to your sym-lib-table file, or use one of the available libraries.", + 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) @@ -566,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: @@ -581,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 diff --git a/python/commands/pin_locator.py b/python/commands/pin_locator.py index 040cbbd..3657c46 100644 --- a/python/commands/pin_locator.py +++ b/python/commands/pin_locator.py @@ -117,11 +117,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 +127,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 @@ -220,20 +212,14 @@ class PinLocator: symbol_at = target_symbol.at.value symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0 - 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: return None pins = self.get_symbol_pins(schematic_path, lib_id) if pin_number not in pins: matched_num = next( - ( - num - for num, data in pins.items() - if data.get("name") == pin_number - ), + (num for num, data in pins.items() if data.get("name") == pin_number), None, ) if matched_num: @@ -290,9 +276,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 @@ -311,11 +295,7 @@ class PinLocator: if pin_number not in pins: # Try matching by pin name (e.g. "VCC1", "SDA", "GND") matched_num = next( - ( - num - for num, data in pins.items() - if data.get("name") == pin_number - ), + (num for num, data in pins.items() if data.get("name") == pin_number), None, ) if matched_num: @@ -336,26 +316,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: @@ -397,9 +369,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 {} @@ -412,9 +382,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 @@ -444,9 +412,7 @@ 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(__file__).parent.parent - / "templates" - / "template_with_symbols_expanded.kicad_sch" + Path(__file__).parent.parent / "templates" / "template_with_symbols_expanded.kicad_sch" ) shutil.copy(template_path, test_path) diff --git a/python/commands/project.py b/python/commands/project.py index 46872cf..2a2e403 100644 --- a/python/commands/project.py +++ b/python/commands/project.py @@ -22,9 +22,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 +99,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 +203,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(), }, } diff --git a/python/commands/routing.py b/python/commands/routing.py index 5dd0101..da66436 100644 --- a/python/commands/routing.py +++ b/python/commands/routing.py @@ -149,9 +149,9 @@ class RoutingCommands: # KiCAD 9 SWIG. Use footprint.GetLayer() instead — it always reflects # the actual placed layer after Flip(). fp_start = footprints[from_ref] - fp_end = footprints[to_ref] + fp_end = footprints[to_ref] start_layer = self.board.GetLayerName(fp_start.GetLayer()) - end_layer = self.board.GetLayerName(fp_end.GetLayer()) + end_layer = self.board.GetLayerName(fp_end.GetLayer()) copper_layers = {"F.Cu", "B.Cu"} needs_via = ( start_layer in copper_layers @@ -168,24 +168,34 @@ class RoutingCommands: via_y = (start_pos.y + end_pos.y) / 2 / scale # Trace on start layer: start_pad → via - 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, - }) + 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, + } + ) # Via connecting both layers - self.add_via({ - "position": {"x": via_x, "y": via_y, "unit": "mm"}, - "net": net, - "from_layer": start_layer, - "to_layer": end_layer, - }) + 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({ - "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, - }) + 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, + } + ) success = r1.get("success") and r2.get("success") result = { "success": success, @@ -195,21 +205,28 @@ class RoutingCommands: } else: # Same layer — direct 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, - }) + 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, + } + ) 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 +369,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 +511,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 +949,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 +965,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 +1325,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 +1396,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() diff --git a/python/commands/schematic.py b/python/commands/schematic.py index 8647062..9365f25 100644 --- a/python/commands/schematic.py +++ b/python/commands/schematic.py @@ -31,31 +31,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") diff --git a/python/commands/schematic_analysis.py b/python/commands/schematic_analysis.py index 09d284d..4044972 100644 --- a/python/commands/schematic_analysis.py +++ b/python/commands/schematic_analysis.py @@ -169,28 +169,16 @@ def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]: 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") - ): + 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") - ): + 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") - ): + elif isinstance(sub, list) and len(sub) >= 2 and sub[0] == Symbol("radius"): r = float(sub[1]) if r > 0: points.extend( @@ -212,11 +200,7 @@ def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]: 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") - ): + if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"): points.append((float(pt[1]), float(pt[2]))) else: @@ -243,11 +227,7 @@ def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict]: """ lib_symbols_section = None for item in sexp_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_section = item break @@ -465,9 +445,7 @@ def _compute_symbol_bbox_direct( # --------------------------------------------------------------------------- -def find_overlapping_elements( - schematic_path: Path, tolerance: float = 0.5 -) -> Dict[str, Any]: +def find_overlapping_elements(schematic_path: Path, tolerance: float = 0.5) -> Dict[str, Any]: """ Detect spatially overlapping symbols, wires, and labels. @@ -490,9 +468,7 @@ def find_overlapping_elements( # --- 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"] + s for s in symbols if not s["reference"].startswith("_TEMPLATE") and s["reference"] ] # Pre-compute bounding boxes for all non-template symbols @@ -503,9 +479,7 @@ def find_overlapping_elements( graphics_points = lib_data.get("graphics_points", []) bbox = None if pin_defs: - bbox = _compute_symbol_bbox_direct( - sym, pin_defs, graphics_points=graphics_points - ) + bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) symbol_bboxes.append((sym, bbox)) for i in range(len(symbol_bboxes)): @@ -706,9 +680,7 @@ def get_elements_in_region( 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 - ) + or _line_segment_intersects_aabb(s[0], s[1], e[0], e[1], min_x, min_y, max_x, max_y) ): region_wires.append( { @@ -877,15 +849,11 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]: 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 - ): + 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 - ): + if not _line_segment_intersects_aabb(sx, sy, nex, ney, bx1, by1, bx2, by2): continue # Wire terminates at pin from outside sym = sd["sym"] diff --git a/python/commands/svg_import.py b/python/commands/svg_import.py index f0b7386..e337871 100644 --- a/python/commands/svg_import.py +++ b/python/commands/svg_import.py @@ -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]: @@ -57,9 +55,9 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]: """ polygons: List[Polygon] = [] current: Polygon = [] - cx, cy = 0.0, 0.0 # current point - sx, sy = 0.0, 0.0 # subpath start - last_ctrl = None # last bezier control point (for S/T commands) + cx, cy = 0.0, 0.0 # current point + sx, sy = 0.0, 0.0 # subpath start + last_ctrl = None # last bezier control point (for S/T commands) last_cmd = "" i = 0 @@ -73,13 +71,15 @@ 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 mt = 1 - t - x = mt**3*p0[0] + 3*mt**2*t*p1[0] + 3*mt*t**2*p2[0] + t**3*p3[0] - y = mt**3*p0[1] + 3*mt**2*t*p1[1] + 3*mt*t**2*p2[1] + t**3*p3[1] + x = mt**3 * p0[0] + 3 * mt**2 * t * p1[0] + 3 * mt * t**2 * p2[0] + t**3 * p3[0] + y = mt**3 * p0[1] + 3 * mt**2 * t * p1[1] + 3 * mt * t**2 * p2[1] + t**3 * p3[1] pts.append((x, y)) return pts @@ -88,13 +88,23 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]: for k in range(1, steps + 1): t = k / steps mt = 1 - t - x = mt**2*p0[0] + 2*mt*t*p1[0] + t**2*p2[0] - y = mt**2*p0[1] + 2*mt*t*p1[1] + t**2*p2[1] + x = mt**2 * p0[0] + 2 * mt * t * p1[0] + t**2 * p2[0] + y = mt**2 * p0[1] + 2 * mt * t * p1[1] + t**2 * p2[1] 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)] @@ -104,13 +114,13 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]: x1p = cos_phi * dx + sin_phi * dy y1p = -sin_phi * dx + cos_phi * dy rx, ry = abs(rx), abs(ry) - lam = (x1p / rx)**2 + (y1p / ry)**2 + lam = (x1p / rx) ** 2 + (y1p / ry) ** 2 if lam > 1: lam = math.sqrt(lam) rx *= lam ry *= lam - num = max(0.0, (rx*ry)**2 - (rx*y1p)**2 - (ry*x1p)**2) - den = (rx*y1p)**2 + (ry*x1p)**2 + num = max(0.0, (rx * ry) ** 2 - (rx * y1p) ** 2 - (ry * x1p) ** 2) + den = (rx * y1p) ** 2 + (ry * x1p) ** 2 sq = math.sqrt(num / den) if den != 0 else 0 if large_arc == sweep: sq = -sq @@ -120,8 +130,10 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]: 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))))) - if ux*vy - uy*vx < 0: + 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,48 +292,45 @@ 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(): 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) - ] + 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: cx_, cy_ = args[1], args[2] t1 = [[1, 0, cx_], [0, 1, cy_], [0, 0, 1]] - r = [[cos, -sin, 0], [sin, cos, 0], [0, 0, 1]] + r = [[cos, -sin, 0], [sin, cos, 0], [0, 0, 1]] t2 = [[1, 0, -cx_], [0, 1, -cy_], [0, 0, 1]] 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 @@ -321,25 +346,22 @@ def _apply_transform(pts: List[Point], mat: List[List[float]]) -> List[Point]: 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) - ] + 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 @@ -351,13 +373,13 @@ def _identity(): 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,20 +483,24 @@ 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 = [ - "\t(gr_poly", - "\t\t(pts", - ] + pts_lines + [ - "\t\t)", - "\t\t(stroke", - f"\t\t\t(width {stroke_width:.4f})", - "\t\t\t(type solid)", - "\t\t)", - f"\t\t(fill {fill_str})", - f'\t\t(layer "{layer}")', - f'\t\t(uuid "{uid}")', - "\t)", - ] + lines = ( + [ + "\t(gr_poly", + "\t\t(pts", + ] + + pts_lines + + [ + "\t\t)", + "\t\t(stroke", + f"\t\t\t(width {stroke_width:.4f})", + "\t\t\t(type solid)", + "\t\t)", + f"\t\t(fill {fill_str})", + f'\t\t(layer "{layer}")', + 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)} diff --git a/python/commands/symbol_creator.py b/python/commands/symbol_creator.py index a54d239..e06a07f 100644 --- a/python/commands/symbol_creator.py +++ b/python/commands/symbol_creator.py @@ -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" )", ] diff --git a/python/commands/wire_connectivity.py b/python/commands/wire_connectivity.py index dfba1e8..eeb897b 100644 --- a/python/commands/wire_connectivity.py +++ b/python/commands/wire_connectivity.py @@ -95,9 +95,7 @@ def _parse_virtual_connections(schematic, schematic_path): locator = PinLocator() for symbol in schematic.symbol: try: - if not hasattr(symbol, "property") or not hasattr( - symbol.property, "Reference" - ): + if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"): continue ref = symbol.property.Reference.value if not ref.startswith("#PWR"): @@ -194,9 +192,7 @@ def _find_pins_on_net( ref = None for symbol in schematic.symbol: try: - if not hasattr(symbol, "property") or not hasattr( - symbol.property, "Reference" - ): + if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"): continue ref = symbol.property.Reference.value if ref.startswith("_TEMPLATE"): @@ -241,9 +237,7 @@ def get_wire_connections( adjacency, iu_to_wires = _build_adjacency(all_wires) - point_to_label, label_to_points = _parse_virtual_connections( - schematic, schematic_path - ) + point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path) visited, net_points = _find_connected_wires( x_mm, diff --git a/python/commands/wire_manager.py b/python/commands/wire_manager.py index 1fba738..d1046f5 100644 --- a/python/commands/wire_manager.py +++ b/python/commands/wire_manager.py @@ -76,11 +76,7 @@ class WireManager: # 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] == _SYM_SHEET_INSTANCES - ): + if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES: sheet_instances_index = i break @@ -146,20 +142,14 @@ class WireManager: # 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 - ) + 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] == _SYM_SHEET_INSTANCES - ): + if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES: sheet_instances_index = i break @@ -235,11 +225,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] == _SYM_SHEET_INSTANCES - ): + if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES: sheet_instances_index = i break @@ -274,11 +260,7 @@ class WireManager: 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 - ): + 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 @@ -379,9 +361,7 @@ class WireManager: return splits @staticmethod - def add_junction( - schematic_path: Path, position: List[float], diameter: float = 0 - ) -> bool: + def add_junction(schematic_path: Path, position: List[float], diameter: float = 0) -> bool: """ Add a junction (connection dot) to the schematic. @@ -423,11 +403,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] == _SYM_SHEET_INSTANCES - ): + if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES: sheet_instances_index = i break @@ -484,11 +460,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] == _SYM_SHEET_INSTANCES - ): + if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES: sheet_instances_index = i break @@ -544,9 +516,7 @@ 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] == _SYM_WIRE - ): + if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_WIRE): continue # Extract pts from the wire s-expression @@ -626,9 +596,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] == _SYM_LABEL - ): + if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_LABEL): continue # Second element is the label text @@ -649,8 +617,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 diff --git a/python/kicad_api/__init__.py b/python/kicad_api/__init__.py index 0bdca28..223b009 100644 --- a/python/kicad_api/__init__.py +++ b/python/kicad_api/__init__.py @@ -23,5 +23,5 @@ Usage: from kicad_api.factory import create_backend from kicad_api.base import KiCADBackend -__all__ = ['create_backend', 'KiCADBackend'] -__version__ = '2.0.0-alpha.1' +__all__ = ["create_backend", "KiCADBackend"] +__version__ = "2.0.0-alpha.1" diff --git a/python/kicad_api/base.py b/python/kicad_api/base.py index f64e1f6..4a9157a 100644 --- a/python/kicad_api/base.py +++ b/python/kicad_api/base.py @@ -3,6 +3,7 @@ Abstract base class for KiCAD API backends Defines the interface that all KiCAD backends must implement. """ + from abc import ABC, abstractmethod from pathlib import Path from typing import Optional, Dict, Any, List @@ -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 @@ -167,7 +168,7 @@ class BoardAPI(ABC): x: float, y: float, rotation: float = 0, - layer: str = "F.Cu" + layer: str = "F.Cu", ) -> bool: """ Place a component on the board @@ -194,7 +195,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 +221,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 +276,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 diff --git a/python/kicad_api/factory.py b/python/kicad_api/factory.py index 2eabfed..3ead056 100644 --- a/python/kicad_api/factory.py +++ b/python/kicad_api/factory.py @@ -3,6 +3,7 @@ Backend factory for creating appropriate KiCAD API backend Auto-detects available backends and provides fallback mechanism. """ + import os import logging from typing import Optional @@ -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)) diff --git a/python/kicad_api/ipc_backend.py b/python/kicad_api/ipc_backend.py index 94deda9..cd4e0e8 100644 --- a/python/kicad_api/ipc_backend.py +++ b/python/kicad_api/ipc_backend.py @@ -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 kicad_api.base import ( - KiCADBackend, - BoardAPI, - ConnectionError, - APINotAvailableError -) +from kicad_api.base import KiCADBackend, BoardAPI, ConnectionError, APINotAvailableError logger = logging.getLogger(__name__) @@ -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).""" @@ -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 "", - "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" - }, - "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 "" - }) + 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", + }, + "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 "", + } + ) 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 ) @@ -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 @@ -560,14 +546,7 @@ class IPCBoardAPI(BoardAPI): return None def _place_loaded_footprint( - self, - loaded_fp, - reference: str, - x: float, - y: float, - rotation: float, - layer: str, - value: str + self, loaded_fp, reference: str, x: float, y: float, rotation: float, layer: str, value: str ) -> bool: """ Place a loaded pcbnew footprint onto the board. @@ -589,7 +568,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 +616,27 @@ class IPCBoardAPI(BoardAPI): except Exception as e: logger.debug(f"Could not refresh IPC board: {e}") - self._notify("component_placed", { - "reference": reference, - "footprint": loaded_fp.GetFPIDAsString(), - "position": {"x": x, "y": y}, - "rotation": rotation, - "layer": layer, - "loaded_from_library": True - }) + self._notify( + "component_placed", + { + "reference": reference, + "footprint": loaded_fp.GetFPIDAsString(), + "position": {"x": x, "y": y}, + "rotation": rotation, + "layer": layer, + "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 +646,7 @@ class IPCBoardAPI(BoardAPI): y: float, rotation: float, layer: str, - value: str + value: str, ) -> bool: """ Place a placeholder footprint when library loading fails. @@ -701,15 +683,18 @@ class IPCBoardAPI(BoardAPI): board.create_items(fp) board.push_commit(commit, f"Placed component {reference}") - self._notify("component_placed", { - "reference": reference, - "footprint": footprint, - "position": {"x": x, "y": y}, - "rotation": rotation, - "layer": layer, - "loaded_from_library": False, - "is_placeholder": True - }) + self._notify( + "component_placed", + { + "reference": reference, + "footprint": footprint, + "position": {"x": x, "y": y}, + "rotation": rotation, + "layer": layer, + "loaded_from_library": False, + "is_placeholder": True, + }, + ) logger.info(f"Placed placeholder component {reference} at ({x}, {y}) mm") return True @@ -718,7 +703,9 @@ 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 @@ -749,11 +736,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 +785,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. @@ -842,13 +828,16 @@ class IPCBoardAPI(BoardAPI): board.create_items(track) board.push_commit(commit, "Added track") - 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 - }) + 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, + }, + ) logger.info(f"Added track from ({start_x}, {start_y}) to ({end_x}, {end_y}) mm") return True @@ -864,7 +853,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. @@ -906,13 +895,16 @@ class IPCBoardAPI(BoardAPI): board.create_items(via) board.push_commit(commit, "Added via") - self._notify("via_added", { - "position": {"x": x, "y": y}, - "diameter": diameter, - "drill": drill, - "net": net_name, - "type": via_type - }) + self._notify( + "via_added", + { + "position": {"x": x, "y": y}, + "diameter": diameter, + "drill": drill, + "net": net_name, + "type": via_type, + }, + ) logger.info(f"Added via at ({x}, {y}) mm") return True @@ -928,7 +920,7 @@ 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: @@ -959,11 +951,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 +970,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) - }, - "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 "" - }) + 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 "", + } + ) except Exception as e: logger.warning(f"Error processing track: {e}") continue @@ -1017,17 +1001,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) - }, - "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 "" - }) + 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 "", + } + ) except Exception as e: logger.warning(f"Error processing via: {e}") continue @@ -1047,10 +1030,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 +1052,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. @@ -1157,12 +1139,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 +1162,18 @@ class IPCBoardAPI(BoardAPI): result = [] for zone in zones: try: - 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 "" - }) + 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 "", + } + ) except Exception as e: logger.warning(f"Error processing zone: {e}") continue @@ -1219,10 +1203,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 +1224,4 @@ class IPCBoardAPI(BoardAPI): # Export for factory -__all__ = ['IPCBackend', 'IPCBoardAPI'] +__all__ = ["IPCBackend", "IPCBoardAPI"] diff --git a/python/kicad_api/swig_backend.py b/python/kicad_api/swig_backend.py index 4aedcd2..312e0b6 100644 --- a/python/kicad_api/swig_backend.py +++ b/python/kicad_api/swig_backend.py @@ -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 kicad_api.base import ( - KiCADBackend, - BoardAPI, - ConnectionError, - APINotAvailableError -) +from kicad_api.base import KiCADBackend, BoardAPI, ConnectionError, APINotAvailableError logger = logging.getLogger(__name__) @@ -48,6 +44,7 @@ class SWIGBackend(KiCADBackend): """ try: import pcbnew + self._pcbnew = pcbnew version = pcbnew.GetBuildVersion() logger.info(f"✓ Connected to pcbnew (SWIG): {version}") @@ -191,7 +188,7 @@ class SWIGBoardAPI(BoardAPI): x: float, y: float, rotation: float = 0, - layer: str = "F.Cu" + layer: str = "F.Cu", ) -> bool: """Place component using existing implementation""" from commands.component import ComponentCommands @@ -202,7 +199,7 @@ class SWIGBoardAPI(BoardAPI): position={"x": x, "y": y, "unit": "mm"}, reference=reference, rotation=rotation, - layer=layer + layer=layer, ) return result.get("success", False) except Exception as e: diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 3d78e6e..80fab5f 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -52,9 +52,7 @@ if sys.platform == "win32": # List versions try: versions = [ - d - for d in os.listdir(base_path) - if os.path.isdir(os.path.join(base_path, d)) + d for d in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, d)) ] logger.info(f" Versions found: {', '.join(versions)}") for version in versions: @@ -92,9 +90,7 @@ paths_added = PlatformHelper.add_kicad_to_python_path() if paths_added: logger.info("Successfully added KiCAD Python paths to sys.path") else: - logger.warning( - "No KiCAD Python paths found - attempting to import pcbnew from system path" - ) + logger.warning("No KiCAD Python paths found - attempting to import pcbnew from system path") logger.info(f"Current Python path: {sys.path}") @@ -434,9 +430,7 @@ class KiCADInterface: "check_freerouting": self.freerouting_commands.check_freerouting, } - logger.info( - f"KiCAD interface initialized (backend: {'IPC' if self.use_ipc else 'SWIG'})" - ) + logger.info(f"KiCAD interface initialized (backend: {'IPC' if self.use_ipc else 'SWIG'})") # Commands that can be handled via IPC for real-time updates IPC_CAPABLE_COMMANDS = { @@ -475,11 +469,7 @@ class KiCADInterface: try: # Check if we can use IPC for this command (real-time UI sync) - if ( - self.use_ipc - and self.ipc_board_api - and command in self.IPC_CAPABLE_COMMANDS - ): + if self.use_ipc and self.ipc_board_api and command in self.IPC_CAPABLE_COMMANDS: ipc_handler_name = self.IPC_CAPABLE_COMMANDS[command] ipc_handler = getattr(self, ipc_handler_name, None) @@ -602,9 +592,7 @@ class KiCADInterface: # - TypeScript tools use: name, path # - Python schema uses: filename, title # - Legacy uses: projectName, path, metadata - project_name = ( - params.get("projectName") or params.get("name") or params.get("title") - ) + project_name = params.get("projectName") or params.get("name") or params.get("title") # Handle filename parameter - it may contain full path filename = params.get("filename") @@ -665,13 +653,9 @@ class KiCADInterface: board_path = params.get("boardPath") if board_path: board_path_norm = str(Path(board_path).resolve()) - current_board_file = ( - str(Path(self.board.GetFileName()).resolve()) if self.board else "" - ) + current_board_file = str(Path(self.board.GetFileName()).resolve()) if self.board else "" if board_path_norm != current_board_file: - logger.info( - f"boardPath differs from current board — reloading: {board_path}" - ) + logger.info(f"boardPath differs from current board — reloading: {board_path}") try: self.board = pcbnew.LoadBoard(board_path) self._update_command_handlers() @@ -689,9 +673,7 @@ class KiCADInterface: self._current_project_path = project_path local_lib = FootprintLibraryManager(project_path=project_path) self.component_commands = ComponentCommands(self.board, local_lib) - logger.info( - f"Reloaded FootprintLibraryManager with project_path={project_path}" - ) + logger.info(f"Reloaded FootprintLibraryManager with project_path={project_path}") return self.component_commands.place_component(params) @@ -788,9 +770,7 @@ class KiCADInterface: # Skip lib_symbols section lib_sym_pos = content.find("(lib_symbols") - lib_sym_end = ( - find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1 - ) + lib_sym_end = find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1 # Find ALL placed symbol blocks matching the reference (handles duplicates). # Use content-string search so multi-line KiCAD format is handled correctly: @@ -840,9 +820,7 @@ class KiCADInterface: f.write(content) deleted_count = len(blocks_to_delete) - logger.info( - f"Deleted {deleted_count} instance(s) of {reference} from {sch_file.name}" - ) + logger.info(f"Deleted {deleted_count} instance(s) of {reference} from {sch_file.name}") return { "success": True, "reference": reference, @@ -918,9 +896,7 @@ class KiCADInterface: # Skip lib_symbols section lib_sym_pos = content.find("(lib_symbols") - lib_sym_end = ( - find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1 - ) + lib_sym_end = find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1 # Find placed symbol blocks that match the reference # Search for (symbol (lib_id "...") ... (property "Reference" "" ...) ...) @@ -1052,9 +1028,7 @@ class KiCADInterface: # Skip lib_symbols section lib_sym_pos = content.find("(lib_symbols") - lib_sym_end = ( - find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1 - ) + lib_sym_end = find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1 # Find the placed symbol block for this reference block_start = block_end = None @@ -1215,9 +1189,7 @@ class KiCADInterface: match = find_nearest_pin(points[-1], snap_tolerance) if match: ref, pin_num, coords = match - logger.info( - f"Snapped end point {points[-1]} -> {coords} (pin {ref}/{pin_num})" - ) + logger.info(f"Snapped end point {points[-1]} -> {coords} (pin {ref}/{pin_num})") snapped_info.append( f"end snapped to {ref}/{pin_num} at [{coords[0]}, {coords[1]}]" ) @@ -1312,9 +1284,7 @@ class KiCADInterface: def _handle_create_footprint(self, params): """Create a new .kicad_mod footprint file in a .pretty library.""" - logger.info( - f"create_footprint: {params.get('name')} in {params.get('libraryPath')}" - ) + logger.info(f"create_footprint: {params.get('name')} in {params.get('libraryPath')}") try: creator = FootprintCreator() return creator.create_footprint( @@ -1358,9 +1328,7 @@ class KiCADInterface: logger.info("list_footprint_libraries") try: creator = FootprintCreator() - return creator.list_footprint_libraries( - search_paths=params.get("searchPaths") - ) + return creator.list_footprint_libraries(search_paths=params.get("searchPaths")) except Exception as e: logger.error(f"list_footprint_libraries error: {e}") return {"success": False, "error": str(e)} @@ -1387,9 +1355,7 @@ class KiCADInterface: def _handle_create_symbol(self, params): """Create a new symbol in a .kicad_sym library.""" - logger.info( - f"create_symbol: {params.get('name')} in {params.get('libraryPath')}" - ) + logger.info(f"create_symbol: {params.get('name')} in {params.get('libraryPath')}") try: creator = SymbolCreator() return creator.create_symbol( @@ -1413,9 +1379,7 @@ class KiCADInterface: def _handle_delete_symbol(self, params): """Delete a symbol from a .kicad_sym library.""" - logger.info( - f"delete_symbol: {params.get('name')} from {params.get('libraryPath')}" - ) + logger.info(f"delete_symbol: {params.get('name')} from {params.get('libraryPath')}") try: creator = SymbolCreator() return creator.delete_symbol( @@ -1663,8 +1627,7 @@ class KiCADInterface: if pin_num in pins_def: entry["name"] = pins_def[pin_num].get("name", pin_num) entry["angle"] = ( - locator.get_pin_angle(Path(schematic_path), reference, pin_num) - or 0 + locator.get_pin_angle(Path(schematic_path), reference, pin_num) or 0 ) result[pin_num] = entry @@ -1747,9 +1710,7 @@ class KiCADInterface: "message": "cairosvg not installed — returning SVG instead of PNG. Install with: pip install cairosvg", } - png_data = svg2png( - url=svg_path, output_width=width, output_height=height - ) + png_data = svg2png(url=svg_path, output_width=width, output_height=height) return { "success": True, @@ -1814,15 +1775,9 @@ class KiCADInterface: if ref_prefix_filter and not ref.startswith(ref_prefix_filter): continue - value = ( - symbol.property.Value.value - if hasattr(symbol.property, "Value") - else "" - ) + value = symbol.property.Value.value if hasattr(symbol.property, "Value") else "" footprint = ( - symbol.property.Footprint.value - if hasattr(symbol.property, "Footprint") - else "" + symbol.property.Footprint.value if hasattr(symbol.property, "Footprint") else "" ) position = symbol.at.value if hasattr(symbol, "at") else [0, 0, 0] uuid_val = symbol.uuid.value if hasattr(symbol, "uuid") else "" @@ -1849,9 +1804,7 @@ class KiCADInterface: "position": {"x": coords[0], "y": coords[1]}, } if pin_num in pins_def: - pin_info["name"] = pins_def[pin_num].get( - "name", pin_num - ) + pin_info["name"] = pins_def[pin_num].get("name", pin_num) pin_list.append(pin_info) comp["pins"] = pin_list except Exception: @@ -2016,9 +1969,7 @@ class KiCADInterface: if not ref.startswith("#PWR"): continue value = ( - symbol.property.Value.value - if hasattr(symbol.property, "Value") - else ref + symbol.property.Value.value if hasattr(symbol.property, "Value") else ref ) pos = symbol.at.value if hasattr(symbol, "at") else [0, 0, 0] labels.append( @@ -2239,9 +2190,7 @@ class KiCADInterface: start_point = [start.get("x", 0), start.get("y", 0)] end_point = [end.get("x", 0), end.get("y", 0)] - deleted = WireManager.delete_wire( - Path(schematic_path), start_point, end_point - ) + deleted = WireManager.delete_wire(Path(schematic_path), start_point, end_point) if deleted: return {"success": True} else: @@ -2453,9 +2402,7 @@ class KiCADInterface: "errorDetails": "Install KiCAD 8.0+ or add kicad-cli to PATH.", } - 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: @@ -2471,9 +2418,7 @@ class KiCADInterface: ] logger.info(f"Running ERC command: {' '.join(cmd)}") - result = subprocess.run( - cmd, capture_output=True, text=True, timeout=120 - ) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) if result.returncode != 0: logger.error(f"ERC command failed: {result.stderr}") @@ -2542,9 +2487,7 @@ class KiCADInterface: if not schematic: return {"success": False, "message": "Failed to load schematic"} - netlist = ConnectionManager.generate_netlist( - schematic, schematic_path=schematic_path - ) + netlist = ConnectionManager.generate_netlist(schematic, schematic_path=schematic_path) return {"success": True, "netlist": netlist} except Exception as e: logger.error(f"Error generating netlist: {str(e)}") @@ -2599,9 +2542,7 @@ class KiCADInterface: if not schematic: return {"success": False, "message": "Failed to load schematic"} - netlist = ConnectionManager.generate_netlist( - schematic, schematic_path=schematic_path - ) + netlist = ConnectionManager.generate_netlist(schematic, schematic_path=schematic_path) # Build (reference, pad_number) -> net_name map pad_net_map = {} # {(ref, pin_str): net_name} @@ -2890,9 +2831,7 @@ class KiCADInterface: "message": "Missing required parameters: pcbPath, svgPath", } - result = import_svg_to_pcb( - pcb_path, svg_path, x, y, width, layer, stroke_width, filled - ) + result = import_svg_to_pcb(pcb_path, svg_path, x, y, width, layer, stroke_width, filled) # import_svg_to_pcb writes gr_poly entries directly to the .kicad_pcb file, # bypassing the pcbnew in-memory board object. Any subsequent board.Save() @@ -2950,9 +2889,7 @@ class KiCADInterface: prompt_file = None if prompt_text: - prompt_filename = ( - f"PROMPT_step{step}_{ts}.md" if step else f"PROMPT_{ts}.md" - ) + prompt_filename = f"PROMPT_step{step}_{ts}.md" if step else f"PROMPT_{ts}.md" prompt_file = logs_dir / prompt_filename prompt_file.write_text(prompt_text, encoding="utf-8") logger.info(f"Prompt saved: {prompt_file}") @@ -2962,9 +2899,7 @@ class KiCADInterface: system = platform.system() if system == "Windows": - mcp_log_dir = os.path.join( - os.environ.get("APPDATA", ""), "Claude", "logs" - ) + mcp_log_dir = os.path.join(os.environ.get("APPDATA", ""), "Claude", "logs") elif system == "Darwin": mcp_log_dir = os.path.expanduser("~/Library/Logs/Claude") else: @@ -2979,15 +2914,11 @@ class KiCADInterface: if "Initializing server" in line: session_start = i session_lines = all_lines[session_start:] - log_filename = ( - f"mcp_log_step{step}_{ts}.txt" if step else f"mcp_log_{ts}.txt" - ) + log_filename = f"mcp_log_step{step}_{ts}.txt" if step else f"mcp_log_{ts}.txt" mcp_log_dest = logs_dir / log_filename with open(mcp_log_dest, "w", encoding="utf-8") as f: f.writelines(session_lines) - logger.info( - f"MCP session log saved: {mcp_log_dest} ({len(session_lines)} lines)" - ) + logger.info(f"MCP session log saved: {mcp_log_dest} ({len(session_lines)} lines)") base_name = Path(project_dir).name suffix_parts = [p for p in [f"step{step}" if step else "", label, ts] if p] @@ -2996,9 +2927,7 @@ class KiCADInterface: snapshots_base.mkdir(exist_ok=True) snapshot_dir = str(snapshots_base / snapshot_name) - shutil.copytree( - project_dir, snapshot_dir, ignore=shutil.ignore_patterns("snapshots") - ) + shutil.copytree(project_dir, snapshot_dir, ignore=shutil.ignore_patterns("snapshots")) logger.info(f"Project snapshot saved: {snapshot_dir}") return { "success": True, @@ -3077,9 +3006,7 @@ class KiCADInterface: } self.board.Save(board_path) - zone_count = ( - self.board.GetAreaCount() if hasattr(self.board, "GetAreaCount") else 0 - ) + zone_count = self.board.GetAreaCount() if hasattr(self.board, "GetAreaCount") else 0 # Run pcbnew zone fill in an isolated subprocess to prevent crashes import subprocess, sys, textwrap @@ -3117,11 +3044,7 @@ print("ok") "success": False, "message": "Zone fill failed in subprocess — zones are defined and will fill when opened in KiCAD (press B). Continuing is safe.", "zoneCount": zone_count, - "details": ( - result.stderr[:300] - if result.stderr - else result.stdout[:300] - ), + "details": (result.stderr[:300] if result.stderr else result.stdout[:300]), } except subprocess.TimeoutExpired: logger.warning("Zone fill subprocess timed out after 60s") @@ -3151,16 +3074,8 @@ print("ok") net = params.get("net") # Handle both dict format and direct x/y - start_x = ( - start.get("x", 0) - if isinstance(start, dict) - else params.get("startX", 0) - ) - start_y = ( - start.get("y", 0) - if isinstance(start, dict) - else params.get("startY", 0) - ) + start_x = start.get("x", 0) if isinstance(start, dict) else params.get("startX", 0) + start_y = start.get("y", 0) if isinstance(start, dict) else params.get("startY", 0) end_x = end.get("x", 0) if isinstance(end, dict) else params.get("endX", 0) end_y = end.get("y", 0) if isinstance(end, dict) else params.get("endY", 0) @@ -3177,9 +3092,7 @@ print("ok") return { "success": success, "message": ( - "Added trace (visible in KiCAD UI)" - if success - else "Failed to add trace" + "Added trace (visible in KiCAD UI)" if success else "Failed to add trace" ), "trace": { "start": {"x": start_x, "y": start_y, "unit": "mm"}, @@ -3197,16 +3110,8 @@ print("ok") """IPC handler for add_via - adds via with real-time UI update""" try: position = params.get("position", {}) - x = ( - position.get("x", 0) - if isinstance(position, dict) - else params.get("x", 0) - ) - y = ( - position.get("y", 0) - if isinstance(position, dict) - else params.get("y", 0) - ) + x = position.get("x", 0) if isinstance(position, dict) else params.get("x", 0) + y = position.get("y", 0) if isinstance(position, dict) else params.get("y", 0) size = params.get("size", 0.8) drill = params.get("drill", 0.4) @@ -3220,11 +3125,7 @@ print("ok") return { "success": success, - "message": ( - "Added via (visible in KiCAD UI)" - if success - else "Failed to add via" - ), + "message": ("Added via (visible in KiCAD UI)" if success else "Failed to add via"), "via": { "position": {"x": x, "y": y, "unit": "mm"}, "size": size, @@ -3271,9 +3172,7 @@ print("ok") # Convert points format if needed (handle both {x, y} and {x, y, unit}) formatted_points = [] for point in points: - formatted_points.append( - {"x": point.get("x", 0), "y": point.get("y", 0)} - ) + formatted_points.append({"x": point.get("x", 0), "y": point.get("y", 0)}) success = self.ipc_board_api.add_zone( points=formatted_points, @@ -3315,9 +3214,7 @@ print("ok") return { "success": success, "message": ( - "Zones refilled (visible in KiCAD UI)" - if success - else "Failed to refill zones" + "Zones refilled (visible in KiCAD UI)" if success else "Failed to refill zones" ), } except Exception as e: @@ -3329,16 +3226,8 @@ print("ok") try: text = params.get("text", "") position = params.get("position", {}) - x = ( - position.get("x", 0) - if isinstance(position, dict) - else params.get("x", 0) - ) - y = ( - position.get("y", 0) - if isinstance(position, dict) - else params.get("y", 0) - ) + x = position.get("x", 0) if isinstance(position, dict) else params.get("x", 0) + y = position.get("y", 0) if isinstance(position, dict) else params.get("y", 0) layer = params.get("layer", "F.SilkS") size = params.get("size", 1.0) rotation = params.get("rotation", 0) @@ -3412,16 +3301,8 @@ print("ok") reference = params.get("reference", params.get("componentId", "")) footprint = params.get("footprint", "") position = params.get("position", {}) - x = ( - position.get("x", 0) - if isinstance(position, dict) - else params.get("x", 0) - ) - y = ( - position.get("y", 0) - if isinstance(position, dict) - else params.get("y", 0) - ) + x = position.get("x", 0) if isinstance(position, dict) else params.get("x", 0) + y = position.get("y", 0) if isinstance(position, dict) else params.get("y", 0) rotation = params.get("rotation", 0) layer = params.get("layer", "F.Cu") value = params.get("value", "") @@ -3460,16 +3341,8 @@ print("ok") try: reference = params.get("reference", params.get("componentId", "")) position = params.get("position", {}) - x = ( - position.get("x", 0) - if isinstance(position, dict) - else params.get("x", 0) - ) - y = ( - position.get("y", 0) - if isinstance(position, dict) - else params.get("y", 0) - ) + x = position.get("x", 0) if isinstance(position, dict) else params.get("x", 0) + y = position.get("y", 0) if isinstance(position, dict) else params.get("y", 0) rotation = params.get("rotation") success = self.ipc_board_api.move_component( @@ -3534,9 +3407,7 @@ print("ok") """IPC handler for delete_trace - Note: IPC doesn't support direct trace deletion yet""" # IPC API doesn't have a direct delete track method # Fall back to SWIG for this operation - logger.info( - "delete_trace: Falling back to SWIG (IPC doesn't support trace deletion)" - ) + logger.info("delete_trace: Falling back to SWIG (IPC doesn't support trace deletion)") return self.routing_commands.delete_trace(params) def _ipc_get_nets_list(self, params): @@ -3594,9 +3465,7 @@ print("ok") segment.start = Vector2.from_xy( from_mm(start.get("x", 0)), from_mm(start.get("y", 0)) ) - segment.end = Vector2.from_xy( - from_mm(end.get("x", 0)), from_mm(end.get("y", 0)) - ) + segment.end = Vector2.from_xy(from_mm(end.get("x", 0)), from_mm(end.get("y", 0))) segment.layer = BoardLayer.BL_Edge_Cuts segment.attributes.stroke.width = from_mm(width) @@ -3731,9 +3600,7 @@ print("ok") "success": True, "backend": "ipc" if self.use_ipc else "swig", "realtime_sync": self.use_ipc, - "ipc_connected": ( - self.ipc_backend.is_connected() if self.ipc_backend else False - ), + "ipc_connected": (self.ipc_backend.is_connected() if self.ipc_backend else False), "version": self.ipc_backend.get_version() if self.ipc_backend else "N/A", "message": ( "Using IPC backend with real-time UI sync" @@ -3760,9 +3627,7 @@ print("ok") return { "success": success, "message": ( - "Track added (visible in KiCAD UI)" - if success - else "Failed to add track" + "Track added (visible in KiCAD UI)" if success else "Failed to add track" ), "realtime": True, } @@ -3786,11 +3651,7 @@ print("ok") ) return { "success": success, - "message": ( - "Via added (visible in KiCAD UI)" - if success - else "Failed to add via" - ), + "message": ("Via added (visible in KiCAD UI)" if success else "Failed to add via"), "realtime": True, } except Exception as e: @@ -3814,9 +3675,7 @@ print("ok") return { "success": success, "message": ( - "Text added (visible in KiCAD UI)" - if success - else "Failed to add text" + "Text added (visible in KiCAD UI)" if success else "Failed to add text" ), "realtime": True, } @@ -3979,9 +3838,7 @@ print("ok") return {"success": False, "message": f"Part not found: {lcsc_number}"} # Get suggested KiCAD footprints - footprints = self.jlcpcb_parts.map_package_to_footprint( - part.get("package", "") - ) + footprints = self.jlcpcb_parts.map_package_to_footprint(part.get("package", "")) return {"success": True, "part": part, "footprints": footprints} @@ -4013,9 +3870,7 @@ print("ok") reference_price = None if original_part and original_part.get("price_breaks"): try: - reference_price = float( - original_part["price_breaks"][0].get("price", 0) - ) + reference_price = float(original_part["price_breaks"][0].get("price", 0)) except: pass @@ -4141,9 +3996,7 @@ def main(): tools.append(tool_def) else: # Fallback for tools without schemas - logger.warning( - f"No schema defined for tool: {cmd_name}" - ) + logger.warning(f"No schema defined for tool: {cmd_name}") tools.append( { "name": cmd_name, @@ -4172,11 +4025,7 @@ def main(): response = { "jsonrpc": "2.0", "id": request_id, - "result": { - "content": [ - {"type": "text", "text": json.dumps(result)} - ] - }, + "result": {"content": [{"type": "text", "text": json.dumps(result)}]}, } elif method == "resources/list": logger.info("Handling MCP resources/list") @@ -4201,9 +4050,7 @@ def main(): } else: # Read the resource - resource_data = handle_resource_read( - resource_uri, interface - ) + resource_data = handle_resource_read(resource_uri, interface) response = { "jsonrpc": "2.0", diff --git a/python/parsers/kicad_mod_parser.py b/python/parsers/kicad_mod_parser.py index a059f6f..82cbab7 100644 --- a/python/parsers/kicad_mod_parser.py +++ b/python/parsers/kicad_mod_parser.py @@ -27,6 +27,7 @@ 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 @@ -56,7 +57,7 @@ def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]: 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) + 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}") @@ -78,7 +79,7 @@ def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]: # 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) + m = re.search(r"\(attr\s+([^)]+)\)", content) if m: tokens = m.group(1).split() result["attributes"] = { @@ -107,7 +108,7 @@ def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]: 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 m in re.finditer(r"\(layers\s+([^)]+)\)", content): for lyr in re.findall(r'"([^"]+)"', m.group(1)): layers.add(lyr) result["layers"] = sorted(layers) @@ -128,6 +129,7 @@ def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]: # Internal helpers # --------------------------------------------------------------------------- + def _extract_pads(content: str) -> List[Dict[str, Any]]: """ Parse all (pad …) blocks and return a list of pad objects. @@ -147,8 +149,8 @@ def _extract_pads(content: str) -> List[Dict[str, Any]]: # 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' + 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) @@ -159,9 +161,9 @@ def _extract_pads(content: str) -> List[Dict[str, Any]]: 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' + 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) @@ -183,7 +185,7 @@ def _extract_blocks(content: str, token: str) -> List[str]: parenthesis depth. This correctly handles nested parens inside blocks. """ blocks: List[str] = [] - pattern = re.compile(r'\(' + re.escape(token) + r'\b') + pattern = re.compile(r"\(" + re.escape(token) + r"\b") for match in pattern.finditer(content): start = match.start() @@ -191,9 +193,9 @@ def _extract_blocks(content: str, token: str) -> List[str]: i = start while i < len(content): ch = content[i] - if ch == '(': + if ch == "(": depth += 1 - elif ch == ')': + elif ch == ")": depth -= 1 if depth == 0: blocks.append(content[start : i + 1]) @@ -219,8 +221,8 @@ def _extract_courtyard(content: str) -> Optional[Dict[str, float]]: 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) + 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))] @@ -234,7 +236,7 @@ def _extract_courtyard(content: str) -> Optional[Dict[str, float]]: 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): + 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))) diff --git a/python/resources/__init__.py b/python/resources/__init__.py index 65a85e9..782f961 100644 --- a/python/resources/__init__.py +++ b/python/resources/__init__.py @@ -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"] diff --git a/python/resources/resource_definitions.py b/python/resources/resource_definitions.py index 22f1075..feed376 100644 --- a/python/resources/resource_definitions.py +++ b/python/resources/resource_definitions.py @@ -10,7 +10,7 @@ import base64 from typing import Dict, Any, List, Optional import logging -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") # ============================================================================= # RESOURCE DEFINITIONS @@ -21,56 +21,57 @@ 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]: """ 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,67 +103,71 @@ 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]: """Get current project information""" result = interface.project_commands.get_project_info({}) if result.get("success"): return { - "contents": [{ - "uri": "kicad://project/current/info", - "mimeType": "application/json", - "text": json.dumps(result.get("project", {}), indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/info", + "mimeType": "application/json", + "text": json.dumps(result.get("project", {}), indent=2), + } + ] } else: return { - "contents": [{ - "uri": "kicad://project/current/info", - "mimeType": "text/plain", - "text": "No project currently open" - }] + "contents": [ + { + "uri": "kicad://project/current/info", + "mimeType": "text/plain", + "text": "No project currently open", + } + ] } + def _get_board_info(interface) -> Dict[str, Any]: """Get board properties and metadata""" result = interface.board_commands.get_board_info({}) if result.get("success"): return { - "contents": [{ - "uri": "kicad://project/current/board", - "mimeType": "application/json", - "text": json.dumps(result.get("board", {}), indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/board", + "mimeType": "application/json", + "text": json.dumps(result.get("board", {}), indent=2), + } + ] } else: return { - "contents": [{ - "uri": "kicad://project/current/board", - "mimeType": "text/plain", - "text": "No board currently loaded" - }] + "contents": [ + { + "uri": "kicad://project/current/board", + "mimeType": "text/plain", + "text": "No board currently loaded", + } + ] } + def _get_components(interface) -> Dict[str, Any]: """Get list of all components""" result = interface.component_commands.get_component_list({}) @@ -170,24 +175,28 @@ def _get_components(interface) -> Dict[str, Any]: if result.get("success"): components = result.get("components", []) return { - "contents": [{ - "uri": "kicad://project/current/components", - "mimeType": "application/json", - "text": json.dumps({ - "count": len(components), - "components": components - }, indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/components", + "mimeType": "application/json", + "text": json.dumps( + {"count": len(components), "components": components}, indent=2 + ), + } + ] } else: return { - "contents": [{ - "uri": "kicad://project/current/components", - "mimeType": "application/json", - "text": json.dumps({"count": 0, "components": []}, indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/components", + "mimeType": "application/json", + "text": json.dumps({"count": 0, "components": []}, indent=2), + } + ] } + def _get_nets(interface) -> Dict[str, Any]: """Get list of electrical nets""" result = interface.routing_commands.get_nets_list({}) @@ -195,24 +204,26 @@ def _get_nets(interface) -> Dict[str, Any]: if result.get("success"): nets = result.get("nets", []) return { - "contents": [{ - "uri": "kicad://project/current/nets", - "mimeType": "application/json", - "text": json.dumps({ - "count": len(nets), - "nets": nets - }, indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/nets", + "mimeType": "application/json", + "text": json.dumps({"count": len(nets), "nets": nets}, indent=2), + } + ] } else: return { - "contents": [{ - "uri": "kicad://project/current/nets", - "mimeType": "application/json", - "text": json.dumps({"count": 0, "nets": []}, indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/nets", + "mimeType": "application/json", + "text": json.dumps({"count": 0, "nets": []}, indent=2), + } + ] } + def _get_layers(interface) -> Dict[str, Any]: """Get layer stack information""" result = interface.board_commands.get_layer_list({}) @@ -220,45 +231,52 @@ def _get_layers(interface) -> Dict[str, Any]: if result.get("success"): layers = result.get("layers", []) return { - "contents": [{ - "uri": "kicad://project/current/layers", - "mimeType": "application/json", - "text": json.dumps({ - "count": len(layers), - "layers": layers - }, indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/layers", + "mimeType": "application/json", + "text": json.dumps({"count": len(layers), "layers": layers}, indent=2), + } + ] } else: return { - "contents": [{ - "uri": "kicad://project/current/layers", - "mimeType": "application/json", - "text": json.dumps({"count": 0, "layers": []}, indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/layers", + "mimeType": "application/json", + "text": json.dumps({"count": 0, "layers": []}, indent=2), + } + ] } + def _get_design_rules(interface) -> Dict[str, Any]: """Get design rule settings""" result = interface.design_rule_commands.get_design_rules({}) if result.get("success"): return { - "contents": [{ - "uri": "kicad://project/current/design-rules", - "mimeType": "application/json", - "text": json.dumps(result.get("rules", {}), indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/design-rules", + "mimeType": "application/json", + "text": json.dumps(result.get("rules", {}), indent=2), + } + ] } else: return { - "contents": [{ - "uri": "kicad://project/current/design-rules", - "mimeType": "text/plain", - "text": "Design rules not available" - }] + "contents": [ + { + "uri": "kicad://project/current/design-rules", + "mimeType": "text/plain", + "text": "Design rules not available", + } + ] } + def _get_drc_report(interface) -> Dict[str, Any]: """Get DRC violations""" result = interface.design_rule_commands.get_drc_violations({}) @@ -266,28 +284,35 @@ def _get_drc_report(interface) -> Dict[str, Any]: if result.get("success"): violations = result.get("violations", []) return { - "contents": [{ - "uri": "kicad://project/current/drc-report", - "mimeType": "application/json", - "text": json.dumps({ - "count": len(violations), - "violations": violations - }, indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/drc-report", + "mimeType": "application/json", + "text": json.dumps( + {"count": len(violations), "violations": violations}, indent=2 + ), + } + ] } else: return { - "contents": [{ - "uri": "kicad://project/current/drc-report", - "mimeType": "application/json", - "text": json.dumps({ - "count": 0, - "violations": [], - "message": "Run DRC first to get violations" - }, indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/drc-report", + "mimeType": "application/json", + "text": json.dumps( + { + "count": 0, + "violations": [], + "message": "Run DRC first to get violations", + }, + indent=2, + ), + } + ] } + def _get_board_preview(interface) -> 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": [{ - "uri": "kicad://board/preview.png", - "mimeType": "image/png", - "blob": image_data # Base64 encoded PNG - }] + "contents": [ + { + "uri": "kicad://board/preview.png", + "mimeType": "image/png", + "blob": image_data, # Base64 encoded PNG + } + ] } else: # Return a placeholder message return { - "contents": [{ - "uri": "kicad://board/preview.png", - "mimeType": "text/plain", - "text": "Board preview not available" - }] + "contents": [ + { + "uri": "kicad://board/preview.png", + "mimeType": "text/plain", + "text": "Board preview not available", + } + ] } diff --git a/python/schemas/__init__.py b/python/schemas/__init__.py index 420eb48..8fbf767 100644 --- a/python/schemas/__init__.py +++ b/python/schemas/__init__.py @@ -4,4 +4,4 @@ Tool schema definitions for KiCAD MCP Server from .tool_schemas import TOOL_SCHEMAS -__all__ = ['TOOL_SCHEMAS'] +__all__ = ["TOOL_SCHEMAS"] diff --git a/python/test_ipc_backend.py b/python/test_ipc_backend.py index 4b59385..c8f6ee1 100644 --- a/python/test_ipc_backend.py +++ b/python/test_ipc_backend.py @@ -13,6 +13,7 @@ Prerequisites: Usage: ./venv/bin/python python/test_ipc_backend.py """ + import sys import os @@ -23,17 +24,16 @@ 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(): """Test basic IPC connection to KiCAD.""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("TEST 1: IPC Connection") - print("="*60) + print("=" * 60) try: from kicad_api.ipc_backend import IPCBackend @@ -64,9 +64,9 @@ def test_connection(): def test_board_access(backend): """Test board access and component listing.""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("TEST 2: Board Access") - print("="*60) + print("=" * 60) try: board_api = backend.get_board() @@ -79,11 +79,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 @@ -95,9 +95,9 @@ def test_board_access(backend): def test_board_info(board_api): """Test getting board information.""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("TEST 3: Board Information") - print("="*60) + print("=" * 60) try: # Get board size @@ -136,28 +136,23 @@ def test_board_info(board_api): def test_realtime_track(board_api, interactive=False): """Test adding a track in real-time (appears immediately in KiCAD UI).""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("TEST 4: Real-time Track Addition") - print("="*60) + print("=" * 60) print("\nThis test will add a track that appears IMMEDIATELY in KiCAD UI.") print("Watch the KiCAD window!") 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: @@ -175,28 +170,22 @@ def test_realtime_track(board_api, interactive=False): def test_realtime_via(board_api, interactive=False): """Test adding a via in real-time (appears immediately in KiCAD UI).""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("TEST 5: Real-time Via Addition") - print("="*60) + print("=" * 60) print("\nThis test will add a via that appears IMMEDIATELY in KiCAD UI.") print("Watch the KiCAD window!") 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") @@ -213,26 +202,20 @@ def test_realtime_via(board_api, interactive=False): def test_realtime_text(board_api, interactive=False): """Test adding text in real-time.""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("TEST 6: Real-time Text Addition") - print("="*60) + print("=" * 60) print("\nThis test will add text that appears IMMEDIATELY in KiCAD UI.") 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") @@ -248,9 +231,9 @@ def test_realtime_text(board_api, interactive=False): def test_selection(board_api, interactive=False): """Test getting the current selection from KiCAD UI.""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("TEST 7: UI Selection") - print("="*60) + print("=" * 60) if interactive: print("\nSelect some items in KiCAD, then press Enter...") @@ -274,26 +257,26 @@ def test_selection(board_api, interactive=False): def run_all_tests(interactive=False): """Run all IPC backend tests.""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("KiCAD IPC Backend Test Suite") - print("="*60) + print("=" * 60) print("\nThis script tests real-time communication with KiCAD via IPC API.") print("Make sure KiCAD is running with a board open.\n") # Test connection backend = test_connection() if not backend: - print("\n" + "="*60) + print("\n" + "=" * 60) print("TESTS FAILED: Could not connect to KiCAD") - print("="*60) + print("=" * 60) return False # Test board access board_api = test_board_access(backend) if not board_api: - print("\n" + "="*60) + print("\n" + "=" * 60) print("TESTS FAILED: Could not access board") - print("="*60) + print("=" * 60) return False # Test board info @@ -307,9 +290,9 @@ def run_all_tests(interactive=False): # Test selection test_selection(board_api, interactive) - print("\n" + "="*60) + print("\n" + "=" * 60) print("TESTS COMPLETE") - print("="*60) + print("=" * 60) print("\nThe IPC backend is working! Changes appear in real-time.") print("No manual reload required - this is the power of the IPC API!") @@ -321,9 +304,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) diff --git a/python/tests/test_delete_schematic_component.py b/python/tests/test_delete_schematic_component.py index 528d033..cefeb55 100644 --- a/python/tests/test_delete_schematic_component.py +++ b/python/tests/test_delete_schematic_component.py @@ -5,6 +5,7 @@ Key regression: the handler previously used a line-by-line regex that required `(symbol` and `(lib_id` to appear on the *same* line. KiCAD's file writer puts them on *separate* lines, so every real-world delete returned "not found". """ + import re import sys import tempfile @@ -17,7 +18,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch" # Inline format (single line) – matches what tests previously used -PLACED_RESISTOR_INLINE = '''\ +PLACED_RESISTOR_INLINE = """\ (symbol (lib_id "Device:R") (at 50 50 0) (unit 1) (in_bom yes) (on_board yes) (dnp no) (uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") @@ -34,11 +35,11 @@ PLACED_RESISTOR_INLINE = '''\ (effects (font (size 1.27 1.27)) hide) ) ) -''' +""" # Multi-line format – as KiCAD's own file writer produces it. # (symbol and (lib_id are on separate lines, which broke the old regex. -PLACED_RESISTOR_MULTILINE = '''\ +PLACED_RESISTOR_MULTILINE = """\ \t(symbol \t\t(lib_id "Device:R") \t\t(at 50 50 0) @@ -64,10 +65,10 @@ PLACED_RESISTOR_MULTILINE = '''\ \t\t\t) \t\t) \t) -''' +""" # Multi-line power symbol – the exact scenario that was reported as broken. -PLACED_POWER_SYMBOL_MULTILINE = '''\ +PLACED_POWER_SYMBOL_MULTILINE = """\ \t(symbol \t\t(lib_id "power:VCC") \t\t(at 365.6 38.1 0) @@ -94,7 +95,7 @@ PLACED_POWER_SYMBOL_MULTILINE = '''\ \t\t\t) \t\t) \t) -''' +""" def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path: @@ -112,6 +113,7 @@ def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path: # Unit tests – regression proof for the old regex vs the new approach # --------------------------------------------------------------------------- + @pytest.mark.unit class TestDeleteDetectionRegex: """Verify that the new content-string pattern finds blocks in both formats.""" @@ -154,10 +156,12 @@ class TestDeleteDetectionRegex: # Integration tests – real file I/O using the handler # --------------------------------------------------------------------------- + @pytest.mark.integration class TestDeleteSchematicComponentIntegration: def _get_handler(self): from kicad_interface import KiCADInterface + iface = KiCADInterface.__new__(KiCADInterface) return iface._handle_delete_schematic_component diff --git a/python/tests/test_freerouting.py b/python/tests/test_freerouting.py index c338c80..287c560 100644 --- a/python/tests/test_freerouting.py +++ b/python/tests/test_freerouting.py @@ -47,9 +47,7 @@ def reset_pcbnew_mock(): @pytest.fixture def mock_board(): board = MagicMock() - board.GetFileName.return_value = ( - "/tmp/test_project/test.kicad_pcb" - ) + board.GetFileName.return_value = "/tmp/test_project/test.kicad_pcb" board.GetTracks.return_value = [] return board @@ -101,15 +99,14 @@ def _patch_no_runtime(): class TestCheckFreerouting: def test_no_java_no_docker(self, cmds): - with patch( - "commands.freerouting._find_java", return_value=None - ), patch( - "commands.freerouting._docker_available", - return_value=False, + with ( + patch("commands.freerouting._find_java", return_value=None), + patch( + "commands.freerouting._docker_available", + return_value=False, + ), ): - result = cmds.check_freerouting( - {"freeroutingJar": "/nonexistent.jar"} - ) + result = cmds.check_freerouting({"freeroutingJar": "/nonexistent.jar"}) assert result["success"] is True assert result["java"]["found"] is False assert result["docker"]["available"] is False @@ -119,48 +116,46 @@ class TestCheckFreerouting: def test_java_too_old_docker_available(self, cmds, tmp_path): jar = tmp_path / "freerouting.jar" jar.touch() - with patch( - "commands.freerouting._find_java", - return_value="/usr/bin/java", - ), patch( - "commands.freerouting._java_version_ok", - return_value=False, - ), patch( - "commands.freerouting._docker_available", - return_value=True, - ), patch( - "commands.freerouting.subprocess.run" - ) as mock_run: - mock_run.return_value = MagicMock( - stderr='openjdk version "17.0.1"', stdout="" - ) - result = cmds.check_freerouting( - {"freeroutingJar": str(jar)} - ) + with ( + patch( + "commands.freerouting._find_java", + return_value="/usr/bin/java", + ), + patch( + "commands.freerouting._java_version_ok", + return_value=False, + ), + patch( + "commands.freerouting._docker_available", + return_value=True, + ), + patch("commands.freerouting.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(stderr='openjdk version "17.0.1"', stdout="") + result = cmds.check_freerouting({"freeroutingJar": str(jar)}) assert result["ready"] is True assert result["execution_mode"] == "docker" def test_java_21_direct(self, cmds, tmp_path): jar = tmp_path / "freerouting.jar" jar.touch() - with patch( - "commands.freerouting._find_java", - return_value="/usr/bin/java", - ), patch( - "commands.freerouting._java_version_ok", - return_value=True, - ), patch( - "commands.freerouting._docker_available", - return_value=False, - ), patch( - "commands.freerouting.subprocess.run" - ) as mock_run: - mock_run.return_value = MagicMock( - stderr='openjdk version "21.0.1"', stdout="" - ) - result = cmds.check_freerouting( - {"freeroutingJar": str(jar)} - ) + with ( + patch( + "commands.freerouting._find_java", + return_value="/usr/bin/java", + ), + patch( + "commands.freerouting._java_version_ok", + return_value=True, + ), + patch( + "commands.freerouting._docker_available", + return_value=False, + ), + patch("commands.freerouting.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(stderr='openjdk version "21.0.1"', stdout="") + result = cmds.check_freerouting({"freeroutingJar": str(jar)}) assert result["ready"] is True assert result["execution_mode"] == "direct" @@ -198,9 +193,7 @@ class TestExportDsn: assert result["path"] == output def test_export_failure(self, cmds): - pcbnew_mock.ExportSpecctraDSN.side_effect = Exception( - "DSN error" - ) + pcbnew_mock.ExportSpecctraDSN.side_effect = Exception("DSN error") result = cmds.export_dsn({}) assert result["success"] is False assert "DSN error" in result["errorDetails"] @@ -213,9 +206,7 @@ class TestExportDsn: class TestImportSes: def test_no_board(self, cmds_no_board): - result = cmds_no_board.import_ses( - {"sesPath": "/tmp/test.ses"} - ) + result = cmds_no_board.import_ses({"sesPath": "/tmp/test.ses"}) assert result["success"] is False assert "No board" in result["message"] @@ -225,9 +216,7 @@ class TestImportSes: assert "Missing sesPath" in result["message"] def test_ses_file_not_found(self, cmds): - result = cmds.import_ses( - {"sesPath": "/nonexistent/test.ses"} - ) + result = cmds.import_ses({"sesPath": "/nonexistent/test.ses"}) assert result["success"] is False assert "not found" in result["message"] @@ -245,9 +234,7 @@ class TestImportSes: ses_file = tmp_path / "test.ses" ses_file.write_text("(session test)") - pcbnew_mock.ImportSpecctraSES.side_effect = Exception( - "SES error" - ) + pcbnew_mock.ImportSpecctraSES.side_effect = Exception("SES error") result = cmds.import_ses({"sesPath": str(ses_file)}) assert result["success"] is False assert "SES error" in result["errorDetails"] @@ -268,16 +255,12 @@ class TestAutoroute: jar = tmp_path / "freerouting.jar" jar.touch() with _patch_no_runtime(): - result = cmds.autoroute( - {"freeroutingJar": str(jar)} - ) + result = cmds.autoroute({"freeroutingJar": str(jar)}) assert result["success"] is False assert "No suitable Java runtime" in result["message"] def test_no_jar(self, cmds): - result = cmds.autoroute( - {"freeroutingJar": "/nonexistent/freerouting.jar"} - ) + result = cmds.autoroute({"freeroutingJar": "/nonexistent/freerouting.jar"}) assert result["success"] is False assert "JAR not found" in result["message"] @@ -286,21 +269,15 @@ class TestAutoroute: jar = tmp_path / "freerouting.jar" jar.touch() - pcbnew_mock.ExportSpecctraDSN.side_effect = Exception( - "export fail" - ) + pcbnew_mock.ExportSpecctraDSN.side_effect = Exception("export fail") with _patch_direct_java(): - result = cmds.autoroute( - {"freeroutingJar": str(jar)} - ) + result = cmds.autoroute({"freeroutingJar": str(jar)}) assert result["success"] is False assert "DSN export failed" in result["message"] @patch("commands.freerouting.subprocess.run") - def test_freerouting_timeout( - self, mock_run, cmds, tmp_path - ): + def test_freerouting_timeout(self, mock_run, cmds, tmp_path): import subprocess jar = tmp_path / "freerouting.jar" @@ -312,24 +289,19 @@ class TestAutoroute: dsn_file = board_dir / "test.dsn" cmds.board.GetFileName.return_value = str(board_file) - pcbnew_mock.ExportSpecctraDSN.side_effect = ( - lambda b, p: (dsn_file.write_text("(pcb)"), True)[1] - ) - mock_run.side_effect = subprocess.TimeoutExpired( - cmd="", timeout=10 - ) + pcbnew_mock.ExportSpecctraDSN.side_effect = lambda b, p: ( + dsn_file.write_text("(pcb)"), + True, + )[1] + mock_run.side_effect = subprocess.TimeoutExpired(cmd="", timeout=10) with _patch_direct_java(): - result = cmds.autoroute( - {"freeroutingJar": str(jar), "timeout": 10} - ) + result = cmds.autoroute({"freeroutingJar": str(jar), "timeout": 10}) assert result["success"] is False assert "timed out" in result["message"] @patch("commands.freerouting.subprocess.run") - def test_full_success_direct( - self, mock_run, cmds, tmp_path - ): + def test_full_success_direct(self, mock_run, cmds, tmp_path): jar = tmp_path / "freerouting.jar" jar.touch() board_dir = tmp_path / "project" @@ -341,12 +313,11 @@ class TestAutoroute: cmds.board.GetFileName.return_value = str(board_file) - pcbnew_mock.ExportSpecctraDSN.side_effect = ( - lambda b, p: (dsn_file.write_text("(pcb)"), True)[1] - ) - mock_run.return_value = MagicMock( - returncode=0, stdout="Routing completed", stderr="" - ) + pcbnew_mock.ExportSpecctraDSN.side_effect = lambda b, p: ( + dsn_file.write_text("(pcb)"), + True, + )[1] + mock_run.return_value = MagicMock(returncode=0, stdout="Routing completed", stderr="") ses_file.write_text("(session)") pcbnew_mock.ImportSpecctraSES.return_value = True @@ -357,9 +328,7 @@ class TestAutoroute: cmds.board.GetTracks.return_value = [track, track, via] with _patch_direct_java(): - result = cmds.autoroute( - {"freeroutingJar": str(jar)} - ) + result = cmds.autoroute({"freeroutingJar": str(jar)}) assert result["success"] is True assert result["mode"] == "direct" @@ -368,9 +337,7 @@ class TestAutoroute: assert "elapsed_seconds" in result @patch("commands.freerouting.subprocess.run") - def test_full_success_docker( - self, mock_run, cmds, tmp_path - ): + def test_full_success_docker(self, mock_run, cmds, tmp_path): jar = tmp_path / "freerouting.jar" jar.touch() board_dir = tmp_path / "project" @@ -382,24 +349,24 @@ class TestAutoroute: cmds.board.GetFileName.return_value = str(board_file) - pcbnew_mock.ExportSpecctraDSN.side_effect = ( - lambda b, p: (dsn_file.write_text("(pcb)"), True)[1] - ) - mock_run.return_value = MagicMock( - returncode=0, stdout="Routing completed", stderr="" - ) + pcbnew_mock.ExportSpecctraDSN.side_effect = lambda b, p: ( + dsn_file.write_text("(pcb)"), + True, + )[1] + mock_run.return_value = MagicMock(returncode=0, stdout="Routing completed", stderr="") ses_file.write_text("(session)") pcbnew_mock.ImportSpecctraSES.return_value = True cmds.board.GetTracks.return_value = [MagicMock()] - with _patch_docker_mode(), patch( - "commands.freerouting._find_docker", - return_value="/usr/bin/docker", + with ( + _patch_docker_mode(), + patch( + "commands.freerouting._find_docker", + return_value="/usr/bin/docker", + ), ): - result = cmds.autoroute( - {"freeroutingJar": str(jar)} - ) + result = cmds.autoroute({"freeroutingJar": str(jar)}) assert result["success"] is True assert result["mode"] == "docker" @@ -409,9 +376,7 @@ class TestAutoroute: assert "--rm" in call_args @patch("commands.freerouting.subprocess.run") - def test_freerouting_nonzero_exit( - self, mock_run, cmds, tmp_path - ): + def test_freerouting_nonzero_exit(self, mock_run, cmds, tmp_path): jar = tmp_path / "freerouting.jar" jar.touch() board_dir = tmp_path / "project" @@ -421,17 +386,14 @@ class TestAutoroute: dsn_file = board_dir / "test.dsn" cmds.board.GetFileName.return_value = str(board_file) - pcbnew_mock.ExportSpecctraDSN.side_effect = ( - lambda b, p: (dsn_file.write_text("(pcb)"), True)[1] - ) - mock_run.return_value = MagicMock( - returncode=1, stdout="", stderr="OutOfMemoryError" - ) + pcbnew_mock.ExportSpecctraDSN.side_effect = lambda b, p: ( + dsn_file.write_text("(pcb)"), + True, + )[1] + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="OutOfMemoryError") with _patch_direct_java(): - result = cmds.autoroute( - {"freeroutingJar": str(jar)} - ) + result = cmds.autoroute({"freeroutingJar": str(jar)}) assert result["success"] is False assert "exited with code 1" in result["message"] @@ -451,10 +413,13 @@ class TestFindJava: assert _find_java() == "/usr/bin/java" def test_none_when_not_found(self): - with patch( - "commands.freerouting.shutil.which", - return_value=None, - ), patch("os.path.isfile", return_value=False): + with ( + patch( + "commands.freerouting.shutil.which", + return_value=None, + ), + patch("os.path.isfile", return_value=False), + ): assert _find_java() is None @@ -462,18 +427,14 @@ class TestFindDocker: def test_finds_docker(self): with patch( "commands.freerouting.shutil.which", - side_effect=lambda x: "/usr/bin/docker" - if x == "docker" - else None, + side_effect=lambda x: "/usr/bin/docker" if x == "docker" else None, ): assert _find_docker() == "/usr/bin/docker" def test_finds_podman(self): with patch( "commands.freerouting.shutil.which", - side_effect=lambda x: "/usr/bin/podman" - if x == "podman" - else None, + side_effect=lambda x: "/usr/bin/podman" if x == "podman" else None, ): assert _find_docker() == "/usr/bin/podman" @@ -487,12 +448,13 @@ class TestFindDocker: class TestDockerAvailable: def test_docker_found(self): - with patch( - "commands.freerouting._find_docker", - return_value="/usr/bin/docker", - ), patch( - "commands.freerouting.subprocess.run" - ) as mock_run: + with ( + patch( + "commands.freerouting._find_docker", + return_value="/usr/bin/docker", + ), + patch("commands.freerouting.subprocess.run") as mock_run, + ): mock_run.return_value = MagicMock(returncode=0) assert _docker_available() is True @@ -504,33 +466,26 @@ class TestDockerAvailable: assert _docker_available() is False def test_docker_not_running(self): - with patch( - "commands.freerouting._find_docker", - return_value="/usr/bin/docker", - ), patch( - "commands.freerouting.subprocess.run" - ) as mock_run: + with ( + patch( + "commands.freerouting._find_docker", + return_value="/usr/bin/docker", + ), + patch("commands.freerouting.subprocess.run") as mock_run, + ): mock_run.return_value = MagicMock(returncode=1) assert _docker_available() is False class TestJavaVersionOk: def test_java_21(self): - with patch( - "commands.freerouting.subprocess.run" - ) as mock_run: - mock_run.return_value = MagicMock( - stderr='openjdk version "21.0.1"', stdout="" - ) + with patch("commands.freerouting.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stderr='openjdk version "21.0.1"', stdout="") assert _java_version_ok("/usr/bin/java") is True def test_java_17(self): - with patch( - "commands.freerouting.subprocess.run" - ) as mock_run: - mock_run.return_value = MagicMock( - stderr='openjdk version "17.0.18"', stdout="" - ) + with patch("commands.freerouting.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stderr='openjdk version "17.0.18"', stdout="") assert _java_version_ok("/usr/bin/java") is False def test_java_error(self): diff --git a/python/tests/test_schematic_analysis.py b/python/tests/test_schematic_analysis.py index c36290a..a0cd46d 100644 --- a/python/tests/test_schematic_analysis.py +++ b/python/tests/test_schematic_analysis.py @@ -256,18 +256,14 @@ class TestFindOverlappingElements: def test_overlapping_symbols_detected(self): # Two resistors at nearly the same position — bboxes fully overlap - extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp( - "R2", 100.1, 100 - ) + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100.1, 100) tmp = _make_temp_schematic(extra) result = find_overlapping_elements(tmp, tolerance=0.5) assert result["totalOverlaps"] >= 1 assert len(result["overlappingSymbols"]) >= 1 def test_well_separated_symbols_not_flagged(self): - extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp( - "R2", 200, 200 - ) + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 200, 200) tmp = _make_temp_schematic(extra) result = find_overlapping_elements(tmp, tolerance=0.5) assert result["totalOverlaps"] == 0 @@ -294,9 +290,7 @@ class TestFindOverlappingElements: """ # R1 at y=100, R2 at y=105 — pin spans [96.19, 103.81] and [101.19, 108.81] # These overlap in Y from 101.19 to 103.81 - extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp( - "R2", 100, 105 - ) + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100, 105) tmp = _make_temp_schematic(extra) result = find_overlapping_elements(tmp, tolerance=0.5) assert result["totalOverlaps"] >= 1, ( @@ -310,9 +304,7 @@ class TestFindOverlappingElements: R pins at y ±3.81, but different X positions far enough apart. """ - extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp( - "R2", 110, 100 - ) + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 110, 100) tmp = _make_temp_schematic(extra) result = find_overlapping_elements(tmp, tolerance=0.5) assert result["totalOverlaps"] == 0 @@ -419,9 +411,7 @@ class TestIntegrationFindWiresCrossingSymbols: tmp = _make_temp_schematic(r_at_100 + r_at_200 + wire) result = find_wires_crossing_symbols(tmp) # The wire must not be reported against the far-away R? at (200, 100) - collisions_at_200 = [ - c for c in result if abs(c["component"]["position"]["x"] - 200) < 0.5 - ] + collisions_at_200 = [c for c in result if abs(c["component"]["position"]["x"] - 200) < 0.5] assert len(collisions_at_200) == 0, ( "Wire at x≈100 must not be flagged against the R? at x=200; " "likely caused by reference-lookup always returning the first 'R?'" @@ -458,9 +448,7 @@ class TestIntegrationFindWiresCrossingSymbols: tmp = _make_temp_schematic(extra) result = find_wires_crossing_symbols(tmp) d1_crossings = [c for c in result if c["component"]["reference"] == "D1"] - assert ( - len(d1_crossings) == 0 - ), "Wire terminating at pin from outside should not be flagged" + assert len(d1_crossings) == 0, "Wire terminating at pin from outside should not be flagged" def test_wire_shorts_component_pins_detected_as_collision(self): """Regression: a wire connecting pin1→pin2 of the same component @@ -825,9 +813,7 @@ class TestComputeSymbolBboxWithGraphics: } graphics_points = [(-1.016, -2.54), (1.016, 2.54)] - bbox = _compute_symbol_bbox_direct( - sym, pin_defs, graphics_points=graphics_points - ) + bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) assert bbox is not None min_x, min_y, max_x, max_y = bbox # X should come from rectangle: 100 ± 1.016 @@ -902,9 +888,7 @@ class TestComputeSymbolBboxWithGraphics: # Rectangle corners in local coords graphics_points = [(-1.016, -2.54), (1.016, 2.54)] - bbox = _compute_symbol_bbox_direct( - sym, pin_defs, graphics_points=graphics_points - ) + bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) assert bbox is not None min_x, min_y, max_x, max_y = bbox # After 90° rotation, X and Y swap roles @@ -935,9 +919,7 @@ class TestIntegrationGraphicsBbox: assert len(graphics_points) >= 2, "Should have extracted rectangle points" - bbox = _compute_symbol_bbox_direct( - r1, pin_defs, graphics_points=graphics_points - ) + bbox = _compute_symbol_bbox_direct(r1, pin_defs, graphics_points=graphics_points) assert bbox is not None min_x, min_y, max_x, max_y = bbox # Rectangle is ±1.016 in X, NOT ±1.5 from degenerate expansion diff --git a/python/tests/test_schematic_tools.py b/python/tests/test_schematic_tools.py index bca8419..2c5f51c 100644 --- a/python/tests/test_schematic_tools.py +++ b/python/tests/test_schematic_tools.py @@ -8,6 +8,7 @@ Covers: (tested by calling _handle_* methods on a lightweight stub that avoids importing the full kicad_interface module). """ + import shutil import tempfile from pathlib import Path @@ -43,9 +44,7 @@ _WIRE_SCH = """\ def _write_temp_sch(content: str) -> Path: """Write *content* to a temp file and return its Path.""" - tmp = tempfile.NamedTemporaryFile( - suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8" - ) + tmp = tempfile.NamedTemporaryFile(suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8") tmp.write(content) tmp.close() return Path(tmp.name) @@ -66,9 +65,7 @@ class TestDeleteWireUnit: self.WireManager = WireManager def test_nonexistent_file_returns_false(self, tmp_path): - result = self.WireManager.delete_wire( - tmp_path / "nope.kicad_sch", [0, 0], [10, 10] - ) + result = self.WireManager.delete_wire(tmp_path / "nope.kicad_sch", [0, 0], [10, 10]) assert result is False def test_no_matching_wire_returns_false(self, tmp_path): @@ -100,9 +97,7 @@ class TestDeleteLabelUnit: self.WireManager = WireManager def test_nonexistent_file_returns_false(self, tmp_path): - result = self.WireManager.delete_label( - tmp_path / "nope.kicad_sch", "VCC" - ) + result = self.WireManager.delete_label(tmp_path / "nope.kicad_sch", "VCC") assert result is False def test_missing_label_returns_false(self, tmp_path): @@ -114,9 +109,7 @@ class TestDeleteLabelUnit: def test_position_kwarg_accepted(self, tmp_path): sch = tmp_path / "test.kicad_sch" shutil.copy(EMPTY_SCH, sch) - result = self.WireManager.delete_label( - sch, "VCC", position=[10.0, 20.0], tolerance=0.5 - ) + result = self.WireManager.delete_label(sch, "VCC", position=[10.0, 20.0], tolerance=0.5) assert result is False @@ -145,9 +138,7 @@ class TestDeleteWireIntegration: wire_items = [ item for item in data - if isinstance(item, list) - and item - and item[0] == sexpdata.Symbol("wire") + if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire") ] assert wire_items == [], "Wire should have been removed from the file" @@ -165,18 +156,14 @@ class TestDeleteWireIntegration: sch.write_text(_WIRE_SCH, encoding="utf-8") # Coordinates differ by 0.3 mm — within default tolerance of 0.5 - result = self.WireManager.delete_wire( - sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5 - ) + result = self.WireManager.delete_wire(sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5) assert result is True def test_outside_tolerance_no_delete(self, tmp_path): sch = tmp_path / "test.kicad_sch" sch.write_text(_WIRE_SCH, encoding="utf-8") - result = self.WireManager.delete_wire( - sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0 - ) + result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0) # tolerance=0.0 means exact float equality — may still match on most # platforms, but the key thing is that a *distant* miss is rejected sch2 = tmp_path / "test2.kicad_sch" @@ -200,9 +187,7 @@ class TestDeleteWireIntegration: labels = [ item for item in data - if isinstance(item, list) - and item - and item[0] == sexpdata.Symbol("label") + if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label") ] assert len(labels) == 1 @@ -230,9 +215,7 @@ class TestDeleteLabelIntegration: labels = [ item for item in data - if isinstance(item, list) - and item - and item[0] == sexpdata.Symbol("label") + if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label") ] assert labels == [], "Label should have been removed" @@ -247,9 +230,7 @@ class TestDeleteLabelIntegration: sch = tmp_path / "test.kicad_sch" sch.write_text(_WIRE_SCH, encoding="utf-8") - result = self.WireManager.delete_label( - sch, "VCC", position=[99.0, 99.0], tolerance=0.5 - ) + result = self.WireManager.delete_label(sch, "VCC", position=[99.0, 99.0], tolerance=0.5) assert result is False def test_wire_preserved_after_label_deletion(self, tmp_path): @@ -260,9 +241,7 @@ class TestDeleteLabelIntegration: wires = [ item for item in data - if isinstance(item, list) - and item - and item[0] == sexpdata.Symbol("wire") + if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire") ] assert len(wires) == 1 @@ -387,9 +366,7 @@ class TestHandlerParamValidation: params = {} schematic_path = params.get("schematicPath") result = ( - {"success": False, "message": "schematicPath is required"} - if not schematic_path - else {} + {"success": False, "message": "schematicPath is required"} if not schematic_path else {} ) assert result["success"] is False diff --git a/python/tests/test_wire_connectivity.py b/python/tests/test_wire_connectivity.py index e8c7028..6df3c8c 100644 --- a/python/tests/test_wire_connectivity.py +++ b/python/tests/test_wire_connectivity.py @@ -265,18 +265,14 @@ class TestCoreLogic: def test_find_connected_wires_no_wire_at_point(self): wires = [[(0, 0), (10_000, 0)]] adjacency, iu_to_wires = _build_adjacency(wires) - visited, net_points = _find_connected_wires( - 5.0, 0.0, wires, iu_to_wires, adjacency - ) + visited, net_points = _find_connected_wires(5.0, 0.0, wires, iu_to_wires, adjacency) assert visited is None assert net_points is None def test_find_connected_wires_single_wire(self): wires = [[(0, 0), (10_000, 0)]] adjacency, iu_to_wires = _build_adjacency(wires) - visited, net_points = _find_connected_wires( - 0.0, 0.0, wires, iu_to_wires, adjacency - ) + visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) assert visited == {0} assert (0, 0) in net_points assert (10_000, 0) in net_points @@ -289,9 +285,7 @@ class TestCoreLogic: [(20_000, 0), (30_000, 0)], ] adjacency, iu_to_wires = _build_adjacency(wires) - visited, net_points = _find_connected_wires( - 0.0, 0.0, wires, iu_to_wires, adjacency - ) + visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) assert visited == {0, 1, 2} def test_find_connected_wires_does_not_cross_gap(self): diff --git a/python/tests/test_wire_junction_changes.py b/python/tests/test_wire_junction_changes.py index 1becfee..bcf8b34 100644 --- a/python/tests/test_wire_junction_changes.py +++ b/python/tests/test_wire_junction_changes.py @@ -44,11 +44,7 @@ def _parse_sch(path: Path): def _find_elements(sch_data, tag: str): """Return all top-level S-expression elements with the given tag Symbol.""" - return [ - item - for item in sch_data - if isinstance(item, list) and item and item[0] == Symbol(tag) - ] + return [item for item in sch_data if isinstance(item, list) and item and item[0] == Symbol(tag)] # --------------------------------------------------------------------------- @@ -188,9 +184,7 @@ class TestHandleAddSchematicWireValidation: assert "Schematic path" in result["message"] def test_missing_waypoints(self): - result = self.iface._handle_add_schematic_wire( - {"schematicPath": "/tmp/x.kicad_sch"} - ) + result = self.iface._handle_add_schematic_wire({"schematicPath": "/tmp/x.kicad_sch"}) assert result["success"] is False assert "waypoint" in result["message"].lower() @@ -334,9 +328,7 @@ class TestPinSnapping: def __init__(self): pass - skip_mod.Schematic = lambda path: type( - "FakeSch", (), {"symbol": [FakeSymbol()]} - )() + skip_mod.Schematic = lambda path: type("FakeSch", (), {"symbol": [FakeSymbol()]})() mock_pins.return_value = {"1": [10.0, 20.0], "2": [10.0, 30.0]} @@ -350,9 +342,7 @@ class TestPinSnapping: with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): iface = KiCADInterface.__new__(KiCADInterface) - with patch( - "commands.wire_manager.WireManager.add_wire", return_value=True - ) as mw: + with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw: result = iface._handle_add_schematic_wire( { "schematicPath": str(self.sch_path), @@ -371,9 +361,10 @@ class TestPinSnapping: def test_snap_disabled_passes_original_coords(self): """With snapToPins=False the handler should not load PinLocator at all.""" - with patch( - "commands.wire_manager.WireManager.add_wire", return_value=True - ) as mw, patch("commands.pin_locator.PinLocator") as mock_locator_cls: + with ( + patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw, + patch("commands.pin_locator.PinLocator") as mock_locator_cls, + ): result = self.iface._handle_add_schematic_wire( { "schematicPath": str(self.sch_path), @@ -389,9 +380,7 @@ class TestPinSnapping: @patch("commands.wire_manager.WireManager.add_wire", return_value=True) def test_snap_miss_leaves_coords_unchanged(self, mock_wire): """Point beyond tolerance should not be snapped.""" - with patch( - "commands.wire_manager.WireManager.add_wire", return_value=True - ) as mw: + with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw: result = self.iface._handle_add_schematic_wire( { "schematicPath": str(self.sch_path), @@ -409,9 +398,7 @@ class TestPinSnapping: def test_intermediate_waypoints_not_snapped(self, mock_poly): """Middle waypoints must remain unchanged even with snapToPins=True.""" mid = [50.0, 50.0] - with patch( - "commands.wire_manager.WireManager.add_polyline_wire", return_value=True - ) as mp: + with patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) as mp: result = self.iface._handle_add_schematic_wire( { "schematicPath": str(self.sch_path), @@ -451,9 +438,7 @@ class TestHandleAddSchematicJunction: assert "Schematic path" in result["message"] def test_missing_position(self): - result = self.iface._handle_add_schematic_junction( - {"schematicPath": "/tmp/x.kicad_sch"} - ) + result = self.iface._handle_add_schematic_junction({"schematicPath": "/tmp/x.kicad_sch"}) assert result["success"] is False assert "Position" in result["message"] @@ -551,9 +536,7 @@ class TestIntegrationWireManager: assert ok is True data = _parse_sch(sch) wires = _find_elements(data, "wire") - assert ( - len(wires) == 3 - ), f"4 waypoints should produce 3 wire segments, got {len(wires)}" + assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}" def test_add_junction_writes_junction_element(self, sch): from commands.wire_manager import WireManager @@ -575,14 +558,10 @@ class TestIntegrationWireManager: data = _parse_sch(sch) wire = _find_elements(data, "wire")[0] pts = [ - item - for item in wire - if isinstance(item, list) and item and item[0] == Symbol("pts") + item for item in wire if isinstance(item, list) and item and item[0] == Symbol("pts") ][0] xy_entries = [ - item - for item in pts - if isinstance(item, list) and item and item[0] == Symbol("xy") + item for item in pts if isinstance(item, list) and item and item[0] == Symbol("xy") ] assert xy_entries[0][1] == 5.0 assert xy_entries[0][2] == 7.5 @@ -644,9 +623,7 @@ class TestIntegrationHandlerEndToEnd: assert result["success"] is True data = _parse_sch(self.sch) wires = _find_elements(data, "wire") - assert ( - len(wires) == 3 - ), f"4 waypoints should produce 3 wire segments, got {len(wires)}" + assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}" # --------------------------------------------------------------------------- @@ -812,9 +789,7 @@ class TestMakeWireSexp: def test_custom_stroke(self): from commands.wire_manager import WireManager - sexp = WireManager._make_wire_sexp( - [0, 0], [5, 0], stroke_width=0.5, stroke_type="dash" - ) + sexp = WireManager._make_wire_sexp([0, 0], [5, 0], stroke_width=0.5, stroke_type="dash") parsed = WireManager._parse_wire(sexp) assert parsed is not None _, _, width, stype = parsed @@ -922,9 +897,7 @@ class TestBreakWiresAtPoint: data = [Symbol("kicad_sch")] data.append( - WireManager._make_wire_sexp( - [0, 0], [20, 0], stroke_width=0.5, stroke_type="dash" - ) + WireManager._make_wire_sexp([0, 0], [20, 0], stroke_width=0.5, stroke_type="dash") ) data.append([Symbol("sheet_instances")]) splits = WireManager._break_wires_at_point(data, [10, 0]) @@ -998,9 +971,7 @@ class TestIntegrationTJunction: WireManager.add_junction(sch, [15, 0]) data = _parse_sch(sch) wires = _find_elements(data, "wire") - assert ( - len(wires) == 2 - ), f"Expected 2 wires after junction split, got {len(wires)}" + assert len(wires) == 2, f"Expected 2 wires after junction split, got {len(wires)}" junctions = _find_elements(data, "junction") assert len(junctions) == 1 @@ -1012,9 +983,7 @@ class TestIntegrationTJunction: WireManager.add_junction(sch, [20, 0]) data = _parse_sch(sch) wires = _find_elements(data, "wire") - assert ( - len(wires) == 1 - ), f"Expected 1 wire (no split at endpoint), got {len(wires)}" + assert len(wires) == 1, f"Expected 1 wire (no split at endpoint), got {len(wires)}" def test_polyline_breaks_existing_wire(self, sch): """Polyline whose start/end hits mid-wire should break it.""" diff --git a/python/utils/kicad_process.py b/python/utils/kicad_process.py index d0d6df6..88ac20c 100644 --- a/python/utils/kicad_process.py +++ b/python/utils/kicad_process.py @@ -3,77 +3,82 @@ KiCAD Process Management Utilities Detects if KiCAD is running and provides auto-launch functionality. """ -import os -import subprocess -import logging -import platform -import time -import ctypes -from ctypes import wintypes -from pathlib import Path + +import os +import subprocess +import logging +import platform +import time +import ctypes +from ctypes import wintypes +from pathlib import Path from typing import Optional, List logger = logging.getLogger(__name__) -class KiCADProcessManager: - """Manages KiCAD process detection and launching""" - - @staticmethod - def _windows_list_processes() -> List[dict]: - """List running processes on Windows using Toolhelp API.""" - processes: List[dict] = [] - try: - TH32CS_SNAPPROCESS = 0x00000002 - try: - ulong_ptr = wintypes.ULONG_PTR # type: ignore[attr-defined] - except AttributeError: - ulong_ptr = ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong - - class PROCESSENTRY32W(ctypes.Structure): - _fields_ = [ - ("dwSize", wintypes.DWORD), - ("cntUsage", wintypes.DWORD), - ("th32ProcessID", wintypes.DWORD), - ("th32DefaultHeapID", ulong_ptr), - ("th32ModuleID", wintypes.DWORD), - ("cntThreads", wintypes.DWORD), - ("th32ParentProcessID", wintypes.DWORD), - ("pcPriClassBase", wintypes.LONG), - ("dwFlags", wintypes.DWORD), - ("szExeFile", wintypes.WCHAR * wintypes.MAX_PATH), - ] - - CreateToolhelp32Snapshot = ctypes.windll.kernel32.CreateToolhelp32Snapshot - Process32FirstW = ctypes.windll.kernel32.Process32FirstW - Process32NextW = ctypes.windll.kernel32.Process32NextW - CloseHandle = ctypes.windll.kernel32.CloseHandle - - snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) - if snapshot == wintypes.HANDLE(-1).value: - return processes - - entry = PROCESSENTRY32W() - entry.dwSize = ctypes.sizeof(PROCESSENTRY32W) - - if Process32FirstW(snapshot, ctypes.byref(entry)): - while True: - processes.append({ - "pid": str(entry.th32ProcessID), - "name": entry.szExeFile, - "command": entry.szExeFile - }) - if not Process32NextW(snapshot, ctypes.byref(entry)): - break - - CloseHandle(snapshot) - except Exception as e: - logger.error(f"Error listing Windows processes: {e}") - - return processes - - @staticmethod - def is_running() -> bool: +class KiCADProcessManager: + """Manages KiCAD process detection and launching""" + + @staticmethod + def _windows_list_processes() -> List[dict]: + """List running processes on Windows using Toolhelp API.""" + processes: List[dict] = [] + try: + TH32CS_SNAPPROCESS = 0x00000002 + try: + ulong_ptr = wintypes.ULONG_PTR # type: ignore[attr-defined] + except AttributeError: + ulong_ptr = ( + ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong + ) + + class PROCESSENTRY32W(ctypes.Structure): + _fields_ = [ + ("dwSize", wintypes.DWORD), + ("cntUsage", wintypes.DWORD), + ("th32ProcessID", wintypes.DWORD), + ("th32DefaultHeapID", ulong_ptr), + ("th32ModuleID", wintypes.DWORD), + ("cntThreads", wintypes.DWORD), + ("th32ParentProcessID", wintypes.DWORD), + ("pcPriClassBase", wintypes.LONG), + ("dwFlags", wintypes.DWORD), + ("szExeFile", wintypes.WCHAR * wintypes.MAX_PATH), + ] + + CreateToolhelp32Snapshot = ctypes.windll.kernel32.CreateToolhelp32Snapshot + Process32FirstW = ctypes.windll.kernel32.Process32FirstW + Process32NextW = ctypes.windll.kernel32.Process32NextW + CloseHandle = ctypes.windll.kernel32.CloseHandle + + snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) + if snapshot == wintypes.HANDLE(-1).value: + return processes + + entry = PROCESSENTRY32W() + entry.dwSize = ctypes.sizeof(PROCESSENTRY32W) + + if Process32FirstW(snapshot, ctypes.byref(entry)): + while True: + processes.append( + { + "pid": str(entry.th32ProcessID), + "name": entry.szExeFile, + "command": entry.szExeFile, + } + ) + if not Process32NextW(snapshot, ctypes.byref(entry)): + break + + CloseHandle(snapshot) + except Exception as e: + logger.error(f"Error listing Windows processes: {e}") + + return processes + + @staticmethod + def is_running() -> bool: """ Check if KiCAD is currently running @@ -87,27 +92,21 @@ class KiCADProcessManager: # Check for actual pcbnew/kicad binaries (not python scripts) # Use exact process name matching to avoid matching our own kicad_interface.py result = subprocess.run( - ["pgrep", "-x", "pcbnew|kicad"], - capture_output=True, - text=True + ["pgrep", "-x", "pcbnew|kicad"], capture_output=True, text=True ) if result.returncode == 0: return True # Also check with -f for full path matching, but exclude our script result = subprocess.run( - ["pgrep", "-f", "/pcbnew|/kicad"], - capture_output=True, - text=True + ["pgrep", "-f", "/pcbnew|/kicad"], capture_output=True, text=True ) # Double-check it's not our own process if result.returncode == 0: - pids = result.stdout.strip().split('\n') + pids = result.stdout.strip().split("\n") for pid in pids: try: cmdline = subprocess.run( - ["ps", "-p", pid, "-o", "command="], - capture_output=True, - text=True + ["ps", "-p", pid, "-o", "command="], capture_output=True, text=True ) if "kicad_interface.py" not in cmdline.stdout: return True @@ -117,18 +116,16 @@ class KiCADProcessManager: elif system == "Darwin": # macOS result = subprocess.run( - ["pgrep", "-f", "KiCad|pcbnew"], - capture_output=True, - text=True + ["pgrep", "-f", "KiCad|pcbnew"], capture_output=True, text=True ) return result.returncode == 0 - elif system == "Windows": - processes = KiCADProcessManager._windows_list_processes() - for proc in processes: - name = (proc.get("name") or "").lower() - if name in ("pcbnew.exe", "kicad.exe"): - return True + elif system == "Windows": + processes = KiCADProcessManager._windows_list_processes() + for proc in processes: + name = (proc.get("name") or "").lower() + if name in ("pcbnew.exe", "kicad.exe"): + return True return False else: @@ -151,14 +148,14 @@ class KiCADProcessManager: # Try to find executable in PATH first for cmd in ["pcbnew", "kicad"]: - result = subprocess.run( - ["which", cmd] if system != "Windows" else ["where", cmd], - capture_output=True, - text=True, - encoding="mbcs" if system == "Windows" else None, - errors="ignore" if system == "Windows" else None, - timeout=5 if system == "Windows" else None - ) + result = subprocess.run( + ["which", cmd] if system != "Windows" else ["where", cmd], + capture_output=True, + text=True, + encoding="mbcs" if system == "Windows" else None, + errors="ignore" if system == "Windows" else None, + timeout=5 if system == "Windows" else None, + ) if result.returncode == 0: path = result.stdout.strip().split("\n")[0] logger.info(f"Found KiCAD executable: {path}") @@ -232,7 +229,7 @@ class KiCADProcessManager: cmd, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL + stderr=subprocess.DEVNULL, ) else: # Unix: Use nohup or start in background @@ -240,7 +237,7 @@ class KiCADProcessManager: cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - start_new_session=True + start_new_session=True, ) # Wait for process to start @@ -275,28 +272,30 @@ class KiCADProcessManager: try: if system in ["Linux", "Darwin"]: - result = subprocess.run( - ["ps", "aux"], - capture_output=True, - text=True - ) + result = subprocess.run(["ps", "aux"], capture_output=True, text=True) for line in result.stdout.split("\n"): # Only match actual KiCAD binaries, not our MCP server processes - if ("pcbnew" in line.lower() or "kicad" in line.lower()) and "kicad_interface.py" not in line and "grep" not in line: + if ( + ("pcbnew" in line.lower() or "kicad" in line.lower()) + and "kicad_interface.py" not in line + and "grep" not in line + ): # More specific check: must have /pcbnew or /kicad in the path if "/pcbnew" in line or "/kicad" in line or "KiCad.app" in line: parts = line.split() if len(parts) >= 11: - processes.append({ - "pid": parts[1], - "name": parts[10], - "command": " ".join(parts[10:]) - }) + processes.append( + { + "pid": parts[1], + "name": parts[10], + "command": " ".join(parts[10:]), + } + ) - elif system == "Windows": - for proc in KiCADProcessManager._windows_list_processes(): - name = (proc.get("name") or "").lower() - if "pcbnew" in name or "kicad" in name: + elif system == "Windows": + for proc in KiCADProcessManager._windows_list_processes(): + name = (proc.get("name") or "").lower() + if "pcbnew" in name or "kicad" in name: processes.append(proc) except Exception as e: @@ -304,6 +303,7 @@ class KiCADProcessManager: return processes + def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: bool = True) -> dict: """ Check if KiCAD is running and optionally launch it @@ -325,7 +325,7 @@ def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: boo "running": True, "launched": False, "processes": processes, - "message": "KiCAD is already running" + "message": "KiCAD is already running", } if not auto_launch: @@ -333,7 +333,7 @@ def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: boo "running": False, "launched": False, "processes": [], - "message": "KiCAD is not running (auto-launch disabled)" + "message": "KiCAD is not running (auto-launch disabled)", } # Try to launch @@ -345,5 +345,5 @@ def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: boo "launched": success, "processes": manager.get_process_info() if success else [], "message": "KiCAD launched successfully" if success else "Failed to launch KiCAD", - "project": str(project_path) if project_path else None + "project": str(project_path) if project_path else None, } diff --git a/python/utils/platform_helper.py b/python/utils/platform_helper.py index fbc0426..8393153 100644 --- a/python/utils/platform_helper.py +++ b/python/utils/platform_helper.py @@ -4,6 +4,7 @@ Platform detection and path utilities for cross-platform compatibility This module provides helpers for detecting the current platform and getting appropriate paths for KiCAD, configuration, logs, etc. """ + import os import platform import sys @@ -74,19 +75,23 @@ class PlatformHelper: # Also check based on Python version py_version = f"{sys.version_info.major}.{sys.version_info.minor}" - candidates.extend([ - Path(f"/usr/lib/python{py_version}/dist-packages/kicad"), - Path(f"/usr/local/lib/python{py_version}/dist-packages/kicad"), - ]) + candidates.extend( + [ + Path(f"/usr/lib/python{py_version}/dist-packages/kicad"), + Path(f"/usr/local/lib/python{py_version}/dist-packages/kicad"), + ] + ) # Check system Python dist-packages (modern KiCAD 9+ on Ubuntu/Debian) # This is where pcbnew.py typically lives on modern systems - candidates.extend([ - Path(f"/usr/lib/python3/dist-packages"), - Path(f"/usr/lib/python{py_version}/dist-packages"), - Path(f"/usr/local/lib/python3/dist-packages"), - Path(f"/usr/local/lib/python{py_version}/dist-packages"), - ]) + candidates.extend( + [ + Path(f"/usr/lib/python3/dist-packages"), + Path(f"/usr/lib/python{py_version}/dist-packages"), + Path(f"/usr/local/lib/python3/dist-packages"), + Path(f"/usr/local/lib/python{py_version}/dist-packages"), + ] + ) paths = [p for p in candidates if p.exists()] @@ -102,7 +107,17 @@ class PlatformHelper: for kicad_app in kicad_app_paths: if kicad_app.exists(): for version in ["3.9", "3.10", "3.11", "3.12", "3.13"]: - path = kicad_app / "Contents" / "Frameworks" / "Python.framework" / "Versions" / version / "lib" / f"python{version}" / "site-packages" + path = ( + kicad_app + / "Contents" + / "Frameworks" + / "Python.framework" + / "Versions" + / version + / "lib" + / f"python{version}" + / "site-packages" + ) if path.exists(): paths.append(path) @@ -110,7 +125,7 @@ class PlatformHelper: homebrew_paths = [ Path("/opt/homebrew/lib/python3.12/site-packages"), # Apple Silicon Path("/opt/homebrew/lib/python3.11/site-packages"), - Path("/usr/local/lib/python3.12/site-packages"), # Intel Mac + Path("/usr/local/lib/python3.12/site-packages"), # Intel Mac Path("/usr/local/lib/python3.11/site-packages"), ] for hp in homebrew_paths: @@ -161,7 +176,10 @@ class PlatformHelper: patterns = [ "/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", "/Applications/KiCAD/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", - str(Path.home() / "Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym"), + str( + Path.home() + / "Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym" + ), ] # Add user library paths for all platforms @@ -301,6 +319,7 @@ def detect_platform() -> dict: if __name__ == "__main__": # Quick test/diagnostic import json + info = detect_platform() print("Platform Information:") print(json.dumps(info, indent=2)) diff --git a/tests/test_platform_helper.py b/tests/test_platform_helper.py index f242999..0742e98 100644 --- a/tests/test_platform_helper.py +++ b/tests/test_platform_helper.py @@ -3,6 +3,7 @@ Tests for platform_helper utility These are unit tests that work on all platforms. """ + import pytest import platform from pathlib import Path @@ -176,6 +177,7 @@ class TestKiCADPathDetection: PlatformHelper.add_kicad_to_python_path() try: import pcbnew + # If we get here, pcbnew is available assert pcbnew is not None version = pcbnew.GetBuildVersion() From c44bd9205d5df2907d3aa416a0af95acb8287b36 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 29 Mar 2026 13:02:24 +0100 Subject: [PATCH 38/74] style: sort Python imports with isort Add isort configuration (profile=black, line_length=100) to pyproject.toml, add isort pre-commit hook, and auto-sort imports across all Python source files. Co-Authored-By: Claude Opus 4.6 --- .pre-commit-config.yaml | 6 ++ pyproject.toml | 4 + python/commands/__init__.py | 4 +- python/commands/board/__init__.py | 10 ++- python/commands/board/layers.py | 5 +- python/commands/board/outline.py | 5 +- python/commands/board/size.py | 5 +- python/commands/board/view.py | 13 ++-- python/commands/component.py | 9 ++- python/commands/component_schematic.py | 9 ++- python/commands/connection_schematic.py | 7 +- python/commands/datasheet_manager.py | 2 +- python/commands/design_rules.py | 11 +-- python/commands/dynamic_symbol_loader.py | 2 +- python/commands/export.py | 13 ++-- python/commands/footprint.py | 2 +- python/commands/freerouting.py | 10 +-- python/commands/jlcpcb.py | 17 +++-- python/commands/jlcpcb_parts.py | 8 +- python/commands/jlcsearch.py | 5 +- python/commands/library.py | 7 +- python/commands/library_schematic.py | 5 +- python/commands/library_symbol.py | 4 +- python/commands/pin_locator.py | 7 +- python/commands/project.py | 7 +- python/commands/routing.py | 7 +- python/commands/schematic.py | 5 +- python/commands/schematic_analysis.py | 5 +- python/commands/svg_import.py | 10 +-- python/commands/symbol_creator.py | 2 +- python/commands/wire_connectivity.py | 1 + python/commands/wire_manager.py | 10 +-- python/kicad_api/__init__.py | 2 +- python/kicad_api/base.py | 4 +- python/kicad_api/factory.py | 6 +- python/kicad_api/ipc_backend.py | 22 +++--- python/kicad_api/swig_backend.py | 4 +- python/kicad_interface.py | 75 +++++++++++-------- python/parsers/kicad_mod_parser.py | 2 +- python/resources/resource_definitions.py | 4 +- python/schemas/tool_schemas.py | 2 +- python/test_ipc_backend.py | 2 +- python/tests/test_freerouting.py | 5 +- python/tests/test_schematic_analysis.py | 26 +++---- .../tests/test_schematic_component_fields.py | 2 +- python/tests/test_schematic_tools.py | 5 +- python/tests/test_wire_junction_changes.py | 4 +- python/utils/kicad_process.py | 12 +-- python/utils/platform_helper.py | 2 +- tests/test_platform_helper.py | 9 ++- 50 files changed, 226 insertions(+), 179 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d66144d..e5ce50d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,3 +16,9 @@ repos: - 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] diff --git a/pyproject.toml b/pyproject.toml index 1bbf96e..9ab3555 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,3 +6,7 @@ requires-python = ">=3.10" [tool.black] line-length = 100 target-version = ["py310"] + +[tool.isort] +profile = "black" +line_length = 100 diff --git a/python/commands/__init__.py b/python/commands/__init__.py index 6d69317..158d4cc 100644 --- a/python/commands/__init__.py +++ b/python/commands/__init__.py @@ -2,12 +2,12 @@ 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", diff --git a/python/commands/board/__init__.py b/python/commands/board/__init__.py index 9c23957..96e511c 100644 --- a/python/commands/board/__init__.py +++ b/python/commands/board/__init__.py @@ -2,14 +2,16 @@ 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") diff --git a/python/commands/board/layers.py b/python/commands/board/layers.py index 0219a5d..aa56cdf 100644 --- a/python/commands/board/layers.py +++ b/python/commands/board/layers.py @@ -2,9 +2,10 @@ 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") diff --git a/python/commands/board/outline.py b/python/commands/board/outline.py index 05fe16d..57825fc 100644 --- a/python/commands/board/outline.py +++ b/python/commands/board/outline.py @@ -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") diff --git a/python/commands/board/size.py b/python/commands/board/size.py index 3d9fd4d..243b2ca 100644 --- a/python/commands/board/size.py +++ b/python/commands/board/size.py @@ -2,9 +2,10 @@ 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") diff --git a/python/commands/board/view.py b/python/commands/board/view.py index 9e35610..baee5cd 100644 --- a/python/commands/board/view.py +++ b/python/commands/board/view.py @@ -2,13 +2,14 @@ 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") diff --git a/python/commands/component.py b/python/commands/component.py index e7eb029..4de1369 100644 --- a/python/commands/component.py +++ b/python/commands/component.py @@ -2,12 +2,13 @@ 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") diff --git a/python/commands/component_schematic.py b/python/commands/component_schematic.py index 23fb4d6..2592820 100644 --- a/python/commands/component_schematic.py +++ b/python/commands/component_schematic.py @@ -1,10 +1,11 @@ -from skip import Schematic +import logging import os import uuid -import logging from pathlib import Path from typing import Optional +from skip import Schematic + logger = logging.getLogger(__name__) # Import dynamic symbol loader @@ -350,9 +351,9 @@ class ComponentManager: 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") diff --git a/python/commands/connection_schematic.py b/python/commands/connection_schematic.py index 24f08fc..19dfb95 100644 --- a/python/commands/connection_schematic.py +++ b/python/commands/connection_schematic.py @@ -1,15 +1,16 @@ -from skip import Schematic -import os import logging +import os from pathlib import Path from typing import 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: diff --git a/python/commands/datasheet_manager.py b/python/commands/datasheet_manager.py index 9e72429..fe11f14 100644 --- a/python/commands/datasheet_manager.py +++ b/python/commands/datasheet_manager.py @@ -9,8 +9,8 @@ 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 diff --git a/python/commands/design_rules.py b/python/commands/design_rules.py index 766a573..b8b69e5 100644 --- a/python/commands/design_rules.py +++ b/python/commands/design_rules.py @@ -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") @@ -171,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: diff --git a/python/commands/dynamic_symbol_loader.py b/python/commands/dynamic_symbol_loader.py index c102394..4c9b778 100644 --- a/python/commands/dynamic_symbol_loader.py +++ b/python/commands/dynamic_symbol_loader.py @@ -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 diff --git a/python/commands/export.py b/python/commands/export.py index f7a2fd9..4311a48 100644 --- a/python/commands/export.py +++ b/python/commands/export.py @@ -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") @@ -321,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: @@ -608,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") diff --git a/python/commands/footprint.py b/python/commands/footprint.py index 5398672..55e54f7 100644 --- a/python/commands/footprint.py +++ b/python/commands/footprint.py @@ -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 diff --git a/python/commands/freerouting.py b/python/commands/freerouting.py index 39318d6..3da22b1 100644 --- a/python/commands/freerouting.py +++ b/python/commands/freerouting.py @@ -9,13 +9,13 @@ 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") diff --git a/python/commands/jlcpcb.py b/python/commands/jlcpcb.py index 8856848..2cf9cad 100644 --- a/python/commands/jlcpcb.py +++ b/python/commands/jlcpcb.py @@ -5,18 +5,19 @@ 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 + +import requests logger = logging.getLogger("kicad_interface") diff --git a/python/commands/jlcpcb_parts.py b/python/commands/jlcpcb_parts.py index ff36424..3edadd5 100644 --- a/python/commands/jlcpcb_parts.py +++ b/python/commands/jlcpcb_parts.py @@ -5,13 +5,13 @@ 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 Dict, List, Optional logger = logging.getLogger("kicad_interface") diff --git a/python/commands/jlcsearch.py b/python/commands/jlcsearch.py index b51088a..9ae38c8 100644 --- a/python/commands/jlcsearch.py +++ b/python/commands/jlcsearch.py @@ -6,9 +6,10 @@ jlcsearch service at https://jlcsearch.tscircuit.com/ """ import logging -import requests -from typing import Optional, Dict, List, Callable import time +from typing import Callable, Dict, List, Optional + +import requests logger = logging.getLogger("kicad_interface") diff --git a/python/commands/library.py b/python/commands/library.py index 678a7c2..5e137e8 100644 --- a/python/commands/library.py +++ b/python/commands/library.py @@ -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") @@ -522,9 +522,10 @@ class LibraryCommands: # Attempt to enrich with parsed .kicad_mod data try: - from parsers.kicad_mod_parser import parse_kicad_mod 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: diff --git a/python/commands/library_schematic.py b/python/commands/library_schematic.py index b9c8fd4..6ddf7b4 100644 --- a/python/commands/library_schematic.py +++ b/python/commands/library_schematic.py @@ -1,8 +1,9 @@ -from skip import Schematic +import glob # Symbol class might not be directly importable in the current version import os -import glob + +from skip import Schematic class LibraryManager: diff --git a/python/commands/library_symbol.py b/python/commands/library_symbol.py index 434a622..95cd716 100644 --- a/python/commands/library_symbol.py +++ b/python/commands/library_symbol.py @@ -5,12 +5,12 @@ 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") diff --git a/python/commands/pin_locator.py b/python/commands/pin_locator.py index 3657c46..7122216 100644 --- a/python/commands/pin_locator.py +++ b/python/commands/pin_locator.py @@ -9,7 +9,8 @@ import logging import math import tempfile from pathlib import Path -from typing import List, Tuple, Optional, Dict +from typing import Dict, List, Optional, Tuple + import sexpdata from sexpdata import Symbol from skip import Schematic @@ -396,12 +397,12 @@ class PinLocator: if __name__ == "__main__": # Test pin location discovery + import shutil import sys - 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)) diff --git a/python/commands/project.py b/python/commands/project.py index 2a2e403..2b82697 100644 --- a/python/commands/project.py +++ b/python/commands/project.py @@ -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") diff --git a/python/commands/routing.py b/python/commands/routing.py index da66436..317de8b 100644 --- a/python/commands/routing.py +++ b/python/commands/routing.py @@ -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") diff --git a/python/commands/schematic.py b/python/commands/schematic.py index 9365f25..19a801a 100644 --- a/python/commands/schematic.py +++ b/python/commands/schematic.py @@ -1,9 +1,10 @@ -from skip import Schematic +import logging import os import shutil -import logging import uuid +from skip import Schematic + logger = logging.getLogger("kicad_interface") diff --git a/python/commands/schematic_analysis.py b/python/commands/schematic_analysis.py index 4044972..d1a212a 100644 --- a/python/commands/schematic_analysis.py +++ b/python/commands/schematic_analysis.py @@ -8,12 +8,11 @@ and checking connectivity in KiCad schematic files. import logging import math from pathlib import Path -from typing import Dict, List, Tuple, Optional, Any, Set +from typing import Any, Dict, List, Optional, Set, Tuple import sexpdata -from sexpdata import Symbol - from commands.pin_locator import PinLocator +from sexpdata import Symbol logger = logging.getLogger("kicad_interface") diff --git a/python/commands/svg_import.py b/python/commands/svg_import.py index e337871..e6f2f33 100644 --- a/python/commands/svg_import.py +++ b/python/commands/svg_import.py @@ -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") diff --git a/python/commands/symbol_creator.py b/python/commands/symbol_creator.py index e06a07f..6b99ffb 100644 --- a/python/commands/symbol_creator.py +++ b/python/commands/symbol_creator.py @@ -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 diff --git a/python/commands/wire_connectivity.py b/python/commands/wire_connectivity.py index eeb897b..acd1f3c 100644 --- a/python/commands/wire_connectivity.py +++ b/python/commands/wire_connectivity.py @@ -9,6 +9,7 @@ coordinate matching, mirroring KiCad's own connectivity algorithm. import logging from pathlib import Path from typing import Dict, List, Optional, Set, Tuple + from commands.pin_locator import PinLocator logger = logging.getLogger("kicad_interface") diff --git a/python/commands/wire_manager.py b/python/commands/wire_manager.py index d1046f5..3851f36 100644 --- a/python/commands/wire_manager.py +++ b/python/commands/wire_manager.py @@ -6,12 +6,13 @@ 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 +from typing import List, Optional, Tuple + import sexpdata from sexpdata import Symbol @@ -671,10 +672,9 @@ class WireManager: if __name__ == "__main__": # Test wire creation - import sys - - from pathlib import Path import shutil + import sys + from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) diff --git a/python/kicad_api/__init__.py b/python/kicad_api/__init__.py index 223b009..ee997dc 100644 --- a/python/kicad_api/__init__.py +++ b/python/kicad_api/__init__.py @@ -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" diff --git a/python/kicad_api/base.py b/python/kicad_api/base.py index 4a9157a..b64c922 100644 --- a/python/kicad_api/base.py +++ b/python/kicad_api/base.py @@ -4,10 +4,10 @@ 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__) diff --git a/python/kicad_api/factory.py b/python/kicad_api/factory.py index 3ead056..34d4764 100644 --- a/python/kicad_api/factory.py +++ b/python/kicad_api/factory.py @@ -4,12 +4,12 @@ Backend factory for creating appropriate KiCAD API backend Auto-detects available backends and provides fallback mechanism. """ -import os import logging -from typing import Optional +import os from pathlib import Path +from typing import Optional -from kicad_api.base import KiCADBackend, APINotAvailableError +from kicad_api.base import APINotAvailableError, KiCADBackend logger = logging.getLogger(__name__) diff --git a/python/kicad_api/ipc_backend.py b/python/kicad_api/ipc_backend.py index cd4e0e8..65a3879 100644 --- a/python/kicad_api/ipc_backend.py +++ b/python/kicad_api/ipc_backend.py @@ -18,9 +18,9 @@ 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__) @@ -317,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() @@ -655,9 +655,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() @@ -708,7 +708,7 @@ class IPCBoardAPI(BoardAPI): ) -> 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() @@ -795,8 +795,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() @@ -863,8 +863,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() @@ -925,9 +925,9 @@ class IPCBoardAPI(BoardAPI): """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() @@ -1072,8 +1072,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() diff --git a/python/kicad_api/swig_backend.py b/python/kicad_api/swig_backend.py index 312e0b6..7864c2b 100644 --- a/python/kicad_api/swig_backend.py +++ b/python/kicad_api/swig_backend.py @@ -11,9 +11,9 @@ WARNING: SWIG bindings are deprecated as of KiCAD 9.0 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__) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 80fab5f..b3903ad 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -7,16 +7,17 @@ and KiCAD's Python API (pcbnew). It receives commands via stdin as JSON and returns responses via stdout also as JSON. """ -import sys import json -import traceback import logging import os -from typing import Dict, Any, Optional +import sys +import traceback +from typing import Any, Dict, Optional + +from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read # Import tool schemas and resource definitions from schemas.tool_schemas import TOOL_SCHEMAS -from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read # Configure logging log_dir = os.path.join(os.path.expanduser("~"), ".kicad-mcp", "logs") @@ -80,9 +81,10 @@ utils_dir = os.path.join(os.path.dirname(__file__)) if utils_dir not in sys.path: sys.path.insert(0, utils_dir) +from utils.kicad_process import KiCADProcessManager, check_and_launch_kicad + # Import platform helper and add KiCAD paths from utils.platform_helper import PlatformHelper -from utils.kicad_process import check_and_launch_kicad, KiCADProcessManager logger.info(f"Detecting KiCAD Python paths for {PlatformHelper.get_platform_name()}...") paths_added = PlatformHelper.add_kicad_to_python_path() @@ -202,27 +204,27 @@ elif KICAD_BACKEND == "ipc" and not USE_IPC_BACKEND: # Import command handlers try: logger.info("Importing command handlers...") - from commands.project import ProjectCommands from commands.board import BoardCommands from commands.component import ComponentCommands - from commands.routing import RoutingCommands - from commands.design_rules import DesignRuleCommands - from commands.export import ExportCommands - from commands.schematic import SchematicManager from commands.component_schematic import ComponentManager from commands.connection_schematic import ConnectionManager - from commands.library_schematic import LibraryManager as SchematicLibraryManager - from commands.library import ( - LibraryManager as FootprintLibraryManager, - LibraryCommands, - ) - from commands.library_symbol import SymbolLibraryManager, SymbolLibraryCommands + from commands.datasheet_manager import DatasheetManager + from commands.design_rules import DesignRuleCommands + from commands.export import ExportCommands + from commands.footprint import FootprintCreator + from commands.freerouting import FreeroutingCommands from commands.jlcpcb import JLCPCBClient, test_jlcpcb_connection from commands.jlcpcb_parts import JLCPCBPartsManager - from commands.datasheet_manager import DatasheetManager - from commands.footprint import FootprintCreator + from commands.library import ( + LibraryCommands, + ) + from commands.library import LibraryManager as FootprintLibraryManager + from commands.library_schematic import LibraryManager as SchematicLibraryManager + from commands.library_symbol import SymbolLibraryCommands, SymbolLibraryManager + from commands.project import ProjectCommands + from commands.routing import RoutingCommands + from commands.schematic import SchematicManager from commands.symbol_creator import SymbolCreator - from commands.freerouting import FreeroutingCommands logger.info("Successfully imported all command handlers") except ImportError as e: @@ -682,6 +684,7 @@ class KiCADInterface: logger.info("Adding component to schematic") try: from pathlib import Path + from commands.dynamic_symbol_loader import DynamicSymbolLoader schematic_path = params.get("schematicPath") @@ -733,8 +736,8 @@ class KiCADInterface: """Remove a placed symbol from a schematic using text-based manipulation (no skip writes)""" logger.info("Deleting schematic component") try: - from pathlib import Path import re + from pathlib import Path schematic_path = params.get("schematicPath") reference = params.get("reference") @@ -841,8 +844,8 @@ class KiCADInterface: """ logger.info("Editing schematic component") try: - from pathlib import Path import re + from pathlib import Path schematic_path = params.get("schematicPath") reference = params.get("reference") @@ -992,8 +995,8 @@ class KiCADInterface: """Return full component info: position and all field values with their (at x y angle) positions.""" logger.info("Getting schematic component info") try: - from pathlib import Path import re + from pathlib import Path schematic_path = params.get("schematicPath") reference = params.get("reference") @@ -1116,6 +1119,7 @@ class KiCADInterface: logger.info("Adding wire to schematic") try: from pathlib import Path + from commands.wire_manager import WireManager schematic_path = params.get("schematicPath") @@ -1239,6 +1243,7 @@ class KiCADInterface: logger.info("Adding junction to schematic") try: from pathlib import Path + from commands.wire_manager import WireManager schematic_path = params.get("schematicPath") @@ -1472,6 +1477,7 @@ class KiCADInterface: logger.info("Adding net label to schematic") try: from pathlib import Path + from commands.wire_manager import WireManager schematic_path = params.get("schematicPath") @@ -1591,6 +1597,7 @@ class KiCADInterface: logger.info("Getting schematic pin locations") try: from pathlib import Path + from commands.pin_locator import PinLocator schematic_path = params.get("schematicPath") @@ -1643,9 +1650,9 @@ class KiCADInterface: def _handle_get_schematic_view(self, params): """Get a rasterised image of the schematic (SVG export → optional PNG conversion)""" logger.info("Getting schematic view") + import base64 import subprocess import tempfile - import base64 try: schematic_path = params.get("schematicPath") @@ -1734,6 +1741,7 @@ class KiCADInterface: logger.info("Listing schematic components") try: from pathlib import Path + from commands.pin_locator import PinLocator schematic_path = params.get("schematicPath") @@ -2185,6 +2193,7 @@ class KiCADInterface: return {"success": False, "message": "schematicPath is required"} from pathlib import Path + from commands.wire_manager import WireManager start_point = [start.get("x", 0), start.get("y", 0)] @@ -2218,6 +2227,7 @@ class KiCADInterface: } from pathlib import Path + from commands.wire_manager import WireManager pos_list = None @@ -2240,9 +2250,9 @@ class KiCADInterface: def _handle_export_schematic_svg(self, params): """Export schematic to SVG using kicad-cli""" logger.info("Exporting schematic SVG") - import subprocess import glob import shutil + import subprocess try: schematic_path = params.get("schematicPath") @@ -2381,9 +2391,9 @@ class KiCADInterface: def _handle_run_erc(self, params): """Run Electrical Rules Check on a schematic via kicad-cli""" logger.info("Running ERC on schematic") + import os import subprocess import tempfile - import os try: schematic_path = params.get("schematicPath") @@ -2619,10 +2629,10 @@ class KiCADInterface: def _handle_get_schematic_view_region(self, params): """Export a cropped region of the schematic as an image""" logger.info("Exporting schematic view region") + import base64 + import os import subprocess import tempfile - import os - import base64 try: schematic_path = params.get("schematicPath") @@ -2736,6 +2746,7 @@ class KiCADInterface: logger.info("Finding overlapping elements in schematic") try: from pathlib import Path + from commands.schematic_analysis import find_overlapping_elements schematic_path = params.get("schematicPath") @@ -2761,6 +2772,7 @@ class KiCADInterface: logger.info("Getting elements in schematic region") try: from pathlib import Path + from commands.schematic_analysis import get_elements_in_region schematic_path = params.get("schematicPath") @@ -2790,6 +2802,7 @@ class KiCADInterface: logger.info("Finding wires crossing symbols in schematic") try: from pathlib import Path + from commands.schematic_analysis import find_wires_crossing_symbols schematic_path = params.get("schematicPath") @@ -3009,7 +3022,9 @@ class KiCADInterface: zone_count = self.board.GetAreaCount() if hasattr(self.board, "GetAreaCount") else 0 # Run pcbnew zone fill in an isolated subprocess to prevent crashes - import subprocess, sys, textwrap + import subprocess + import sys + import textwrap script = textwrap.dedent(f""" import pcbnew, sys @@ -3437,8 +3452,8 @@ print("ok") try: from kipy.board_types import BoardSegment 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.ipc_board_api._get_board() @@ -3488,8 +3503,8 @@ print("ok") try: from kipy.board_types import BoardCircle 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.ipc_board_api._get_board() diff --git a/python/parsers/kicad_mod_parser.py b/python/parsers/kicad_mod_parser.py index 82cbab7..a1f2ac0 100644 --- a/python/parsers/kicad_mod_parser.py +++ b/python/parsers/kicad_mod_parser.py @@ -15,8 +15,8 @@ KiCad S-expression file format reference: https://dev-docs.kicad.org/en/file-formats/sexpr-intro/index.html#_footprint """ -import re import logging +import re from pathlib import Path from typing import Any, Dict, List, Optional, Tuple diff --git a/python/resources/resource_definitions.py b/python/resources/resource_definitions.py index feed376..ba893db 100644 --- a/python/resources/resource_definitions.py +++ b/python/resources/resource_definitions.py @@ -5,10 +5,10 @@ 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") diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 5076a0e..94de080 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -10,7 +10,7 @@ Each tool includes: - outputSchema: Optional JSON Schema for return values (structured content) """ -from typing import Dict, Any +from typing import Any, Dict # ============================================================================= # PROJECT TOOLS diff --git a/python/test_ipc_backend.py b/python/test_ipc_backend.py index c8f6ee1..a6baf16 100644 --- a/python/test_ipc_backend.py +++ b/python/test_ipc_backend.py @@ -14,8 +14,8 @@ Usage: ./venv/bin/python python/test_ipc_backend.py """ -import sys import os +import sys # Add parent directory to path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) diff --git a/python/tests/test_freerouting.py b/python/tests/test_freerouting.py index 287c560..c04f769 100644 --- a/python/tests/test_freerouting.py +++ b/python/tests/test_freerouting.py @@ -15,12 +15,11 @@ from pathlib import Path from unittest.mock import MagicMock, patch import pytest - from commands.freerouting import ( FreeroutingCommands, - _find_java, - _find_docker, _docker_available, + _find_docker, + _find_java, _java_version_ok, ) diff --git a/python/tests/test_schematic_analysis.py b/python/tests/test_schematic_analysis.py index a0cd46d..45158e3 100644 --- a/python/tests/test_schematic_analysis.py +++ b/python/tests/test_schematic_analysis.py @@ -6,11 +6,11 @@ Integration tests parse real .kicad_sch files via sexpdata. """ import os -import sys import shutil +import sys import tempfile from pathlib import Path -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch import pytest import sexpdata @@ -20,23 +20,23 @@ from sexpdata import Symbol sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from commands.schematic_analysis import ( - _parse_wires, - _parse_labels, - _parse_symbols, - _load_sexp, - _extract_lib_symbols, - _parse_lib_symbol_graphics, - _transform_local_point, - _line_segment_intersects_aabb, - _point_in_rect, - _distance, _aabb_overlap, _check_wire_overlap, _compute_symbol_bbox_direct, + _distance, + _extract_lib_symbols, + _line_segment_intersects_aabb, + _load_sexp, + _parse_labels, + _parse_lib_symbol_graphics, + _parse_symbols, + _parse_wires, + _point_in_rect, + _transform_local_point, compute_symbol_bbox, find_overlapping_elements, - get_elements_in_region, find_wires_crossing_symbols, + get_elements_in_region, ) # --------------------------------------------------------------------------- diff --git a/python/tests/test_schematic_component_fields.py b/python/tests/test_schematic_component_fields.py index 2173b10..2fb1c19 100644 --- a/python/tests/test_schematic_component_fields.py +++ b/python/tests/test_schematic_component_fields.py @@ -3,8 +3,8 @@ Tests for get_schematic_component and edit_schematic_component fieldPositions su """ import re -import sys import shutil +import sys import tempfile from pathlib import Path diff --git a/python/tests/test_schematic_tools.py b/python/tests/test_schematic_tools.py index 2c5f51c..d6814b5 100644 --- a/python/tests/test_schematic_tools.py +++ b/python/tests/test_schematic_tools.py @@ -268,7 +268,8 @@ def _make_handler_under_test(handler_name: str): This works because every _handle_* method starts with a params dict check before doing any file I/O or heavy imports. """ - import importlib.util, types + import importlib.util + import types # We monkey-patch sys.modules to avoid pcbnew/skip side effects stubs = {} @@ -301,8 +302,8 @@ class TestHandlerParamValidation: def _make_iface_stub(self): """Return a stub that exposes only the handler methods under test.""" - import types import importlib + import types # Build a minimal namespace that satisfies the imports inside each handler stub_mod = types.ModuleType("_handler_stubs") diff --git a/python/tests/test_wire_junction_changes.py b/python/tests/test_wire_junction_changes.py index bcf8b34..01bda02 100644 --- a/python/tests/test_wire_junction_changes.py +++ b/python/tests/test_wire_junction_changes.py @@ -119,7 +119,8 @@ class TestHandlerDispatch: @pytest.fixture(autouse=True) def load_handler_map(self): # Import only the dispatch table without initialising KiCAD connections - import importlib, types + import importlib + import types # Patch heavy imports before loading kicad_interface for mod in ["pcbnew", "skip"]: @@ -334,6 +335,7 @@ class TestPinSnapping: # Re-import so the patched skip.Schematic is used import importlib + import kicad_interface importlib.reload(kicad_interface) diff --git a/python/utils/kicad_process.py b/python/utils/kicad_process.py index 88ac20c..bb367bd 100644 --- a/python/utils/kicad_process.py +++ b/python/utils/kicad_process.py @@ -4,15 +4,15 @@ KiCAD Process Management Utilities Detects if KiCAD is running and provides auto-launch functionality. """ -import os -import subprocess -import logging -import platform -import time import ctypes +import logging +import os +import platform +import subprocess +import time from ctypes import wintypes from pathlib import Path -from typing import Optional, List +from typing import List, Optional logger = logging.getLogger(__name__) diff --git a/python/utils/platform_helper.py b/python/utils/platform_helper.py index 8393153..175db50 100644 --- a/python/utils/platform_helper.py +++ b/python/utils/platform_helper.py @@ -5,12 +5,12 @@ This module provides helpers for detecting the current platform and getting appropriate paths for KiCAD, configuration, logs, etc. """ +import logging import os import platform import sys from pathlib import Path from typing import List, Optional -import logging logger = logging.getLogger(__name__) diff --git a/tests/test_platform_helper.py b/tests/test_platform_helper.py index 0742e98..6a8033e 100644 --- a/tests/test_platform_helper.py +++ b/tests/test_platform_helper.py @@ -4,11 +4,12 @@ Tests for platform_helper utility These are unit tests that work on all platforms. """ -import pytest -import platform -from pathlib import Path -import sys import os +import platform +import sys +from pathlib import Path + +import pytest # Add parent directory to path to import utils sys.path.insert(0, str(Path(__file__).parent.parent / "python")) From 7d50fa1d4c8378c06af2cf71959b9e14c68a5b34 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 29 Mar 2026 13:05:50 +0100 Subject: [PATCH 39/74] style: apply Prettier formatting to TS/JS/JSON/MD files Add Prettier as a dev dependency with .prettierrc.json config and .prettierignore. Hook added via mirrors-prettier in pre-commit config. All TypeScript, JSON, Markdown, and YAML files auto-formatted. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 388 ++-- .pre-commit-config.yaml | 8 +- .prettierignore | 9 + .prettierrc.json | 7 + CHANGELOG.md | 1334 ++++++------ CONTRIBUTING.md | 832 +++---- README.md | 2035 +++++++++--------- config/linux-config.example.json | 30 +- config/macos-config.example.json | 30 +- config/windows-config.example.json | 32 +- docs/ARCHITECTURE.md | 34 +- docs/CLIENT_CONFIGURATION.md | 1081 +++++----- docs/DATASHEET_TOOLS_GUIDE.md | 20 +- docs/FOOTPRINT_SYMBOL_CREATOR_GUIDE.md | 254 +-- docs/FREEROUTING_GUIDE.md | 8 + docs/INDEX.md | 84 +- docs/IPC_BACKEND_STATUS.md | 435 ++-- docs/JLCPCB_INTEGRATION.md | 718 +++--- docs/JLCPCB_USAGE_GUIDE.md | 1067 ++++----- docs/KNOWN_ISSUES.md | 18 + docs/LIBRARY_INTEGRATION.md | 754 +++---- docs/LINUX_COMPATIBILITY_AUDIT.md | 649 +++--- docs/PCB_DESIGN_WORKFLOW.md | 7 + docs/PLATFORM_GUIDE.md | 1073 ++++----- docs/REALTIME_WORKFLOW.md | 857 ++++---- docs/ROADMAP.md | 12 +- docs/ROUTER_ARCHITECTURE.md | 736 ++++--- docs/ROUTER_QUICK_START.md | 362 ++-- docs/ROUTING_TOOLS_REFERENCE.md | 197 +- docs/SCHEMATIC_TOOLS_REFERENCE.md | 291 +-- docs/STATUS_SUMMARY.md | 97 +- docs/SVG_IMPORT_GUIDE.md | 42 +- docs/TOOL_INVENTORY.md | 396 ++-- docs/UI_AUTO_LAUNCH.md | 824 +++---- docs/VISUAL_FEEDBACK.md | 377 ++-- docs/WINDOWS_TROUBLESHOOTING.md | 968 +++++---- docs/archive/BUILD_AND_TEST_SESSION.md | 1013 ++++----- docs/archive/DYNAMIC_LIBRARY_LOADING_PLAN.md | 994 ++++----- docs/archive/DYNAMIC_LOADING_STATUS.md | 803 +++---- docs/archive/IPC_API_MIGRATION_PLAN.md | 970 +++++---- docs/archive/JLCPCB_INTEGRATION_PLAN.md | 1238 +++++------ docs/archive/ROUTER_IMPLEMENTATION_STATUS.md | 463 ++-- docs/archive/SCHEMATIC_WIRING_PLAN.md | 1381 ++++++------ docs/archive/SCHEMATIC_WORKFLOW_FIX.md | 258 +-- docs/archive/WEEK1_SESSION1_SUMMARY.md | 1043 ++++----- docs/archive/WEEK1_SESSION2_SUMMARY.md | 879 ++++---- docs/mcp-router-guide.md | 359 +-- package-lock.json | 17 + package.json | 99 +- src/config.ts | 26 +- src/index.ts | 38 +- src/kicad-server.ts | 1004 ++++----- src/logger.ts | 35 +- src/prompts/component.ts | 468 ++-- src/prompts/design.ts | 666 +++--- src/prompts/footprint.ts | 9 +- src/prompts/routing.ts | 596 ++--- src/resources/board.ts | 726 ++++--- src/resources/component.ts | 265 +-- src/resources/index.ts | 20 +- src/resources/library.ts | 613 +++--- src/resources/project.ts | 527 ++--- src/server.ts | 112 +- src/tools/board.ts | 815 +++---- src/tools/component.ts | 160 +- src/tools/datasheet.ts | 21 +- src/tools/design-rules.ts | 575 ++--- src/tools/export.ts | 577 ++--- src/tools/footprint.ts | 27 +- src/tools/freerouting.ts | 35 +- src/tools/jlcpcb-api.ts | 540 ++--- src/tools/library-symbol.ts | 134 +- src/tools/library.ts | 62 +- src/tools/project.ts | 215 +- src/tools/registry.ts | 603 +++--- src/tools/router.ts | 547 ++--- src/tools/routing.ts | 48 +- src/tools/schematic.ts | 248 +-- src/tools/symbol-creator.ts | 76 +- src/tools/ui.ts | 100 +- src/utils/resource-helpers.ts | 132 +- tsconfig.json | 28 +- 82 files changed, 18314 insertions(+), 17317 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c30a29a..606f32a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,194 +1,194 @@ -name: CI/CD Pipeline - -on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main, develop ] - -jobs: - # TypeScript/Node.js tests - typescript-tests: - name: TypeScript Build & Test - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-24.04, ubuntu-22.04, windows-latest, macos-latest] - node-version: [18.x, 20.x, 22.x] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Run TypeScript compiler - run: npm run build - - - name: Run linter - run: npm run lint || echo "Linter not configured yet" - - - name: Run tests - run: npm test || echo "Tests not configured yet" - - # Python tests - python-tests: - name: Python Tests - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-24.04, ubuntu-22.04] - python-version: ['3.10', '3.11', '3.12'] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - cache: 'pip' - - - name: Install Python dependencies - run: | - python -m pip install --upgrade pip - pip install pytest pytest-cov black mypy pylint - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi - - - name: Run Black formatter check - run: black --check python/ || echo "Black not configured yet" - - - name: Run MyPy type checker - run: mypy python/ || echo "MyPy not configured yet" - - - name: Run Pylint - run: pylint python/ || echo "Pylint not configured yet" - - - name: Run pytest - run: pytest python/ --cov=python --cov-report=xml || echo "Tests not configured yet" - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - with: - file: ./coverage.xml - flags: python - name: python-${{ matrix.python-version }} - if: matrix.python-version == '3.12' && matrix.os == 'ubuntu-24.04' - - # Integration tests (requires KiCAD) - integration-tests: - name: Integration Tests (Linux + KiCAD) - runs-on: ubuntu-24.04 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20.x' - cache: 'npm' - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - cache: 'pip' - - - name: Add KiCAD PPA and Install KiCAD 9.0 - run: | - sudo add-apt-repository --yes ppa:kicad/kicad-9.0-releases - sudo apt-get update - sudo apt-get install -y kicad kicad-libraries - - - name: Verify KiCAD installation - run: | - kicad-cli version || echo "kicad-cli not found" - python3 -c "import pcbnew; print(f'pcbnew version: {pcbnew.GetBuildVersion()}')" || echo "pcbnew module not found" - - - name: Install dependencies - run: | - npm ci - pip install -r requirements.txt - - - name: Build TypeScript - run: npm run build - - - name: Run integration tests - run: | - echo "Integration tests not yet configured" - # pytest tests/integration/ - - # Docker build test - docker-build: - name: Docker Build Test - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build Docker image - run: | - echo "Docker build not yet configured" - # docker build -t kicad-mcp-server:test . - - # Code quality checks - code-quality: - name: Code Quality - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20.x' - - - name: Install dependencies - run: npm ci - - - name: Run ESLint - run: npx eslint src/ || echo "ESLint not configured yet" - - - name: Run Prettier check - run: npx prettier --check "src/**/*.ts" || echo "Prettier not configured yet" - - - name: Check for security vulnerabilities - run: npm audit --audit-level=moderate || echo "No critical vulnerabilities" - - # Documentation check - docs-check: - name: Documentation Check - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Check README exists - run: test -f README.md - - - name: Check for broken links in docs - run: | - sudo apt-get install -y linkchecker || true - # linkchecker docs/ || echo "Link checker not configured" - - - name: Validate JSON files - run: | - find . -name "*.json" -not -path "./node_modules/*" -not -path "./dist/*" | xargs -I {} sh -c 'python3 -m json.tool {} > /dev/null && echo "✓ {}" || echo "✗ {}"' +name: CI/CD Pipeline + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + # TypeScript/Node.js tests + typescript-tests: + name: TypeScript Build & Test + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-24.04, ubuntu-22.04, windows-latest, macos-latest] + node-version: [18.x, 20.x, 22.x] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run TypeScript compiler + run: npm run build + + - name: Run linter + run: npm run lint || echo "Linter not configured yet" + + - name: Run tests + run: npm test || echo "Tests not configured yet" + + # Python tests + python-tests: + name: Python Tests + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-24.04, ubuntu-22.04] + python-version: ["3.10", "3.11", "3.12"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install pytest pytest-cov black mypy pylint + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi + + - name: Run Black formatter check + run: black --check python/ || echo "Black not configured yet" + + - name: Run MyPy type checker + run: mypy python/ || echo "MyPy not configured yet" + + - name: Run Pylint + run: pylint python/ || echo "Pylint not configured yet" + + - name: Run pytest + run: pytest python/ --cov=python --cov-report=xml || echo "Tests not configured yet" + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + flags: python + name: python-${{ matrix.python-version }} + if: matrix.python-version == '3.12' && matrix.os == 'ubuntu-24.04' + + # Integration tests (requires KiCAD) + integration-tests: + name: Integration Tests (Linux + KiCAD) + runs-on: ubuntu-24.04 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20.x" + cache: "npm" + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + + - name: Add KiCAD PPA and Install KiCAD 9.0 + run: | + sudo add-apt-repository --yes ppa:kicad/kicad-9.0-releases + sudo apt-get update + sudo apt-get install -y kicad kicad-libraries + + - name: Verify KiCAD installation + run: | + kicad-cli version || echo "kicad-cli not found" + python3 -c "import pcbnew; print(f'pcbnew version: {pcbnew.GetBuildVersion()}')" || echo "pcbnew module not found" + + - name: Install dependencies + run: | + npm ci + pip install -r requirements.txt + + - name: Build TypeScript + run: npm run build + + - name: Run integration tests + run: | + echo "Integration tests not yet configured" + # pytest tests/integration/ + + # Docker build test + docker-build: + name: Docker Build Test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + run: | + echo "Docker build not yet configured" + # docker build -t kicad-mcp-server:test . + + # Code quality checks + code-quality: + name: Code Quality + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20.x" + + - name: Install dependencies + run: npm ci + + - name: Run ESLint + run: npx eslint src/ || echo "ESLint not configured yet" + + - name: Run Prettier check + run: npx prettier --check "src/**/*.ts" || echo "Prettier not configured yet" + + - name: Check for security vulnerabilities + run: npm audit --audit-level=moderate || echo "No critical vulnerabilities" + + # Documentation check + docs-check: + name: Documentation Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Check README exists + run: test -f README.md + + - name: Check for broken links in docs + run: | + sudo apt-get install -y linkchecker || true + # linkchecker docs/ || echo "Link checker not configured" + + - name: Validate JSON files + run: | + find . -name "*.json" -not -path "./node_modules/*" -not -path "./dist/*" | xargs -I {} sh -c 'python3 -m json.tool {} > /dev/null && echo "✓ {}" || echo "✗ {}"' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e5ce50d..e34355f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ repos: - id: check-yaml - id: check-json - id: check-added-large-files - args: ['--maxkb=500'] + args: ["--maxkb=500"] - id: check-merge-conflict - repo: https://github.com/psf/black @@ -22,3 +22,9 @@ repos: hooks: - id: isort args: [--settings-path=pyproject.toml] + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v4.0.0-alpha.8 + hooks: + - id: prettier + exclude: ^(dist/|package-lock\.json|\.venv/|python/) diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..3910bf6 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +dist/ +node_modules/ +coverage.xml +htmlcov/ +data/ +*.kicad_* +python/ +package-lock.json +.venv/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..cf7c9eb --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "semi": true, + "trailingComma": "all", + "singleQuote": false, + "printWidth": 100, + "tabWidth": 2 +} diff --git a/CHANGELOG.md b/CHANGELOG.md index cc382a1..b4ebaf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,641 +1,693 @@ -# Changelog - -All notable changes to the KiCAD MCP Server project are documented here. - -## [2.2.3] - 2026-03-11 - -### Merged: PR #57 (Kletternaut/demo/rpiCSI-videotest → main) - -This release incorporates 28 commits developed and live-tested during a full -Raspberry Pi CSI adapter PCB design session. All tools listed below were validated -end-to-end using Claude Desktop + KiCAD 9 on Windows. - -### New MCP Tools - -- `connect_passthrough` — Schematic-only tool that wires all pins of one connector - directly to the matching pins of another (e.g. J1 pin N → J2 pin N). Creates nets - named with a configurable prefix (`netPrefix`). Designed for FFC/ribbon cable - passthrough adapters. **Schematic only — do not call for PCB routing.** - -- `sync_schematic_to_board` — Imports all net/pad assignments from the schematic - into the open PCB file. Required after `connect_passthrough` before routing can - start. Returns `pads_assigned` count for verification. - -- `snapshot_project` — Saves a named checkpoint of the entire project folder into a - `snapshots/` subdirectory inside the project. Allows resuming from a known-good - state without redoing earlier steps. Accepts `step`, `label`, and optional `prompt` - parameters. - -- `run_erc` — Runs KiCAD's Electrical Rules Check on the schematic and returns - violations as structured JSON. - -- `import_svg_logo` — Converts an SVG file to PCB silkscreen polygons and places - them on a specified layer. - -### Bug Fixes - -- `route_pad_to_pad`: **Critical fix for B.Cu footprints in KiCAD 9.** `pad.GetLayerName()` - always returned `F.Cu` for SMD pads on flipped footprints (KiCAD 9 SWIG bug). - Fix: use `footprint.GetLayer()` instead, which correctly reflects the placed layer - after `Flip()`. Without this fix, no vias were inserted for back-to-back connectors. - -- `route_pad_to_pad`: Via was placed at the geometric midpoint between the two pads. - For back-to-back mirrored connectors (J1 F.Cu / J2 B.Cu) this caused all 15 vias - to stack at the same X coordinate (board center). Fix: via is now placed at the - X coordinate of the start pad (`via_x = start_pos.x`), producing 15 parallel - vertical traces. - -- `place_component` (B.Cu footprints): `Flip()` was called before `board.Add()`, - causing KiCAD 9 to hang for ~30 seconds. Fix: `board.Add()` first, then `Flip()`. - -- `add_board_outline`: Three separate bugs fixed — incorrect cornerRadius fallback, - wrong top-left origin default, and broken arc delegation for IPC rounded rectangles. - -- `snapshot_project`: Snapshots were saved one level above the project directory, - cluttering the parent folder. Fix: snapshots now go into `/snapshots/`. - -- MCP server log timestamp was always UTC/ISO. Fix: now uses local system time. - -- `search_tools` (router pattern): direct tools like `snapshot_project` were invisible - to the router. Fix: direct tool names added to the router's known-tool list. - -### Developer Mode (`KICAD_MCP_DEV=1`) - -Set the environment variable `KICAD_MCP_DEV=1` in your Claude Desktop config to -enable developer features: - -```json -"env": { - "KICAD_MCP_DEV": "1" -} -``` - -**What it does:** -- `export_gerber` automatically copies the current MCP session log into the project's - `logs/` subdirectory as `mcp_log_.txt`. -- `snapshot_project` copies the MCP session log into `logs/` at every checkpoint as - `mcp_log_step_.txt`. -- If a `prompt` parameter is passed to `snapshot_project`, it is saved as - `PROMPT_step_.md` alongside the log. - -**Purpose:** Makes it easy to include the full tool call history when filing a bug -report or GitHub issue — just attach the log file from the project's `logs/` folder. - -> ⚠️ **Privacy warning:** The MCP session log contains the **complete conversation -> history** between Claude and the MCP server, including all tool parameters and -> responses. When sharing a project directory (e.g. as a ZIP attachment in a GitHub -> issue), **review or delete the `logs/` folder first** to avoid accidentally -> disclosing sensitive file paths, component names, or design details. - -### Snapshot Logging (always active) - -Regardless of dev mode, `snapshot_project` now always saves a copy of the current -MCP session log into `/logs/` at each checkpoint. This means every project -automatically retains a traceable record of which tools were called and in what order. - -> ⚠️ **Same privacy note applies:** the `logs/` directory inside your project folder -> contains tool call history. Do not share it publicly without reviewing its contents. - ---- - -## [2.2.2-alpha] - 2026-03-01 - -### New MCP Tools - -- `route_pad_to_pad` – Convenience wrapper around `route_trace` that looks up pad positions - automatically. Accepts `fromRef`/`fromPad`/`toRef`/`toPad` instead of raw XY coordinates. - Auto-detects net from pad assignment (overridable via `net` param). Saves ~2 tool calls per - connection (~64 calls for a full TMC2209 board compared to the 3-step get_pad_position flow). - Live tested: ESP32 ↔ TMC2209 STEP/DIR traces routed without prior coordinate lookup. ✅ - -- `copy_routing_pattern` – Now registered as MCP tool in TypeScript layer (`routing.ts`). - Was previously implemented in Python but missing from the MCP tool registry. - Parameters: `sourceRefs`, `targetRefs`, `includeVias?`, `traceWidth?`. - -### Bug Fixes - -- `add_schematic_component` / `DynamicSymbolLoader`: ignored project-local `sym-lib-table`. - `find_library_file()` only searched global KiCAD install directories, causing "library not - found" errors for any symbol in a project-local `.kicad_sym` file. Fix: added `project_path` - parameter; reads project `sym-lib-table` first via new `_resolve_library_from_table()` helper - before falling back to global dirs. `project_path` is auto-derived from the schematic path. - -- `place_component`: ignored project-local `fp-lib-table`. `FootprintLibraryManager` was - initialised once at server start without a project path, so self-created `.kicad_mod` - footprints were never found. Fix: new `boardPath` parameter in TypeScript + Python; - `_handle_place_component` wrapper recreates `FootprintLibraryManager(project_path=…)` whenever - the active project changes (cached to avoid redundant recreation). - -- `copy_routing_pattern`: copied 0 traces when pads had no net assignments. The filter - `track.GetNetname() in source_nets` always returned empty when pads were placed without net - assignment. Fix: geometric fallback using bounding box of source footprint pads ±5mm - tolerance. Response includes `filterMethod` field indicating which mode was used - (`"net-based"` or `"geometric (pads have no nets)"`). - -- `template_with_symbols.kicad_sch`, `template_with_symbols_expanded.kicad_sch`: restored - format version `20250114` (KiCAD 9) after upstream commit `2b38796` accidentally downgraded - both files to `20240101`. KiCAD 9 rejects schematics with outdated version numbers. - -- **CRITICAL: `template_with_symbols_expanded.kicad_sch`**: removed 7 invalid `;;` comment - lines introduced by upstream commit `b98c94b`. KiCAD's S-expression parser does not support - any comment syntax — it expects every non-empty, non-whitespace line to start with `(`. - 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 .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. - -- `add_schematic_component` / `inject_symbol_into_schematic`: symbol definition in - `lib_symbols` was never refreshed after editing via `create_symbol` / `edit_symbol`. - If the symbol was already present in the schematic's embedded `lib_symbols` section, - the function returned immediately — `delete + re-add` still pulled in the stale cached - definition. Fix: always read the current definition from the `.kicad_sym` file; if a - stale entry exists in `lib_symbols`, remove it first, then inject the fresh one. - Verified live. ✅ - -- `template_with_symbols_expanded.kicad_sch`: removed 13 legacy `_TEMPLATE_*` offscreen - instances (`_TEMPLATE_R`, `_TEMPLATE_C`, `_TEMPLATE_U`, etc.) that were placed at - `x=-100` as clone-sources for the old `ComponentManager` approach. `DynamicSymbolLoader` - (the current implementation) injects symbols directly and never needs these placeholders. - They appeared as dangling reference designators in KiCAD's component navigator and in - the schematic canvas when zoomed far out. - -### Maintenance - -- `.gitignore`: added `*.kicad_pcb.bak`, `*.kicad_pro.bak` alongside existing `-bak` variants; - consolidated personal/local files under `myContribution/`. - ---- - -## [2.2.1-alpha] - 2026-02-28 - -### New MCP Tools - -- `edit_schematic_component` – Update properties of a placed symbol in-place (footprint, - value, reference rename). More efficient than delete + re-add: preserves position and UUID. - -### Bug Fixes - -- `add_schematic_component`: `footprint` parameter was accepted but silently ignored – the - value was never passed through to `DynamicSymbolLoader.add_component()` / - `create_component_instance()`. All newly placed symbols always had an empty Footprint - field. Fix: added `footprint: str = ""` to both functions and threaded it through every - call site including the TypeScript tool schema. - -- `delete_schematic_component`: only deleted the first matching instance when duplicate - references existed (e.g. after an aborted add attempt). Root cause: loop used `break` - after the first match. Fix: collect all matching blocks first, then delete them all back- - to-front (to preserve line indices). Response now includes `deleted_count`. - -- `templates/*.kicad_sch`, `project.py`, `schematic.py`: Update KiCAD schematic format - version from `20230121` (KiCAD 7) to `20250114` (KiCAD 9). The MCP server targets - KiCAD 9 exclusively (`pcbnew.pyd` compiled for KiCAD 9.0, Python 3.11.5) – generating - files in an outdated format caused a spurious "This file was created with an older - KiCAD version" warning on every newly created schematic. - -- `template_with_symbols_expanded.kicad_sch`: Remove 13 corrupt `_TEMPLATE_*` placed-symbol - blocks with `(lib_id -100)` – an integer caused by old sexpdata serializer (same bug - PR #40 fixed for the add path). KiCAD crashed with a null-pointer when selecting these - symbols. They appeared as grey `_TEMPLATE_R?`, `_TEMPLATE_U_REG?` etc. labels far - outside the sheet boundary (~5000mm off-sheet). - - **Discovered via:** live testing on a real JLCPCB/KiCAD 9 project. - **Affected users:** schematics created from this template before this fix contain the - same corrupt blocks – remove all `(symbol (lib_id -100) ...)` blocks whose Reference - starts with `_TEMPLATE_`. - ---- - ---- - -## [2.2.0-alpha] - 2026-02-27 - -### 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 -- `modify_trace` - Modify trace width or layer -- `create_netclass` - Create or update a net class -- `route_differential_pair` - Route a differential pair between two points -- `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 -- `place_component_array` - Place components in a grid array -- `align_components` - Align components along an axis -- `duplicate_component` - Duplicate a component with offset - -### Bug Fixes - -- `routing.py`: Fix SwigPyObject UUID comparison (`str()` → `m_Uuid.AsString()`) -- `routing.py`: Fix SWIG iterator invalidation after `board.Remove()` by snapshotting `list(board.Tracks())` -- `routing.py`: Add `board.SetModified()` + `track = None` after `Remove()` to prevent dangling SWIG pointer crashes -- `routing.py`: Per-track `try/except` in `query_traces()` to skip invalid objects after bulk delete -- `routing.py`: Add missing return statement (mypy) -- `library.py`: Fix `search_footprints` parameter mapping (`search_term` → `pattern`) -- `library.py`: Fix field access (`fp.name` → `fp.full_name`) -- `library.py`: Accept both `pattern` and `search_term` parameter names -- `library.py`: Fix loop variable shadowing `Path` object (mypy) -- `design_rules.py`: Add type annotation for `violation_counts` (mypy) - -### 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. -- `enrich_datasheets` - Scan a `.kicad_sch` file and write LCSC datasheet URLs into - every symbol that has an `LCSC` property but an empty `Datasheet` field. After - enrichment the URL appears natively in KiCAD's symbol properties, footprint browser - and any other tool that reads the standard KiCAD `Datasheet` field. - Supports `dry_run=true` for preview without writing. - 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`). - -### Bug Fixes (cont.) - -- `schematic.ts` / `kicad_interface.py`: Fix missing `delete_schematic_component` MCP tool. - - **Root cause (two separate issues):** - 1. No MCP tool named `delete_schematic_component` existed. Claude had no way to call - it, so any "delete schematic component" request fell through to the PCB-only - `delete_component` tool, which searches `pcbnew.BOARD` and always returned - "Component not found" for schematic symbols. - 2. `component_schematic.py::remove_component()` still used `skip` for writes. - PR #40 rewrote `DynamicSymbolLoader` (add path) to avoid `skip`-induced schematic - corruption, but `remove_component` (delete path) was not touched by that PR. - - **Fix:** - - Added `delete_schematic_component` to the TypeScript tool layer (`schematic.ts`) - with clear docstring distinguishing it from the PCB `delete_component`. - - Implemented `_handle_delete_schematic_component` in `kicad_interface.py` using - 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"* - -### Additional Bug Fixes - -- `connection_schematic.py` / `kicad_interface.py`: Fix `generate_netlist` missing - `schematic_path` parameter – without it `get_net_connections` always fell back to - proximity matching which only returns one connection per component (first wire hit, - then `break`). PinLocator was never invoked. Fix: added `schematic_path: Optional[Path]` - to `generate_netlist` signature and threaded it through to `get_net_connections`, - and updated `_handle_generate_netlist` in `kicad_interface.py` to pass `schematic_path`. -- `server.ts`: Fix KiCAD bundled Python (3.11.5) not being selected on Windows – the - detection condition `process.env.PYTHONPATH?.includes("KiCad")` was fragile and failed - in some environments, causing System Python 3.12 to be used instead. Since `pcbnew.pyd` - is compiled for KiCAD's Python 3.11.5, this resulted in `No module named 'pcbnew'`. - Fix: removed the condition, KiCAD bundled Python is now always preferred on Windows - when it exists at `C:\Program Files\KiCad\9.0\bin\python.exe`. - Also added `KICAD_PYTHON` to `claude_desktop_config.json` as explicit override. -- `pin_locator.py`: Fix `generate_netlist` timeout – `get_pin_location` and - `get_all_symbol_pins` called `Schematic(schematic_path)` on every single pin lookup, - causing O(nets × components × pins) schematic file loads (e.g. 400+ loads for a - medium schematic). Fix: added `_schematic_cache` dict to `PinLocator.__init__`, - schematic is now loaded once per path and reused. - ---- - -## [2.1.0-alpha] - 2026-01-10 - -### 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) -- S-expression-based wire creation -- 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 -- KiCad-skip verification: All wires/labels correctly formed - ---- - -### 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 -- Accurate netlist generation with component/pin connections -- 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 -- `get_or_create_template()` - Fixed special character handling, auto-reload after dynamic loading -- `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 -4. Match component pins at wire connection points using PinLocator -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 -- Fallback proximity detection (10mm threshold) -- 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 -- Net connectivity: 100% accurate (VCC: 2, GND: 4, +3V3: 1, +5V: 1) -- Netlist generation: 4 nets with accurate connections -- 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) - ---- - -### 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 -- ✅ Parametric component search -- ✅ Cost optimization (Basic vs Extended library) -- ✅ KiCad footprint mapping -- ✅ 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 -- `get_jlcpcb_database_stats` - Database statistics -- `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 -- Troubleshooting section - ---- - -## [2.1.0-alpha] - 2025-11-30 - -### 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 - ---- - -## [2.0.0-alpha] - 2025-11-05 - -### 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 -- Better organization for users - ---- - -## [2.0.0-alpha] - 2025-11-01 - -### 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 - ---- - -## [2.0.0-alpha] - 2025-10-26 - -### 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 - ---- - -## [1.0.0] - 2025-10-01 - -### Initial Release - -**Core Features:** -- 64 fully-documented MCP tools -- JSON Schema validation for all tools -- 8 dynamic resources for project state -- Cross-platform support (Linux, Windows, macOS) -- Comprehensive error handling -- Detailed logging - -**Tool Categories:** -- Project Management (4 tools) -- Board Operations (9 tools) -- Component Management (8 tools) -- Routing (6 tools) -- Export & Manufacturing (5 tools) -- Design Rule Checking (4 tools) -- Schematic Operations (6 tools) -- Symbol Library (3 tools) -- 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 -- Contributing guidelines - ---- - -## Version Numbering - -- **2.1.0-alpha**: Current development version with JLCPCB integration -- **2.0.0-alpha**: Router pattern and IPC backend -- **1.0.0**: Initial stable release - -## 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) - -## Deprecations - -### 2.1.0-alpha -- `docs/JLCPCB_USAGE_GUIDE.md` - Superseded by `docs/JLCPCB_INTEGRATION.md` -- `docs/JLCPCB_INTEGRATION_PLAN.md` - Implementation complete - -## Migration Guide - -### 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 - -### 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 - ---- - -## Credits - -- **JLCSearch API**: [@tscircuit](https://github.com/tscircuit/jlcsearch) -- **JLCParts Database**: [@yaqwsx](https://github.com/yaqwsx/jlcparts) -- **Local JLCPCB Search**: [@l3wi](https://github.com/l3wi) -- **KiCad**: KiCad Development Team -- **MCP Protocol**: Anthropic - -## License - -See LICENSE file for details. +# Changelog + +All notable changes to the KiCAD MCP Server project are documented here. + +## [2.2.3] - 2026-03-11 + +### Merged: PR #57 (Kletternaut/demo/rpiCSI-videotest → main) + +This release incorporates 28 commits developed and live-tested during a full +Raspberry Pi CSI adapter PCB design session. All tools listed below were validated +end-to-end using Claude Desktop + KiCAD 9 on Windows. + +### New MCP Tools + +- `connect_passthrough` — Schematic-only tool that wires all pins of one connector + directly to the matching pins of another (e.g. J1 pin N → J2 pin N). Creates nets + named with a configurable prefix (`netPrefix`). Designed for FFC/ribbon cable + passthrough adapters. **Schematic only — do not call for PCB routing.** + +- `sync_schematic_to_board` — Imports all net/pad assignments from the schematic + into the open PCB file. Required after `connect_passthrough` before routing can + start. Returns `pads_assigned` count for verification. + +- `snapshot_project` — Saves a named checkpoint of the entire project folder into a + `snapshots/` subdirectory inside the project. Allows resuming from a known-good + state without redoing earlier steps. Accepts `step`, `label`, and optional `prompt` + parameters. + +- `run_erc` — Runs KiCAD's Electrical Rules Check on the schematic and returns + violations as structured JSON. + +- `import_svg_logo` — Converts an SVG file to PCB silkscreen polygons and places + them on a specified layer. + +### Bug Fixes + +- `route_pad_to_pad`: **Critical fix for B.Cu footprints in KiCAD 9.** `pad.GetLayerName()` + always returned `F.Cu` for SMD pads on flipped footprints (KiCAD 9 SWIG bug). + Fix: use `footprint.GetLayer()` instead, which correctly reflects the placed layer + after `Flip()`. Without this fix, no vias were inserted for back-to-back connectors. + +- `route_pad_to_pad`: Via was placed at the geometric midpoint between the two pads. + For back-to-back mirrored connectors (J1 F.Cu / J2 B.Cu) this caused all 15 vias + to stack at the same X coordinate (board center). Fix: via is now placed at the + X coordinate of the start pad (`via_x = start_pos.x`), producing 15 parallel + vertical traces. + +- `place_component` (B.Cu footprints): `Flip()` was called before `board.Add()`, + causing KiCAD 9 to hang for ~30 seconds. Fix: `board.Add()` first, then `Flip()`. + +- `add_board_outline`: Three separate bugs fixed — incorrect cornerRadius fallback, + wrong top-left origin default, and broken arc delegation for IPC rounded rectangles. + +- `snapshot_project`: Snapshots were saved one level above the project directory, + cluttering the parent folder. Fix: snapshots now go into `/snapshots/`. + +- MCP server log timestamp was always UTC/ISO. Fix: now uses local system time. + +- `search_tools` (router pattern): direct tools like `snapshot_project` were invisible + to the router. Fix: direct tool names added to the router's known-tool list. + +### Developer Mode (`KICAD_MCP_DEV=1`) + +Set the environment variable `KICAD_MCP_DEV=1` in your Claude Desktop config to +enable developer features: + +```json +"env": { + "KICAD_MCP_DEV": "1" +} +``` + +**What it does:** + +- `export_gerber` automatically copies the current MCP session log into the project's + `logs/` subdirectory as `mcp_log_.txt`. +- `snapshot_project` copies the MCP session log into `logs/` at every checkpoint as + `mcp_log_step_.txt`. +- If a `prompt` parameter is passed to `snapshot_project`, it is saved as + `PROMPT_step_.md` alongside the log. + +**Purpose:** Makes it easy to include the full tool call history when filing a bug +report or GitHub issue — just attach the log file from the project's `logs/` folder. + +> ⚠️ **Privacy warning:** The MCP session log contains the **complete conversation +> history** between Claude and the MCP server, including all tool parameters and +> responses. When sharing a project directory (e.g. as a ZIP attachment in a GitHub +> issue), **review or delete the `logs/` folder first** to avoid accidentally +> disclosing sensitive file paths, component names, or design details. + +### Snapshot Logging (always active) + +Regardless of dev mode, `snapshot_project` now always saves a copy of the current +MCP session log into `/logs/` at each checkpoint. This means every project +automatically retains a traceable record of which tools were called and in what order. + +> ⚠️ **Same privacy note applies:** the `logs/` directory inside your project folder +> contains tool call history. Do not share it publicly without reviewing its contents. + +--- + +## [2.2.2-alpha] - 2026-03-01 + +### New MCP Tools + +- `route_pad_to_pad` – Convenience wrapper around `route_trace` that looks up pad positions + automatically. Accepts `fromRef`/`fromPad`/`toRef`/`toPad` instead of raw XY coordinates. + Auto-detects net from pad assignment (overridable via `net` param). Saves ~2 tool calls per + connection (~64 calls for a full TMC2209 board compared to the 3-step get_pad_position flow). + Live tested: ESP32 ↔ TMC2209 STEP/DIR traces routed without prior coordinate lookup. ✅ + +- `copy_routing_pattern` – Now registered as MCP tool in TypeScript layer (`routing.ts`). + Was previously implemented in Python but missing from the MCP tool registry. + Parameters: `sourceRefs`, `targetRefs`, `includeVias?`, `traceWidth?`. + +### Bug Fixes + +- `add_schematic_component` / `DynamicSymbolLoader`: ignored project-local `sym-lib-table`. + `find_library_file()` only searched global KiCAD install directories, causing "library not + found" errors for any symbol in a project-local `.kicad_sym` file. Fix: added `project_path` + parameter; reads project `sym-lib-table` first via new `_resolve_library_from_table()` helper + before falling back to global dirs. `project_path` is auto-derived from the schematic path. + +- `place_component`: ignored project-local `fp-lib-table`. `FootprintLibraryManager` was + initialised once at server start without a project path, so self-created `.kicad_mod` + footprints were never found. Fix: new `boardPath` parameter in TypeScript + Python; + `_handle_place_component` wrapper recreates `FootprintLibraryManager(project_path=…)` whenever + the active project changes (cached to avoid redundant recreation). + +- `copy_routing_pattern`: copied 0 traces when pads had no net assignments. The filter + `track.GetNetname() in source_nets` always returned empty when pads were placed without net + assignment. Fix: geometric fallback using bounding box of source footprint pads ±5mm + tolerance. Response includes `filterMethod` field indicating which mode was used + (`"net-based"` or `"geometric (pads have no nets)"`). + +- `template_with_symbols.kicad_sch`, `template_with_symbols_expanded.kicad_sch`: restored + format version `20250114` (KiCAD 9) after upstream commit `2b38796` accidentally downgraded + both files to `20240101`. KiCAD 9 rejects schematics with outdated version numbers. + +- **CRITICAL: `template_with_symbols_expanded.kicad_sch`**: removed 7 invalid `;;` comment + lines introduced by upstream commit `b98c94b`. KiCAD's S-expression parser does not support + any comment syntax — it expects every non-empty, non-whitespace line to start with `(`. + 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 .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. + +- `add_schematic_component` / `inject_symbol_into_schematic`: symbol definition in + `lib_symbols` was never refreshed after editing via `create_symbol` / `edit_symbol`. + If the symbol was already present in the schematic's embedded `lib_symbols` section, + the function returned immediately — `delete + re-add` still pulled in the stale cached + definition. Fix: always read the current definition from the `.kicad_sym` file; if a + stale entry exists in `lib_symbols`, remove it first, then inject the fresh one. + Verified live. ✅ + +- `template_with_symbols_expanded.kicad_sch`: removed 13 legacy `_TEMPLATE_*` offscreen + instances (`_TEMPLATE_R`, `_TEMPLATE_C`, `_TEMPLATE_U`, etc.) that were placed at + `x=-100` as clone-sources for the old `ComponentManager` approach. `DynamicSymbolLoader` + (the current implementation) injects symbols directly and never needs these placeholders. + They appeared as dangling reference designators in KiCAD's component navigator and in + the schematic canvas when zoomed far out. + +### Maintenance + +- `.gitignore`: added `*.kicad_pcb.bak`, `*.kicad_pro.bak` alongside existing `-bak` variants; + consolidated personal/local files under `myContribution/`. + +--- + +## [2.2.1-alpha] - 2026-02-28 + +### New MCP Tools + +- `edit_schematic_component` – Update properties of a placed symbol in-place (footprint, + value, reference rename). More efficient than delete + re-add: preserves position and UUID. + +### Bug Fixes + +- `add_schematic_component`: `footprint` parameter was accepted but silently ignored – the + value was never passed through to `DynamicSymbolLoader.add_component()` / + `create_component_instance()`. All newly placed symbols always had an empty Footprint + field. Fix: added `footprint: str = ""` to both functions and threaded it through every + call site including the TypeScript tool schema. + +- `delete_schematic_component`: only deleted the first matching instance when duplicate + references existed (e.g. after an aborted add attempt). Root cause: loop used `break` + after the first match. Fix: collect all matching blocks first, then delete them all back- + to-front (to preserve line indices). Response now includes `deleted_count`. + +- `templates/*.kicad_sch`, `project.py`, `schematic.py`: Update KiCAD schematic format + version from `20230121` (KiCAD 7) to `20250114` (KiCAD 9). The MCP server targets + KiCAD 9 exclusively (`pcbnew.pyd` compiled for KiCAD 9.0, Python 3.11.5) – generating + files in an outdated format caused a spurious "This file was created with an older + KiCAD version" warning on every newly created schematic. + +- `template_with_symbols_expanded.kicad_sch`: Remove 13 corrupt `_TEMPLATE_*` placed-symbol + blocks with `(lib_id -100)` – an integer caused by old sexpdata serializer (same bug + PR #40 fixed for the add path). KiCAD crashed with a null-pointer when selecting these + symbols. They appeared as grey `_TEMPLATE_R?`, `_TEMPLATE_U_REG?` etc. labels far + outside the sheet boundary (~5000mm off-sheet). + + **Discovered via:** live testing on a real JLCPCB/KiCAD 9 project. + **Affected users:** schematics created from this template before this fix contain the + same corrupt blocks – remove all `(symbol (lib_id -100) ...)` blocks whose Reference + starts with `_TEMPLATE_`. + +--- + +--- + +## [2.2.0-alpha] - 2026-02-27 + +### 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 +- `modify_trace` - Modify trace width or layer +- `create_netclass` - Create or update a net class +- `route_differential_pair` - Route a differential pair between two points +- `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 +- `place_component_array` - Place components in a grid array +- `align_components` - Align components along an axis +- `duplicate_component` - Duplicate a component with offset + +### Bug Fixes + +- `routing.py`: Fix SwigPyObject UUID comparison (`str()` → `m_Uuid.AsString()`) +- `routing.py`: Fix SWIG iterator invalidation after `board.Remove()` by snapshotting `list(board.Tracks())` +- `routing.py`: Add `board.SetModified()` + `track = None` after `Remove()` to prevent dangling SWIG pointer crashes +- `routing.py`: Per-track `try/except` in `query_traces()` to skip invalid objects after bulk delete +- `routing.py`: Add missing return statement (mypy) +- `library.py`: Fix `search_footprints` parameter mapping (`search_term` → `pattern`) +- `library.py`: Fix field access (`fp.name` → `fp.full_name`) +- `library.py`: Accept both `pattern` and `search_term` parameter names +- `library.py`: Fix loop variable shadowing `Path` object (mypy) +- `design_rules.py`: Add type annotation for `violation_counts` (mypy) + +### 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. +- `enrich_datasheets` - Scan a `.kicad_sch` file and write LCSC datasheet URLs into + every symbol that has an `LCSC` property but an empty `Datasheet` field. After + enrichment the URL appears natively in KiCAD's symbol properties, footprint browser + and any other tool that reads the standard KiCAD `Datasheet` field. + Supports `dry_run=true` for preview without writing. + 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`). + +### Bug Fixes (cont.) + +- `schematic.ts` / `kicad_interface.py`: Fix missing `delete_schematic_component` MCP tool. + + **Root cause (two separate issues):** + 1. No MCP tool named `delete_schematic_component` existed. Claude had no way to call + it, so any "delete schematic component" request fell through to the PCB-only + `delete_component` tool, which searches `pcbnew.BOARD` and always returned + "Component not found" for schematic symbols. + 2. `component_schematic.py::remove_component()` still used `skip` for writes. + PR #40 rewrote `DynamicSymbolLoader` (add path) to avoid `skip`-induced schematic + corruption, but `remove_component` (delete path) was not touched by that PR. + + **Fix:** + - Added `delete_schematic_component` to the TypeScript tool layer (`schematic.ts`) + with clear docstring distinguishing it from the PCB `delete_component`. + - Implemented `_handle_delete_schematic_component` in `kicad_interface.py` using + 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"_ + +### Additional Bug Fixes + +- `connection_schematic.py` / `kicad_interface.py`: Fix `generate_netlist` missing + `schematic_path` parameter – without it `get_net_connections` always fell back to + proximity matching which only returns one connection per component (first wire hit, + then `break`). PinLocator was never invoked. Fix: added `schematic_path: Optional[Path]` + to `generate_netlist` signature and threaded it through to `get_net_connections`, + and updated `_handle_generate_netlist` in `kicad_interface.py` to pass `schematic_path`. +- `server.ts`: Fix KiCAD bundled Python (3.11.5) not being selected on Windows – the + detection condition `process.env.PYTHONPATH?.includes("KiCad")` was fragile and failed + in some environments, causing System Python 3.12 to be used instead. Since `pcbnew.pyd` + is compiled for KiCAD's Python 3.11.5, this resulted in `No module named 'pcbnew'`. + Fix: removed the condition, KiCAD bundled Python is now always preferred on Windows + when it exists at `C:\Program Files\KiCad\9.0\bin\python.exe`. + Also added `KICAD_PYTHON` to `claude_desktop_config.json` as explicit override. +- `pin_locator.py`: Fix `generate_netlist` timeout – `get_pin_location` and + `get_all_symbol_pins` called `Schematic(schematic_path)` on every single pin lookup, + causing O(nets × components × pins) schematic file loads (e.g. 400+ loads for a + medium schematic). Fix: added `_schematic_cache` dict to `PinLocator.__init__`, + schematic is now loaded once per path and reused. + +--- + +## [2.1.0-alpha] - 2026-01-10 + +### 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) +- S-expression-based wire creation +- 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 +- KiCad-skip verification: All wires/labels correctly formed + +--- + +### 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 +- Accurate netlist generation with component/pin connections +- 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 +- `get_or_create_template()` - Fixed special character handling, auto-reload after dynamic loading +- `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 +4. Match component pins at wire connection points using PinLocator +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 +- Fallback proximity detection (10mm threshold) +- 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 +- Net connectivity: 100% accurate (VCC: 2, GND: 4, +3V3: 1, +5V: 1) +- Netlist generation: 4 nets with accurate connections +- 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) + +--- + +### 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 +- ✅ Parametric component search +- ✅ Cost optimization (Basic vs Extended library) +- ✅ KiCad footprint mapping +- ✅ 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 +- `get_jlcpcb_database_stats` - Database statistics +- `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 +- Troubleshooting section + +--- + +## [2.1.0-alpha] - 2025-11-30 + +### 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 + +--- + +## [2.0.0-alpha] - 2025-11-05 + +### 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 +- Better organization for users + +--- + +## [2.0.0-alpha] - 2025-11-01 + +### 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 + +--- + +## [2.0.0-alpha] - 2025-10-26 + +### 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 + +--- + +## [1.0.0] - 2025-10-01 + +### Initial Release + +**Core Features:** + +- 64 fully-documented MCP tools +- JSON Schema validation for all tools +- 8 dynamic resources for project state +- Cross-platform support (Linux, Windows, macOS) +- Comprehensive error handling +- Detailed logging + +**Tool Categories:** + +- Project Management (4 tools) +- Board Operations (9 tools) +- Component Management (8 tools) +- Routing (6 tools) +- Export & Manufacturing (5 tools) +- Design Rule Checking (4 tools) +- Schematic Operations (6 tools) +- Symbol Library (3 tools) +- 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 +- Contributing guidelines + +--- + +## Version Numbering + +- **2.1.0-alpha**: Current development version with JLCPCB integration +- **2.0.0-alpha**: Router pattern and IPC backend +- **1.0.0**: Initial stable release + +## 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) + +## Deprecations + +### 2.1.0-alpha + +- `docs/JLCPCB_USAGE_GUIDE.md` - Superseded by `docs/JLCPCB_INTEGRATION.md` +- `docs/JLCPCB_INTEGRATION_PLAN.md` - Implementation complete + +## Migration Guide + +### 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 + +### 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 + +--- + +## Credits + +- **JLCSearch API**: [@tscircuit](https://github.com/tscircuit/jlcsearch) +- **JLCParts Database**: [@yaqwsx](https://github.com/yaqwsx/jlcparts) +- **Local JLCPCB Search**: [@l3wi](https://github.com/l3wi) +- **KiCad**: KiCad Development Team +- **MCP Protocol**: Anthropic + +## License + +See LICENSE file for details. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f4f204..2415801 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,414 +1,418 @@ -# Contributing to KiCAD MCP Server - -Thank you for your interest in contributing to the KiCAD MCP Server! This guide will help you get started with development. - -## Table of Contents - -- [Development Environment Setup](#development-environment-setup) -- [Project Structure](#project-structure) -- [Architecture Overview](#architecture-overview) -- [Development Workflow](#development-workflow) -- [Testing](#testing) -- [Code Style](#code-style) -- [Pull Request Process](#pull-request-process) -- [Roadmap & Planning](#roadmap--planning) - ---- - -## Development Environment Setup - -### Prerequisites - -- **KiCAD 9.0 or higher** - [Download here](https://www.kicad.org/download/) -- **Node.js v18+** - [Download here](https://nodejs.org/) -- **Python 3.10+** - Should come with KiCAD, or install separately -- **Git** - For version control - -### Platform-Specific Setup - -#### Linux (Ubuntu/Debian) - -```bash -# Install KiCAD 9.0 from official PPA -sudo add-apt-repository --yes ppa:kicad/kicad-9.0-releases -sudo apt-get update -sudo apt-get install -y kicad kicad-libraries - -# Install Node.js (if not already installed) -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 -cd kicad-mcp-server - -# Install Node.js dependencies -npm install - -# Install Python dependencies -pip3 install -r requirements-dev.txt - -# Build TypeScript -npm run build - -# Run tests -npm test -pytest -``` - -#### Windows - -```powershell -# Install KiCAD 9.0 from https://www.kicad.org/download/windows/ - -# Install Node.js from https://nodejs.org/ - -# Clone the repository -git clone https://github.com/yourusername/kicad-mcp-server.git -cd kicad-mcp-server - -# Install Node.js dependencies -npm install - -# Install Python dependencies -pip install -r requirements-dev.txt - -# Build TypeScript -npm run build - -# Run tests -npm test -pytest -``` - -#### macOS - -```bash -# Install KiCAD 9.0 from https://www.kicad.org/download/macos/ - -# Install Node.js via Homebrew -brew install node - -# Clone the repository -git clone https://github.com/yourusername/kicad-mcp-server.git -cd kicad-mcp-server - -# Install Node.js dependencies -npm install - -# Install Python dependencies -pip3 install -r requirements-dev.txt - -# Build TypeScript -npm run build - -# Run tests -npm test -pytest -``` - ---- - -## Project Structure - -``` -kicad-mcp-server/ -├── .github/ -│ └── workflows/ # CI/CD pipelines -├── config/ # Configuration examples -│ ├── linux-config.example.json -│ ├── windows-config.example.json -│ └── macos-config.example.json -├── docs/ # Documentation -├── python/ # Python interface layer -│ ├── commands/ # KiCAD command handlers -│ ├── integrations/ # External API integrations (JLCPCB, Digikey) -│ ├── utils/ # Utility modules -│ └── kicad_interface.py # Main Python entry point -├── src/ # TypeScript MCP server -│ ├── tools/ # MCP tool implementations -│ ├── resources/ # MCP resource implementations -│ ├── prompts/ # MCP prompt implementations -│ └── server.ts # Main server -├── tests/ # Test suite -│ ├── unit/ -│ ├── integration/ -│ └── fixtures/ -├── dist/ # Compiled JavaScript (generated) -├── node_modules/ # Node dependencies (generated) -├── package.json # Node.js configuration -├── tsconfig.json # TypeScript configuration -├── pytest.ini # Pytest configuration -├── requirements.txt # Python production dependencies -└── requirements-dev.txt # Python dev dependencies -``` - ---- - -## 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 - -```bash -git checkout -b feature/your-feature-name -``` - -### 2. Make Changes - -- Edit TypeScript files in `src/` -- Edit Python files in `python/` -- Add tests for new features - -### 3. Build & Test - -```bash -# Build TypeScript -npm run build - -# Run TypeScript linter -npm run lint - -# Run Python formatter -black python/ - -# Run Python type checker -mypy python/ - -# Run all tests -npm test -pytest - -# Run specific test file -pytest tests/test_platform_helper.py -v - -# Run with coverage -pytest --cov=python --cov-report=html -``` - -### 4. Commit Changes - -```bash -git add . -git commit -m "feat: Add your feature description" -``` - -**Commit Message Convention:** -- `feat:` - New feature -- `fix:` - Bug fix -- `docs:` - Documentation changes -- `test:` - Adding/updating tests -- `refactor:` - Code refactoring -- `chore:` - Maintenance tasks - -### 5. Push and Create Pull Request - -```bash -git push origin feature/your-feature-name -``` - -Then create a Pull Request on GitHub. - ---- - -## Testing - -### Running Tests - -```bash -# All tests -pytest - -# Unit tests only -pytest -m unit - -# Integration tests (requires KiCAD installed) -pytest -m integration - -# Platform-specific tests -pytest -m linux # Linux tests only -pytest -m windows # Windows tests only - -# With coverage report -pytest --cov=python --cov-report=term-missing - -# Verbose output -pytest -v - -# Stop on first failure -pytest -x -``` - -### Writing Tests - -Tests should be placed in `tests/` directory: - -```python -# tests/test_my_feature.py -import pytest - -@pytest.mark.unit -def test_my_feature(): - """Test description""" - # Arrange - expected = "result" - - # Act - result = my_function() - - # Assert - assert result == expected - -@pytest.mark.integration -@pytest.mark.linux -def test_linux_integration(): - """Integration test for Linux""" - # This test will only run on Linux in CI - pass -``` - ---- - -## Code Style - -### Python - -We use **Black** for code formatting and **MyPy** for type checking. - -```bash -# Format all Python files -black python/ - -# Check types -mypy python/ - -# Run linter -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 -- Add docstrings to all public functions/classes -- Follow PEP 8 - -**Example:** -```python -from pathlib import Path -from typing import List, Optional - -def find_kicad_libraries(search_path: Path) -> List[Path]: - """ - Find all KiCAD symbol libraries in the given path. - - Args: - search_path: Directory to search for .kicad_sym files - - Returns: - List of paths to found library files - - Raises: - ValueError: If search_path doesn't exist - """ - if not search_path.exists(): - raise ValueError(f"Search path does not exist: {search_path}") - - return list(search_path.glob("**/*.kicad_sym")) -``` - -### TypeScript - -We use **ESLint** and **Prettier** for TypeScript. - -```bash -# Format TypeScript files -npx prettier --write "src/**/*.ts" - -# Run linter -npx eslint src/ -``` - -**TypeScript Style Guidelines:** -- Use interfaces for data structures -- Use async/await for asynchronous code -- Use descriptive variable names -- Add JSDoc comments to exported functions - ---- - -## Pull Request Process - -1. **Update Documentation** - If you change functionality, update docs -2. **Add Tests** - All new features should have tests -3. **Run CI Locally** - Ensure all tests pass before pushing -4. **Create PR** - Use a clear, descriptive title -5. **Request Review** - Tag relevant maintainers -6. **Address Feedback** - Make requested changes -7. **Merge** - Maintainer will merge when approved - -### PR Checklist - -- [ ] Code follows style guidelines -- [ ] All tests pass locally -- [ ] New tests added for new features -- [ ] Documentation updated -- [ ] Commit messages follow convention -- [ ] No merge conflicts -- [ ] CI/CD pipeline passes - ---- - -## Roadmap & Planning - -We track work using GitHub Projects and Issues: - -- **GitHub Projects** - High-level roadmap and sprints -- **GitHub Issues** - Specific bugs and features -- **GitHub Discussions** - Design discussions and proposals - -### Current Priorities (Week 1-4) - -1. ✅ Linux compatibility fixes -2. ✅ Platform-agnostic path handling -3. ✅ CI/CD pipeline setup -4. 🔄 Migrate to KiCAD IPC API -5. ⏳ Add JLCPCB integration -6. ⏳ Add Digikey integration - -See [docs/REBUILD_PLAN.md](docs/REBUILD_PLAN.md) for the complete 12-week roadmap. - ---- - -## Getting Help - -- **GitHub Discussions** - Ask questions, propose ideas -- **GitHub Issues** - Report bugs, request features -- **Discord** - Real-time chat (link TBD) - ---- - -## License - -By contributing, you agree that your contributions will be licensed under the MIT License. - ---- - -## Thank You! 🎉 - -Your contributions make this project better for everyone. We appreciate your time and effort! +# Contributing to KiCAD MCP Server + +Thank you for your interest in contributing to the KiCAD MCP Server! This guide will help you get started with development. + +## Table of Contents + +- [Development Environment Setup](#development-environment-setup) +- [Project Structure](#project-structure) +- [Architecture Overview](#architecture-overview) +- [Development Workflow](#development-workflow) +- [Testing](#testing) +- [Code Style](#code-style) +- [Pull Request Process](#pull-request-process) +- [Roadmap & Planning](#roadmap--planning) + +--- + +## Development Environment Setup + +### Prerequisites + +- **KiCAD 9.0 or higher** - [Download here](https://www.kicad.org/download/) +- **Node.js v18+** - [Download here](https://nodejs.org/) +- **Python 3.10+** - Should come with KiCAD, or install separately +- **Git** - For version control + +### Platform-Specific Setup + +#### Linux (Ubuntu/Debian) + +```bash +# Install KiCAD 9.0 from official PPA +sudo add-apt-repository --yes ppa:kicad/kicad-9.0-releases +sudo apt-get update +sudo apt-get install -y kicad kicad-libraries + +# Install Node.js (if not already installed) +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 +cd kicad-mcp-server + +# Install Node.js dependencies +npm install + +# Install Python dependencies +pip3 install -r requirements-dev.txt + +# Build TypeScript +npm run build + +# Run tests +npm test +pytest +``` + +#### Windows + +```powershell +# Install KiCAD 9.0 from https://www.kicad.org/download/windows/ + +# Install Node.js from https://nodejs.org/ + +# Clone the repository +git clone https://github.com/yourusername/kicad-mcp-server.git +cd kicad-mcp-server + +# Install Node.js dependencies +npm install + +# Install Python dependencies +pip install -r requirements-dev.txt + +# Build TypeScript +npm run build + +# Run tests +npm test +pytest +``` + +#### macOS + +```bash +# Install KiCAD 9.0 from https://www.kicad.org/download/macos/ + +# Install Node.js via Homebrew +brew install node + +# Clone the repository +git clone https://github.com/yourusername/kicad-mcp-server.git +cd kicad-mcp-server + +# Install Node.js dependencies +npm install + +# Install Python dependencies +pip3 install -r requirements-dev.txt + +# Build TypeScript +npm run build + +# Run tests +npm test +pytest +``` + +--- + +## Project Structure + +``` +kicad-mcp-server/ +├── .github/ +│ └── workflows/ # CI/CD pipelines +├── config/ # Configuration examples +│ ├── linux-config.example.json +│ ├── windows-config.example.json +│ └── macos-config.example.json +├── docs/ # Documentation +├── python/ # Python interface layer +│ ├── commands/ # KiCAD command handlers +│ ├── integrations/ # External API integrations (JLCPCB, Digikey) +│ ├── utils/ # Utility modules +│ └── kicad_interface.py # Main Python entry point +├── src/ # TypeScript MCP server +│ ├── tools/ # MCP tool implementations +│ ├── resources/ # MCP resource implementations +│ ├── prompts/ # MCP prompt implementations +│ └── server.ts # Main server +├── tests/ # Test suite +│ ├── unit/ +│ ├── integration/ +│ └── fixtures/ +├── dist/ # Compiled JavaScript (generated) +├── node_modules/ # Node dependencies (generated) +├── package.json # Node.js configuration +├── tsconfig.json # TypeScript configuration +├── pytest.ini # Pytest configuration +├── requirements.txt # Python production dependencies +└── requirements-dev.txt # Python dev dependencies +``` + +--- + +## 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 + +```bash +git checkout -b feature/your-feature-name +``` + +### 2. Make Changes + +- Edit TypeScript files in `src/` +- Edit Python files in `python/` +- Add tests for new features + +### 3. Build & Test + +```bash +# Build TypeScript +npm run build + +# Run TypeScript linter +npm run lint + +# Run Python formatter +black python/ + +# Run Python type checker +mypy python/ + +# Run all tests +npm test +pytest + +# Run specific test file +pytest tests/test_platform_helper.py -v + +# Run with coverage +pytest --cov=python --cov-report=html +``` + +### 4. Commit Changes + +```bash +git add . +git commit -m "feat: Add your feature description" +``` + +**Commit Message Convention:** + +- `feat:` - New feature +- `fix:` - Bug fix +- `docs:` - Documentation changes +- `test:` - Adding/updating tests +- `refactor:` - Code refactoring +- `chore:` - Maintenance tasks + +### 5. Push and Create Pull Request + +```bash +git push origin feature/your-feature-name +``` + +Then create a Pull Request on GitHub. + +--- + +## Testing + +### Running Tests + +```bash +# All tests +pytest + +# Unit tests only +pytest -m unit + +# Integration tests (requires KiCAD installed) +pytest -m integration + +# Platform-specific tests +pytest -m linux # Linux tests only +pytest -m windows # Windows tests only + +# With coverage report +pytest --cov=python --cov-report=term-missing + +# Verbose output +pytest -v + +# Stop on first failure +pytest -x +``` + +### Writing Tests + +Tests should be placed in `tests/` directory: + +```python +# tests/test_my_feature.py +import pytest + +@pytest.mark.unit +def test_my_feature(): + """Test description""" + # Arrange + expected = "result" + + # Act + result = my_function() + + # Assert + assert result == expected + +@pytest.mark.integration +@pytest.mark.linux +def test_linux_integration(): + """Integration test for Linux""" + # This test will only run on Linux in CI + pass +``` + +--- + +## Code Style + +### Python + +We use **Black** for code formatting and **MyPy** for type checking. + +```bash +# Format all Python files +black python/ + +# Check types +mypy python/ + +# Run linter +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 +- Add docstrings to all public functions/classes +- Follow PEP 8 + +**Example:** + +```python +from pathlib import Path +from typing import List, Optional + +def find_kicad_libraries(search_path: Path) -> List[Path]: + """ + Find all KiCAD symbol libraries in the given path. + + Args: + search_path: Directory to search for .kicad_sym files + + Returns: + List of paths to found library files + + Raises: + ValueError: If search_path doesn't exist + """ + if not search_path.exists(): + raise ValueError(f"Search path does not exist: {search_path}") + + return list(search_path.glob("**/*.kicad_sym")) +``` + +### TypeScript + +We use **ESLint** and **Prettier** for TypeScript. + +```bash +# Format TypeScript files +npx prettier --write "src/**/*.ts" + +# Run linter +npx eslint src/ +``` + +**TypeScript Style Guidelines:** + +- Use interfaces for data structures +- Use async/await for asynchronous code +- Use descriptive variable names +- Add JSDoc comments to exported functions + +--- + +## Pull Request Process + +1. **Update Documentation** - If you change functionality, update docs +2. **Add Tests** - All new features should have tests +3. **Run CI Locally** - Ensure all tests pass before pushing +4. **Create PR** - Use a clear, descriptive title +5. **Request Review** - Tag relevant maintainers +6. **Address Feedback** - Make requested changes +7. **Merge** - Maintainer will merge when approved + +### PR Checklist + +- [ ] Code follows style guidelines +- [ ] All tests pass locally +- [ ] New tests added for new features +- [ ] Documentation updated +- [ ] Commit messages follow convention +- [ ] No merge conflicts +- [ ] CI/CD pipeline passes + +--- + +## Roadmap & Planning + +We track work using GitHub Projects and Issues: + +- **GitHub Projects** - High-level roadmap and sprints +- **GitHub Issues** - Specific bugs and features +- **GitHub Discussions** - Design discussions and proposals + +### Current Priorities (Week 1-4) + +1. ✅ Linux compatibility fixes +2. ✅ Platform-agnostic path handling +3. ✅ CI/CD pipeline setup +4. 🔄 Migrate to KiCAD IPC API +5. ⏳ Add JLCPCB integration +6. ⏳ Add Digikey integration + +See [docs/REBUILD_PLAN.md](docs/REBUILD_PLAN.md) for the complete 12-week roadmap. + +--- + +## Getting Help + +- **GitHub Discussions** - Ask questions, propose ideas +- **GitHub Issues** - Report bugs, request features +- **Discord** - Real-time chat (link TBD) + +--- + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. + +--- + +## Thank You! 🎉 + +Your contributions make this project better for everyone. We appreciate your time and effort! diff --git a/README.md b/README.md index 1c88ddb..4f284fc 100644 --- a/README.md +++ b/README.md @@ -1,988 +1,1047 @@ -# Discussions. Get in here. -https://github.com/mixelpixx/KiCAD-MCP-Server/discussions/73 - - -# KiCAD MCP Server - -A Model Context Protocol (MCP) server that enables AI assistants like Claude to interact with KiCAD for PCB design automation. Built on the MCP 2025-06-18 specification, this server provides comprehensive tool schemas and real-time project state access for intelligent PCB design workflows. - -## Overview - -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:** -- 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 - -A complete workflow for designing passthrough adapter boards (e.g. Raspberry Pi CSI -cable adapters) is now supported: - -1. `connect_passthrough` — wires all pins of one connector to the matching pins of - another in the schematic (J1 pin N → J2 pin N, auto-named nets). -2. `sync_schematic_to_board` — imports the net assignments into the PCB. -3. `route_pad_to_pad` — routes each connection with automatic via insertion when - pads are on opposite copper layers. -4. `snapshot_project` — saves a named checkpoint into `/snapshots/`. - -### Bug Fixes (KiCAD 9 / Windows) - -- **Via insertion for B.Cu footprints** — `route_pad_to_pad` now correctly detects - when a footprint is on B.Cu and inserts the required via. (KiCAD 9 SWIG returned - `F.Cu` for all SMD pads regardless of layer — fixed.) -- **Board outline rounded corners** — `add_board_outline` now correctly applies - `cornerRadius` when `shape="rounded_rectangle"`. -- **B.Cu placement hang** — placing a footprint on B.Cu no longer causes a ~30s - freeze in KiCAD 9. - -### Developer Mode - -Set `KICAD_MCP_DEV=1` in your Claude Desktop MCP environment to automatically save -the MCP session log into the project's `logs/` folder on every `export_gerber` and -`snapshot_project` call. Useful for debugging and for attaching to GitHub issues. - -```json -"env": { - "KICAD_MCP_DEV": "1" -} -``` - -> **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.** - -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 -- Only 13 component types available (severe limitation) -- No working wire/connection functionality - -**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 - - Seamless integration with existing MCP tools - - 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.) - - **Wire graph analysis** - geometric tracing for net connectivity - - **Net label management** (local, global, hierarchical labels) - - **Netlist generation** with accurate component/pin connections - -**Technical Architecture:** -The kicad-skip library cannot create symbols or wires from scratch. We implemented a comprehensive solution: - -1. **Static Templates:** 13 pre-configured symbols (R, C, L, LED, etc.) for instant use -2. **Dynamic Loading:** On-demand injection of ANY symbol from KiCad libraries: - - Parse `.kicad_sym` library files using S-expression parser - - Inject symbol definition into schematic's `lib_symbols` section - - Create offscreen template instance - - Reload schematic so kicad-skip sees new template - - Clone template to create actual component -3. **Wire Creation:** S-expression-based wire injection (bypasses kicad-skip API limitations) -4. **Pin Discovery:** Parse symbol definitions, apply rotation transformations, calculate absolute positions -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") - -# Place components with auto-rotation -ComponentManager.add_component(sch, { - "type": "STM32F103C8Tx", - "library": "MCU_ST_STM32F1", - "reference": "U1", - "x": 100, "y": 100, "rotation": 0 -}) - -# Connect with intelligent routing -ConnectionManager.add_connection(sch_path, "U1", "1", "R1", "2", routing="orthogonal_h") - -# Connect to power nets -ConnectionManager.connect_to_net(sch_path, "U1", "VDD", "VCC") - -# Analyze connectivity -connections = ConnectionManager.get_net_connections(sch, "VCC", sch_path) -# Returns: [{"component": "U1", "pin": "VDD"}, {"component": "R1", "pin": "1"}] -``` - -**Test Results:** -- Component placement: 100% passing -- Dynamic symbol loading: 10,000+ symbols accessible -- Wire creation: 100% passing (8/8 connections in test circuit) -- Pin discovery: Rotation-aware, sub-millimeter accuracy -- Net connectivity: 100% accurate (VCC: 2 connections, GND: 4 connections) -- Netlist generation: Working with accurate pin-level connections - -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 -- 20+ commands now support IPC including routing, component placement, and zone operations - -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: -- **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 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) -- Intelligent cost optimization with alternative part suggestions -- Package-to-footprint mapping for KiCAD compatibility -- Parametric search by category, package, manufacturer -- Local SQLite database for fast offline searching -- No API credentials required for local library search - -**Why this matters:** JLCPCB offers PCB assembly services where Basic parts have no assembly fee, while Extended parts charge $3 per unique component. This integration helps you find the cheapest components with the best availability, potentially saving hundreds of dollars on assembly costs for production runs. - -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 -- Enumerated values for categorical inputs -- 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) -- `kicad://project/current/nets` - Electrical nets -- `kicad://project/current/layers` - Layer stack configuration -- `kicad://project/current/design-rules` - Current DRC settings -- `kicad://project/current/drc-report` - Design rule violations -- `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 -- Standards-compliant error codes - -## Available Tools - -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) -- `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) -- `set_board_size` - Configure PCB dimensions -- `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) -- `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 (13 tools) -- `add_net` - Create electrical net -- `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 (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 - -### 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 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 - -### Freerouting Autorouter (4 tools) -- `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 - -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 - -## Prerequisites - -### Required Software - -**KiCAD 9.0 or Higher** -- Download from [kicad.org/download](https://www.kicad.org/download/) -- Must include Python module (pcbnew) -- Verify installation: - ```bash - python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())" - ``` - -**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) - - kicad-skip >= 0.1.0 (schematic support) - - Pillow >= 9.0.0 (image processing) - - cairosvg >= 2.7.0 (SVG rendering) - - colorlog >= 6.7.0 (logging) - - pydantic >= 2.5.0 (validation) - - requests >= 2.32.5 (HTTP client) - - python-dotenv >= 1.0.0 (environment) - -**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 - -## Installation - -### Linux (Ubuntu/Debian) - -```bash -# Install KiCAD 9.0 -sudo add-apt-repository --yes ppa:kicad/kicad-9.0-releases -sudo apt-get update -sudo apt-get install -y kicad kicad-libraries - -# Install Node.js -curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - -sudo apt-get install -y nodejs - -# Clone and build -git clone https://github.com/mixelpixx/KiCAD-MCP-Server.git -cd KiCAD-MCP-Server -npm install -pip3 install -r requirements.txt -npm run build - -# Verify -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 -.\setup-windows.ps1 -``` - -The script will: -- Detect KiCAD installation -- Verify prerequisites -- Install dependencies -- Build project -- Generate configuration -- Run diagnostics - -**Manual Setup:** -See [Windows Installation Guide](docs/WINDOWS_SETUP.md) for detailed instructions. - -### macOS - -**Important:** On macOS, use KiCAD's bundled Python to ensure proper access to pcbnew module. - -```bash -# Install KiCAD 9.0 from kicad.org/download/macos - -# Install Node.js -brew install node@20 - -# Clone repository -git clone https://github.com/mixelpixx/KiCAD-MCP-Server.git -cd KiCAD-MCP-Server - -# Create virtual environment using KiCAD's bundled Python -/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/Current/bin/python3 -m venv venv --system-site-packages - -# Activate virtual environment -source venv/bin/activate - -# Install dependencies -npm install -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. - -## Configuration - -### Claude Desktop - -Edit configuration file: -- **Linux/macOS:** `~/.config/Claude/claude_desktop_config.json` -- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json` - -**Configuration:** -```json -{ - "mcpServers": { - "kicad": { - "command": "node", - "args": ["/path/to/KiCAD-MCP-Server/dist/index.js"], - "env": { - "PYTHONPATH": "/path/to/kicad/python", - "LOG_LEVEL": "info" - } - } - } -} -``` - -**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` - -#### Linux Python Detection - -The server automatically detects Python on Linux in this priority order: - -1. **Virtual environment** - `venv/bin/python` or `.venv/bin/python` (highest priority) -2. **KICAD_PYTHON env var** - User override for non-standard installations -3. **KiCad bundled Python** - `/usr/lib/kicad/bin/python3`, `/usr/local/lib/kicad/bin/python3`, `/opt/kicad/bin/python3` -4. **System Python via which** - Resolves `which python3` to absolute path (e.g., `/usr/bin/python3`) -5. **Common system paths** - `/usr/bin/python3`, `/bin/python3` - -**For most standard Linux installations (Ubuntu, Debian, Fedora, Arch), no KICAD_PYTHON configuration is needed** - the server will automatically find your Python installation. - -**Troubleshooting:** - -If you see "Python executable not found: python3", you can manually specify the Python path: - -```json -{ - "mcpServers": { - "kicad": { - "command": "node", - "args": ["/path/to/KiCAD-MCP-Server/dist/index.js"], - "env": { - "KICAD_PYTHON": "/usr/bin/python3", - "PYTHONPATH": "/usr/lib/kicad/lib/python3/dist-packages" - } - } - } -} -``` - -To find your Python path: -```bash -which python3 # Example output: /usr/bin/python3 -python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())" # Verify pcbnew access -``` - -### Cline (VSCode) - -Edit: `~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` - -Use the same configuration format as Claude Desktop above. - -### Claude Code - -Claude Code automatically detects MCP servers in the current directory. No additional configuration needed. - -### JLCPCB Integration Setup (Optional) - -The JLCPCB integration provides two modes that can be used independently or together: - -**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" -``` - -This creates a local SQLite database at `data/jlcpcb_parts.db` (3-5 GB for full 2.5M+ part catalog). - -**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" -4. Install libraries like `JLCPCB-KiCAD-Library` or `EDA_MCP` -5. Use `search_symbols` to find components with pre-configured footprints and LCSC IDs - -**Mode 3: Official JLCPCB API (Advanced - Requires Enterprise Account)** - -For users with JLCPCB enterprise accounts and order history: - -1. **Get API Credentials** - - Log in to [JLCPCB](https://jlcpcb.com/) - - Navigate to Account > API Management (requires enterprise approval) - - Create API Key and save your `appKey` and `appSecret` - - Note: This requires prior order history and enterprise account approval - -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 - ``` - -See [JLCPCB Usage Guide](docs/JLCPCB_USAGE_GUIDE.md) for detailed documentation. - -## Usage Examples - -### Basic PCB Design Workflow - -```text -Create a new KiCAD project named 'LEDBoard' in my Documents folder. -Set the board size to 50mm x 50mm and add a rectangular outline. -Place a mounting hole at each corner, 3mm from the edges, with 3mm diameter. -Add text 'LED Controller v1.0' on the front silkscreen at position x=25mm, y=45mm. -``` - -### Component Placement - -```text -Place an LED at x=10mm, y=10mm using footprint LED_SMD:LED_0805_2012Metric. -Create a grid of 4 resistors (R1-R4) starting at x=20mm, y=20mm with 5mm spacing. -Align all resistors horizontally and distribute them evenly. -``` - -### Routing - -```text -Create a net named 'LED1' and route a 0.3mm trace from R1 pad 2 to LED1 anode. -Add a copper pour for GND on the bottom layer covering the entire board. -Create a differential pair for USB_P and USB_N with 0.2mm width and 0.15mm gap. -``` - -### Autoroute with Freerouting - -Automatically route all unconnected nets using the [Freerouting](https://github.com/freerouting/freerouting) autorouter. - -**Setup (one-time):** - -```bash -# 1. Download the Freerouting JAR -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 - -# 2. Runtime — pick ONE: -# Option A: Docker (recommended, no Java install needed) -docker pull eclipse-temurin:21-jre - -# Option B: Install Java 21+ locally -# (Ubuntu/Debian) sudo apt install openjdk-21-jre -``` - -The autorouter auto-detects which runtime is available (Java 21+ direct, or Docker/Podman fallback). - -```text -Check if Freerouting is ready on my system. -Autoroute the current board using Freerouting with a 5-minute timeout. -``` - -**Step-by-step workflow:** - -```text -1. Open the project at ~/Projects/LEDBoard/LEDBoard.kicad_pcb -2. Check Freerouting dependencies are installed -3. Run autoroute with max 10 passes -4. Run DRC to verify the autorouted result -5. Export Gerbers to the fabrication folder -``` - -**Manual DSN/SES workflow** (for advanced users or external autorouters): - -```text -Export the board to Specctra DSN format. -# ... run Freerouting GUI or another autorouter externally ... -Import the routed SES file from ~/Projects/LEDBoard/LEDBoard.ses -``` - -### Design Verification - -```text -Set design rules with 0.15mm clearance and 0.2mm minimum track width. -Run a design rule check and show me any violations. -Export Gerber files to the 'fabrication' folder. -``` - -### Using Resources - -Resources provide read-only access to project state: - -```text -Show me the current component list. -What are the current design rules? -Display the board preview. -List all electrical nets. -``` - -### JLCPCB Component Selection - -**Finding Components with Local Libraries:** - -```text -Search for ESP32 modules in JLCPCB libraries. -Find a 10k resistor in 0603 package from installed libraries. -Show me details for LCSC part C2934196. -``` - -**Optimizing Costs with JLCPCB API:** - -```text -Search for 10k ohm resistors in 0603 package, only Basic parts. -Find the cheapest capacitor 10uF 25V in 0805 package with good stock. -Show me pricing and stock for JLCPCB part C25804. -Suggest cheaper alternatives to C25804. -``` - -**Complete Design Workflow:** - -```text -I'm designing a board with an ESP32 and need to select components for JLCPCB assembly. -Search JLCPCB for ESP32-C3 modules. -Find Basic parts for: 10k resistor 0603, 100nF capacitor 0603, LED 0805. -For each component, show me the cheapest option with good stock availability. -Place these components on my board using the suggested footprints. -``` - -**Database Management:** - -```text -Download the JLCPCB parts database (first time setup). -Show me JLCPCB database statistics. -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 (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 -- Provides logging and error recovery -- **Router System:** - - `src/tools/registry.ts` - Tool categorization and lookup - - `src/tools/router.ts` - Discovery and execution tools - - 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 - - `ipc_backend.py` - KiCAD 9.0 IPC API backend (real-time UI sync) - - `swig_backend.py` - pcbnew SWIG API backend (file-based operations) - - `factory.py` - Backend auto-detection and instantiation -- **schemas/tool_schemas.py:** JSON Schema definitions for all tools -- **resources/resource_definitions.py:** Resource handlers and URIs -- **commands/:** Modular command implementations - - `project.py` - Project operations - - `board.py` - Board manipulation - - `component.py` - Component placement - - `routing.py` - Trace routing and nets - - `design_rules.py` - DRC operations - - `export.py` - File generation - - `schematic.py` - Schematic design - - `library.py` - Footprint libraries - - `library_symbol.py` - Symbol library search (local JLCPCB libraries) - - `jlcpcb.py` - JLCPCB API client - - `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 -- **kicad-skip:** Schematic file manipulation -- **Platform Detection:** Cross-platform path handling -- **UI Management:** Automatic KiCAD UI launch/detection - -## Development - -### Building from Source - -```bash -# Install dependencies -npm install -pip3 install -r requirements.txt - -# Build TypeScript -npm run build - -# Watch mode for development -npm run dev -``` - -### Running Tests - -```bash -# TypeScript tests -npm run test:ts - -# Python tests -npm run test:py - -# All tests with coverage -npm run test:coverage -``` - -### Linting and Formatting - -```bash -# Lint TypeScript and Python -npm run lint - -# Format code -npm run format -``` - -## Troubleshooting - -### Server Not Appearing in Client - -**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 -4. Check client logs for error messages - -### Python Module Import Errors - -**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 - -### Tool Execution Failures - -**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 -4. Check tool parameter types match schema requirements - -### Windows-Specific Issues - -**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 -4. See [Windows Troubleshooting Guide](docs/WINDOWS_TROUBLESHOOTING.md) - -### Getting Help - -1. Check the [GitHub Issues](https://github.com/mixelpixx/KiCAD-MCP-Server/issues) -2. Review server logs: `~/.kicad-mcp/logs/kicad_interface.log` -3. Open a new issue with: - - Operating system and version - - KiCAD version (`python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())"`) - - Node.js version (`node --version`) - - Full error message and stack trace - - Relevant log excerpts - -## Project Status - -**Current Version:** 2.2.3 - -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):** -- Real-time UI synchronization via KiCAD 9.0 IPC API -- 21 IPC-enabled commands with automatic SWIG fallback -- Hybrid footprint loading (SWIG for library access, IPC for placement) - -**Developer Mode:** -Set `KICAD_MCP_DEV=1` to capture MCP session logs for debugging. See CHANGELOG v2.2.3 for details. - -See [ROADMAP.md](docs/ROADMAP.md) for planned features. - -## What Do You Want to See Next? - -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 - -## Contributing - -Contributions are welcome! Please follow these guidelines: - -1. **Report Bugs:** Open an issue with reproduction steps -2. **Suggest Features:** Describe use case and expected behavior -3. **Submit Pull Requests:** - - Fork the repository - - Create a feature branch - - Follow existing code style - - Add tests for new functionality - - Update documentation - - Submit PR with clear description - -See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. - -## License - -This project is licensed under the MIT License. See [LICENSE](LICENSE) for details. - -## Acknowledgments - -- 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 -- [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: - -```bibtex -@software{kicad_mcp_server, - title = {KiCAD MCP Server: AI-Assisted PCB Design}, - author = {mixelpixx}, - year = {2025}, - url = {https://github.com/mixelpixx/KiCAD-MCP-Server}, - version = {2.2.3} -} -``` +# Discussions. Get in here. + +https://github.com/mixelpixx/KiCAD-MCP-Server/discussions/73 + +# KiCAD MCP Server + +A Model Context Protocol (MCP) server that enables AI assistants like Claude to interact with KiCAD for PCB design automation. Built on the MCP 2025-06-18 specification, this server provides comprehensive tool schemas and real-time project state access for intelligent PCB design workflows. + +## Overview + +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:** + +- 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 + +A complete workflow for designing passthrough adapter boards (e.g. Raspberry Pi CSI +cable adapters) is now supported: + +1. `connect_passthrough` — wires all pins of one connector to the matching pins of + another in the schematic (J1 pin N → J2 pin N, auto-named nets). +2. `sync_schematic_to_board` — imports the net assignments into the PCB. +3. `route_pad_to_pad` — routes each connection with automatic via insertion when + pads are on opposite copper layers. +4. `snapshot_project` — saves a named checkpoint into `/snapshots/`. + +### Bug Fixes (KiCAD 9 / Windows) + +- **Via insertion for B.Cu footprints** — `route_pad_to_pad` now correctly detects + when a footprint is on B.Cu and inserts the required via. (KiCAD 9 SWIG returned + `F.Cu` for all SMD pads regardless of layer — fixed.) +- **Board outline rounded corners** — `add_board_outline` now correctly applies + `cornerRadius` when `shape="rounded_rectangle"`. +- **B.Cu placement hang** — placing a footprint on B.Cu no longer causes a ~30s + freeze in KiCAD 9. + +### Developer Mode + +Set `KICAD_MCP_DEV=1` in your Claude Desktop MCP environment to automatically save +the MCP session log into the project's `logs/` folder on every `export_gerber` and +`snapshot_project` call. Useful for debugging and for attaching to GitHub issues. + +```json +"env": { + "KICAD_MCP_DEV": "1" +} +``` + +> **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.** + +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 +- Only 13 component types available (severe limitation) +- No working wire/connection functionality + +**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 +- Seamless integration with existing MCP tools +- 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.) +- **Wire graph analysis** - geometric tracing for net connectivity +- **Net label management** (local, global, hierarchical labels) +- **Netlist generation** with accurate component/pin connections + +**Technical Architecture:** +The kicad-skip library cannot create symbols or wires from scratch. We implemented a comprehensive solution: + +1. **Static Templates:** 13 pre-configured symbols (R, C, L, LED, etc.) for instant use +2. **Dynamic Loading:** On-demand injection of ANY symbol from KiCad libraries: + - Parse `.kicad_sym` library files using S-expression parser + - Inject symbol definition into schematic's `lib_symbols` section + - Create offscreen template instance + - Reload schematic so kicad-skip sees new template + - Clone template to create actual component +3. **Wire Creation:** S-expression-based wire injection (bypasses kicad-skip API limitations) +4. **Pin Discovery:** Parse symbol definitions, apply rotation transformations, calculate absolute positions +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") + +# Place components with auto-rotation +ComponentManager.add_component(sch, { + "type": "STM32F103C8Tx", + "library": "MCU_ST_STM32F1", + "reference": "U1", + "x": 100, "y": 100, "rotation": 0 +}) + +# Connect with intelligent routing +ConnectionManager.add_connection(sch_path, "U1", "1", "R1", "2", routing="orthogonal_h") + +# Connect to power nets +ConnectionManager.connect_to_net(sch_path, "U1", "VDD", "VCC") + +# Analyze connectivity +connections = ConnectionManager.get_net_connections(sch, "VCC", sch_path) +# Returns: [{"component": "U1", "pin": "VDD"}, {"component": "R1", "pin": "1"}] +``` + +**Test Results:** + +- Component placement: 100% passing +- Dynamic symbol loading: 10,000+ symbols accessible +- Wire creation: 100% passing (8/8 connections in test circuit) +- Pin discovery: Rotation-aware, sub-millimeter accuracy +- Net connectivity: 100% accurate (VCC: 2 connections, GND: 4 connections) +- Netlist generation: Working with accurate pin-level connections + +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 +- 20+ commands now support IPC including routing, component placement, and zone operations + +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: + +- **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 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) +- Intelligent cost optimization with alternative part suggestions +- Package-to-footprint mapping for KiCAD compatibility +- Parametric search by category, package, manufacturer +- Local SQLite database for fast offline searching +- No API credentials required for local library search + +**Why this matters:** JLCPCB offers PCB assembly services where Basic parts have no assembly fee, while Extended parts charge $3 per unique component. This integration helps you find the cheapest components with the best availability, potentially saving hundreds of dollars on assembly costs for production runs. + +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 +- Enumerated values for categorical inputs +- 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) +- `kicad://project/current/nets` - Electrical nets +- `kicad://project/current/layers` - Layer stack configuration +- `kicad://project/current/design-rules` - Current DRC settings +- `kicad://project/current/drc-report` - Design rule violations +- `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 +- Standards-compliant error codes + +## Available Tools + +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) + +- `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) + +- `set_board_size` - Configure PCB dimensions +- `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) + +- `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 (13 tools) + +- `add_net` - Create electrical net +- `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 (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 + +### 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 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 + +### Freerouting Autorouter (4 tools) + +- `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 + +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 + +## Prerequisites + +### Required Software + +**KiCAD 9.0 or Higher** + +- Download from [kicad.org/download](https://www.kicad.org/download/) +- Must include Python module (pcbnew) +- Verify installation: + ```bash + python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())" + ``` + +**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) + - kicad-skip >= 0.1.0 (schematic support) + - Pillow >= 9.0.0 (image processing) + - cairosvg >= 2.7.0 (SVG rendering) + - colorlog >= 6.7.0 (logging) + - pydantic >= 2.5.0 (validation) + - requests >= 2.32.5 (HTTP client) + - python-dotenv >= 1.0.0 (environment) + +**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 + +## Installation + +### Linux (Ubuntu/Debian) + +```bash +# Install KiCAD 9.0 +sudo add-apt-repository --yes ppa:kicad/kicad-9.0-releases +sudo apt-get update +sudo apt-get install -y kicad kicad-libraries + +# Install Node.js +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt-get install -y nodejs + +# Clone and build +git clone https://github.com/mixelpixx/KiCAD-MCP-Server.git +cd KiCAD-MCP-Server +npm install +pip3 install -r requirements.txt +npm run build + +# Verify +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 +.\setup-windows.ps1 +``` + +The script will: + +- Detect KiCAD installation +- Verify prerequisites +- Install dependencies +- Build project +- Generate configuration +- Run diagnostics + +**Manual Setup:** +See [Windows Installation Guide](docs/WINDOWS_SETUP.md) for detailed instructions. + +### macOS + +**Important:** On macOS, use KiCAD's bundled Python to ensure proper access to pcbnew module. + +```bash +# Install KiCAD 9.0 from kicad.org/download/macos + +# Install Node.js +brew install node@20 + +# Clone repository +git clone https://github.com/mixelpixx/KiCAD-MCP-Server.git +cd KiCAD-MCP-Server + +# Create virtual environment using KiCAD's bundled Python +/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/Current/bin/python3 -m venv venv --system-site-packages + +# Activate virtual environment +source venv/bin/activate + +# Install dependencies +npm install +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. + +## Configuration + +### Claude Desktop + +Edit configuration file: + +- **Linux/macOS:** `~/.config/Claude/claude_desktop_config.json` +- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json` + +**Configuration:** + +```json +{ + "mcpServers": { + "kicad": { + "command": "node", + "args": ["/path/to/KiCAD-MCP-Server/dist/index.js"], + "env": { + "PYTHONPATH": "/path/to/kicad/python", + "LOG_LEVEL": "info" + } + } + } +} +``` + +**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` + +#### Linux Python Detection + +The server automatically detects Python on Linux in this priority order: + +1. **Virtual environment** - `venv/bin/python` or `.venv/bin/python` (highest priority) +2. **KICAD_PYTHON env var** - User override for non-standard installations +3. **KiCad bundled Python** - `/usr/lib/kicad/bin/python3`, `/usr/local/lib/kicad/bin/python3`, `/opt/kicad/bin/python3` +4. **System Python via which** - Resolves `which python3` to absolute path (e.g., `/usr/bin/python3`) +5. **Common system paths** - `/usr/bin/python3`, `/bin/python3` + +**For most standard Linux installations (Ubuntu, Debian, Fedora, Arch), no KICAD_PYTHON configuration is needed** - the server will automatically find your Python installation. + +**Troubleshooting:** + +If you see "Python executable not found: python3", you can manually specify the Python path: + +```json +{ + "mcpServers": { + "kicad": { + "command": "node", + "args": ["/path/to/KiCAD-MCP-Server/dist/index.js"], + "env": { + "KICAD_PYTHON": "/usr/bin/python3", + "PYTHONPATH": "/usr/lib/kicad/lib/python3/dist-packages" + } + } + } +} +``` + +To find your Python path: + +```bash +which python3 # Example output: /usr/bin/python3 +python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())" # Verify pcbnew access +``` + +### Cline (VSCode) + +Edit: `~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` + +Use the same configuration format as Claude Desktop above. + +### Claude Code + +Claude Code automatically detects MCP servers in the current directory. No additional configuration needed. + +### JLCPCB Integration Setup (Optional) + +The JLCPCB integration provides two modes that can be used independently or together: + +**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" +``` + +This creates a local SQLite database at `data/jlcpcb_parts.db` (3-5 GB for full 2.5M+ part catalog). + +**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" +4. Install libraries like `JLCPCB-KiCAD-Library` or `EDA_MCP` +5. Use `search_symbols` to find components with pre-configured footprints and LCSC IDs + +**Mode 3: Official JLCPCB API (Advanced - Requires Enterprise Account)** + +For users with JLCPCB enterprise accounts and order history: + +1. **Get API Credentials** + - Log in to [JLCPCB](https://jlcpcb.com/) + - Navigate to Account > API Management (requires enterprise approval) + - Create API Key and save your `appKey` and `appSecret` + - Note: This requires prior order history and enterprise account approval + +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 + ``` + +See [JLCPCB Usage Guide](docs/JLCPCB_USAGE_GUIDE.md) for detailed documentation. + +## Usage Examples + +### Basic PCB Design Workflow + +```text +Create a new KiCAD project named 'LEDBoard' in my Documents folder. +Set the board size to 50mm x 50mm and add a rectangular outline. +Place a mounting hole at each corner, 3mm from the edges, with 3mm diameter. +Add text 'LED Controller v1.0' on the front silkscreen at position x=25mm, y=45mm. +``` + +### Component Placement + +```text +Place an LED at x=10mm, y=10mm using footprint LED_SMD:LED_0805_2012Metric. +Create a grid of 4 resistors (R1-R4) starting at x=20mm, y=20mm with 5mm spacing. +Align all resistors horizontally and distribute them evenly. +``` + +### Routing + +```text +Create a net named 'LED1' and route a 0.3mm trace from R1 pad 2 to LED1 anode. +Add a copper pour for GND on the bottom layer covering the entire board. +Create a differential pair for USB_P and USB_N with 0.2mm width and 0.15mm gap. +``` + +### Autoroute with Freerouting + +Automatically route all unconnected nets using the [Freerouting](https://github.com/freerouting/freerouting) autorouter. + +**Setup (one-time):** + +```bash +# 1. Download the Freerouting JAR +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 + +# 2. Runtime — pick ONE: +# Option A: Docker (recommended, no Java install needed) +docker pull eclipse-temurin:21-jre + +# Option B: Install Java 21+ locally +# (Ubuntu/Debian) sudo apt install openjdk-21-jre +``` + +The autorouter auto-detects which runtime is available (Java 21+ direct, or Docker/Podman fallback). + +```text +Check if Freerouting is ready on my system. +Autoroute the current board using Freerouting with a 5-minute timeout. +``` + +**Step-by-step workflow:** + +```text +1. Open the project at ~/Projects/LEDBoard/LEDBoard.kicad_pcb +2. Check Freerouting dependencies are installed +3. Run autoroute with max 10 passes +4. Run DRC to verify the autorouted result +5. Export Gerbers to the fabrication folder +``` + +**Manual DSN/SES workflow** (for advanced users or external autorouters): + +```text +Export the board to Specctra DSN format. +# ... run Freerouting GUI or another autorouter externally ... +Import the routed SES file from ~/Projects/LEDBoard/LEDBoard.ses +``` + +### Design Verification + +```text +Set design rules with 0.15mm clearance and 0.2mm minimum track width. +Run a design rule check and show me any violations. +Export Gerber files to the 'fabrication' folder. +``` + +### Using Resources + +Resources provide read-only access to project state: + +```text +Show me the current component list. +What are the current design rules? +Display the board preview. +List all electrical nets. +``` + +### JLCPCB Component Selection + +**Finding Components with Local Libraries:** + +```text +Search for ESP32 modules in JLCPCB libraries. +Find a 10k resistor in 0603 package from installed libraries. +Show me details for LCSC part C2934196. +``` + +**Optimizing Costs with JLCPCB API:** + +```text +Search for 10k ohm resistors in 0603 package, only Basic parts. +Find the cheapest capacitor 10uF 25V in 0805 package with good stock. +Show me pricing and stock for JLCPCB part C25804. +Suggest cheaper alternatives to C25804. +``` + +**Complete Design Workflow:** + +```text +I'm designing a board with an ESP32 and need to select components for JLCPCB assembly. +Search JLCPCB for ESP32-C3 modules. +Find Basic parts for: 10k resistor 0603, 100nF capacitor 0603, LED 0805. +For each component, show me the cheapest option with good stock availability. +Place these components on my board using the suggested footprints. +``` + +**Database Management:** + +```text +Download the JLCPCB parts database (first time setup). +Show me JLCPCB database statistics. +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 (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 +- Provides logging and error recovery +- **Router System:** + - `src/tools/registry.ts` - Tool categorization and lookup + - `src/tools/router.ts` - Discovery and execution tools + - 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 + - `ipc_backend.py` - KiCAD 9.0 IPC API backend (real-time UI sync) + - `swig_backend.py` - pcbnew SWIG API backend (file-based operations) + - `factory.py` - Backend auto-detection and instantiation +- **schemas/tool_schemas.py:** JSON Schema definitions for all tools +- **resources/resource_definitions.py:** Resource handlers and URIs +- **commands/:** Modular command implementations + - `project.py` - Project operations + - `board.py` - Board manipulation + - `component.py` - Component placement + - `routing.py` - Trace routing and nets + - `design_rules.py` - DRC operations + - `export.py` - File generation + - `schematic.py` - Schematic design + - `library.py` - Footprint libraries + - `library_symbol.py` - Symbol library search (local JLCPCB libraries) + - `jlcpcb.py` - JLCPCB API client + - `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 +- **kicad-skip:** Schematic file manipulation +- **Platform Detection:** Cross-platform path handling +- **UI Management:** Automatic KiCAD UI launch/detection + +## Development + +### Building from Source + +```bash +# Install dependencies +npm install +pip3 install -r requirements.txt + +# Build TypeScript +npm run build + +# Watch mode for development +npm run dev +``` + +### Running Tests + +```bash +# TypeScript tests +npm run test:ts + +# Python tests +npm run test:py + +# All tests with coverage +npm run test:coverage +``` + +### Linting and Formatting + +```bash +# Lint TypeScript and Python +npm run lint + +# Format code +npm run format +``` + +## Troubleshooting + +### Server Not Appearing in Client + +**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 +4. Check client logs for error messages + +### Python Module Import Errors + +**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 + +### Tool Execution Failures + +**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 +4. Check tool parameter types match schema requirements + +### Windows-Specific Issues + +**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 +4. See [Windows Troubleshooting Guide](docs/WINDOWS_TROUBLESHOOTING.md) + +### Getting Help + +1. Check the [GitHub Issues](https://github.com/mixelpixx/KiCAD-MCP-Server/issues) +2. Review server logs: `~/.kicad-mcp/logs/kicad_interface.log` +3. Open a new issue with: + - Operating system and version + - KiCAD version (`python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())"`) + - Node.js version (`node --version`) + - Full error message and stack trace + - Relevant log excerpts + +## Project Status + +**Current Version:** 2.2.3 + +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):** + +- Real-time UI synchronization via KiCAD 9.0 IPC API +- 21 IPC-enabled commands with automatic SWIG fallback +- Hybrid footprint loading (SWIG for library access, IPC for placement) + +**Developer Mode:** +Set `KICAD_MCP_DEV=1` to capture MCP session logs for debugging. See CHANGELOG v2.2.3 for details. + +See [ROADMAP.md](docs/ROADMAP.md) for planned features. + +## What Do You Want to See Next? + +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 + +## Contributing + +Contributions are welcome! Please follow these guidelines: + +1. **Report Bugs:** Open an issue with reproduction steps +2. **Suggest Features:** Describe use case and expected behavior +3. **Submit Pull Requests:** + - Fork the repository + - Create a feature branch + - Follow existing code style + - Add tests for new functionality + - Update documentation + - Submit PR with clear description + +See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. + +## License + +This project is licensed under the MIT License. See [LICENSE](LICENSE) for details. + +## Acknowledgments + +- 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 +- [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: + +```bibtex +@software{kicad_mcp_server, + title = {KiCAD MCP Server: AI-Assisted PCB Design}, + author = {mixelpixx}, + year = {2025}, + url = {https://github.com/mixelpixx/KiCAD-MCP-Server}, + version = {2.2.3} +} +``` diff --git a/config/linux-config.example.json b/config/linux-config.example.json index 880cf81..7d2d92c 100644 --- a/config/linux-config.example.json +++ b/config/linux-config.example.json @@ -1,15 +1,15 @@ -{ - "mcpServers": { - "kicad": { - "command": "node", - "args": ["/home/YOUR_USERNAME/MCP/KiCAD-MCP-Server/dist/index.js"], - "env": { - "NODE_ENV": "production", - "PYTHONPATH": "/usr/share/kicad/scripting/plugins:/usr/lib/kicad/lib/python3/dist-packages", - "LOG_LEVEL": "info", - "KICAD_AUTO_LAUNCH": "false" - }, - "description": "KiCAD PCB Design Assistant - Note: PYTHONPATH auto-detected if venv exists" - } - } -} +{ + "mcpServers": { + "kicad": { + "command": "node", + "args": ["/home/YOUR_USERNAME/MCP/KiCAD-MCP-Server/dist/index.js"], + "env": { + "NODE_ENV": "production", + "PYTHONPATH": "/usr/share/kicad/scripting/plugins:/usr/lib/kicad/lib/python3/dist-packages", + "LOG_LEVEL": "info", + "KICAD_AUTO_LAUNCH": "false" + }, + "description": "KiCAD PCB Design Assistant - Note: PYTHONPATH auto-detected if venv exists" + } + } +} diff --git a/config/macos-config.example.json b/config/macos-config.example.json index a62e82a..5d2286c 100644 --- a/config/macos-config.example.json +++ b/config/macos-config.example.json @@ -1,15 +1,15 @@ -{ - "mcpServers": { - "kicad": { - "command": "node", - "args": ["/Users/YOUR_USERNAME/MCP/KiCAD-MCP-Server/dist/index.js"], - "env": { - "NODE_ENV": "production", - "PYTHONPATH": "/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/Current/lib/python3.11/site-packages", - "LOG_LEVEL": "info", - "KICAD_AUTO_LAUNCH": "false" - }, - "description": "KiCAD PCB Design Assistant - Note: PYTHONPATH auto-detected if venv exists" - } - } -} +{ + "mcpServers": { + "kicad": { + "command": "node", + "args": ["/Users/YOUR_USERNAME/MCP/KiCAD-MCP-Server/dist/index.js"], + "env": { + "NODE_ENV": "production", + "PYTHONPATH": "/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/Current/lib/python3.11/site-packages", + "LOG_LEVEL": "info", + "KICAD_AUTO_LAUNCH": "false" + }, + "description": "KiCAD PCB Design Assistant - Note: PYTHONPATH auto-detected if venv exists" + } + } +} diff --git a/config/windows-config.example.json b/config/windows-config.example.json index 7fdb060..796cfd2 100644 --- a/config/windows-config.example.json +++ b/config/windows-config.example.json @@ -1,16 +1,16 @@ -{ - "mcpServers": { - "kicad": { - "command": "node", - "args": ["C:\\Users\\YOUR_USERNAME\\MCP\\KiCAD-MCP-Server\\dist\\index.js"], - "env": { - "NODE_ENV": "production", - "PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\bin\\Lib\\site-packages", - "LOG_LEVEL": "info", - "KICAD_AUTO_LAUNCH": "false", - "KICAD_MCP_DEV": "0" - }, - "description": "KiCAD PCB Design Assistant - Note: PYTHONPATH auto-detected if venv exists" - } - } -} +{ + "mcpServers": { + "kicad": { + "command": "node", + "args": ["C:\\Users\\YOUR_USERNAME\\MCP\\KiCAD-MCP-Server\\dist\\index.js"], + "env": { + "NODE_ENV": "production", + "PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\bin\\Lib\\site-packages", + "LOG_LEVEL": "info", + "KICAD_AUTO_LAUNCH": "false", + "KICAD_MCP_DEV": "0" + }, + "description": "KiCAD PCB Design Assistant - Note: PYTHONPATH auto-detected if venv exists" + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d29e15c..b99c420 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -118,10 +118,12 @@ KiCAD-MCP-Server/ ### 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", @@ -130,13 +132,14 @@ server.tool( 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` @@ -144,6 +147,7 @@ The router pattern reduces AI context usage: ### 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 @@ -164,6 +168,7 @@ The router pattern reduces AI context usage: ### 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 @@ -173,12 +178,14 @@ Commands are routed by name to handler methods. The mapping is defined in `kicad 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 @@ -189,6 +196,7 @@ Two backends for interacting with KiCAD: ### 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 @@ -214,7 +222,7 @@ server.tool( async (args) => { const result = await callKicadScript("my_new_tool", args); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; - } + }, ); ``` @@ -271,11 +279,13 @@ npm run test:py # Run Python tests ### 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 @@ -302,13 +312,13 @@ Key test files: ## 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 | +| 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 | diff --git a/docs/CLIENT_CONFIGURATION.md b/docs/CLIENT_CONFIGURATION.md index 5532a62..bcfd088 100644 --- a/docs/CLIENT_CONFIGURATION.md +++ b/docs/CLIENT_CONFIGURATION.md @@ -1,543 +1,538 @@ -# KiCAD MCP Server - Client Configuration Guide - -This guide shows how to configure the KiCAD MCP Server with various MCP-compatible clients. - ---- - -## Quick Reference - -| Client | Config File Location | -|--------|---------------------| -| **Claude Desktop** | Linux: `~/.config/Claude/claude_desktop_config.json`
macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
Windows: `%APPDATA%\Claude\claude_desktop_config.json` | -| **Cline (VSCode)** | VSCode Settings → Extensions → Cline → MCP Settings | -| **Claude Code** | `~/.config/claude-code/mcp_config.json` | - ---- - -## 1. Claude Desktop - -### Linux Configuration - -**File:** `~/.config/Claude/claude_desktop_config.json` - -```json -{ - "mcpServers": { - "kicad": { - "command": "node", - "args": ["/home/YOUR_USERNAME/MCP/KiCAD-MCP-Server/dist/index.js"], - "env": { - "PYTHONPATH": "/usr/lib/kicad/lib/python3/dist-packages", - "NODE_ENV": "production" - } - } - } -} -``` - -**Important:** Replace `/home/YOUR_USERNAME` with your actual home directory path. - -### macOS Configuration - -**File:** `~/Library/Application Support/Claude/claude_desktop_config.json` - -```json -{ - "mcpServers": { - "kicad": { - "command": "node", - "args": ["/Users/YOUR_USERNAME/MCP/KiCAD-MCP-Server/dist/index.js"] - } - } -} -``` - -**Note:** For standard KiCad installations in `/Applications/KiCad/`, the server auto-detects KiCad's bundled Python (versions 3.9-3.12). No `PYTHONPATH` configuration is required. - -If KiCad is installed in a non-standard location, you can override the Python path: - -```json -{ - "mcpServers": { - "kicad": { - "command": "node", - "args": ["/Users/YOUR_USERNAME/MCP/KiCAD-MCP-Server/dist/index.js"], - "env": { - "KICAD_PYTHON": "/custom/path/to/python3" - } - } - } -} -``` - -### Windows Configuration - -**File:** `%APPDATA%\Claude\claude_desktop_config.json` - -```json -{ - "mcpServers": { - "kicad": { - "command": "node", - "args": ["C:\\Users\\YOUR_USERNAME\\MCP\\KiCAD-MCP-Server\\dist\\index.js"], - "env": { - "PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\bin\\Lib\\site-packages", - "NODE_ENV": "production" - } - } - } -} -``` - -**Note:** Use double backslashes (`\\`) in Windows paths. - ---- - -## 2. Cline (VSCode Extension) - -### Configuration Steps - -1. Open VSCode -2. Install Cline extension from marketplace -3. Open Settings (Ctrl+,) -4. Search for "Cline MCP" -5. Click "Edit in settings.json" - -### settings.json Configuration - -```json -{ - "cline.mcpServers": { - "kicad": { - "command": "node", - "args": ["/home/YOUR_USERNAME/MCP/KiCAD-MCP-Server/dist/index.js"], - "env": { - "PYTHONPATH": "/usr/lib/kicad/lib/python3/dist-packages" - } - } - } -} -``` - -### Alternative: Workspace Configuration - -Create `.vscode/settings.json` in your project: - -```json -{ - "cline.mcpServers": { - "kicad": { - "command": "node", - "args": ["${workspaceFolder}/../KiCAD-MCP-Server/dist/index.js"], - "env": { - "PYTHONPATH": "/usr/lib/kicad/lib/python3/dist-packages" - } - } - } -} -``` - ---- - -## 3. Claude Code CLI - -### Configuration File - -**File:** `~/.config/claude-code/mcp_config.json` - -```json -{ - "mcpServers": { - "kicad": { - "command": "node", - "args": ["/home/YOUR_USERNAME/MCP/KiCAD-MCP-Server/dist/index.js"], - "env": { - "PYTHONPATH": "/usr/lib/kicad/lib/python3/dist-packages", - "LOG_LEVEL": "info" - } - } - } -} -``` - -### Verify Configuration - -```bash -# List available MCP servers -claude-code mcp list - -# Test KiCAD server connection -claude-code mcp test kicad -``` - ---- - -## 4. Generic MCP Client - -For any MCP-compatible client that supports STDIO transport: - -### Basic Configuration - -```json -{ - "command": "node", - "args": ["/path/to/KiCAD-MCP-Server/dist/index.js"], - "transport": "stdio", - "env": { - "PYTHONPATH": "/path/to/kicad/python/packages" - } -} -``` - -### With Custom Config File - -```json -{ - "command": "node", - "args": [ - "/path/to/KiCAD-MCP-Server/dist/index.js", - "--config", - "/path/to/custom-config.json" - ], - "transport": "stdio" -} -``` - ---- - -## Environment Variables - -### 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 | - ---- - -## Finding KiCAD Python Path - -### Linux (Ubuntu/Debian) - -```bash -# Method 1: dpkg query -dpkg -L kicad | grep "site-packages" | head -1 - -# Method 2: Python auto-detect -python3 -c "from pathlib import Path; import sys; print([p for p in Path('/usr').rglob('pcbnew.py')])" - -# Method 3: Use platform helper -cd /path/to/KiCAD-MCP-Server -PYTHONPATH=python python3 -c "from utils.platform_helper import PlatformHelper; print(PlatformHelper.get_kicad_python_paths())" -``` - -### macOS - -```bash -# Typical location -/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/Current/lib/python3.11/site-packages - -# Find dynamically -find /Applications/KiCad -name "pcbnew.py" -type f -``` - -### Windows - -```cmd -REM Typical location (KiCAD 9.0) -C:\Program Files\KiCad\9.0\bin\Lib\site-packages - -REM Search for pcbnew.py -where /r "C:\Program Files\KiCad" pcbnew.py -``` - ---- - -## Testing Your Configuration - -### 1. Verify Server Starts - -```bash -# Start server manually -node dist/index.js - -# Should see output like: -# [INFO] Using STDIO transport for local communication -# [INFO] Registering KiCAD tools, resources, and prompts... -# [INFO] Successfully connected to STDIO transport -``` - -Press Ctrl+C to stop. - -### 2. Test with Claude Desktop - -1. Restart Claude Desktop -2. Start a new conversation -3. Look for a "hammer" icon or "Tools" indicator -4. The KiCAD tools should be listed - -### 3. Test with Cline - -1. Open Cline panel in VSCode -2. Start a new chat -3. Type: "List available KiCAD tools" -4. Cline should show KiCAD MCP tools are available - -### 4. Test with Claude Code - -```bash -# Start Claude Code with MCP -claude-code - -# In the conversation, ask: -# "What KiCAD tools are available?" -``` - ---- - -## Troubleshooting - -### Server Not Starting - -**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())" -``` - -**Error:** `ENOENT: no such file or directory` - -**Solution:** Check that `dist/index.js` exists: -```bash -cd /path/to/KiCAD-MCP-Server -npm run build -ls -lh dist/index.js -``` - -### Client Can't Connect - -**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 - jq . ~/.config/Claude/claude_desktop_config.json - ``` -3. Check Claude Desktop logs: - - Linux: `~/.config/Claude/logs/` - - macOS: `~/Library/Logs/Claude/` - - Windows: `%APPDATA%\Claude\logs\` - -### Python Module Errors - -**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" -``` - ---- - -## Advanced Configuration - -### Multiple KiCAD Versions - -If you have multiple KiCAD versions installed: - -```json -{ - "mcpServers": { - "kicad-9": { - "command": "node", - "args": ["/path/to/KiCAD-MCP-Server/dist/index.js"], - "env": { - "PYTHONPATH": "/usr/lib/kicad-9/lib/python3/dist-packages" - } - }, - "kicad-8": { - "command": "node", - "args": ["/path/to/KiCAD-MCP-Server/dist/index.js"], - "env": { - "PYTHONPATH": "/usr/lib/kicad-8/lib/python3/dist-packages" - } - } - } -} -``` - -### Custom Logging - -Create a custom config file `config/production.json`: - -```json -{ - "logLevel": "debug", - "python": { - "executable": "python3", - "timeout": 30000 - } -} -``` - -Then use it: - -```json -{ - "command": "node", - "args": [ - "/path/to/dist/index.js", - "--config", - "/path/to/config/production.json" - ] -} -``` - -### Development vs Production - -Development (verbose logging): -```json -{ - "env": { - "NODE_ENV": "development", - "LOG_LEVEL": "debug" - } -} -``` - -Production (minimal logging): -```json -{ - "env": { - "NODE_ENV": "production", - "LOG_LEVEL": "info" - } -} -``` - ---- - -## Platform-Specific Examples - -### Ubuntu 24.04 LTS - -```json -{ - "mcpServers": { - "kicad": { - "command": "node", - "args": ["/home/chris/MCP/KiCAD-MCP-Server/dist/index.js"], - "env": { - "PYTHONPATH": "/usr/share/kicad/scripting/plugins:/usr/lib/kicad/lib/python3/dist-packages" - } - } - } -} -``` - -### Arch Linux - -```json -{ - "mcpServers": { - "kicad": { - "command": "node", - "args": ["/home/user/KiCAD-MCP-Server/dist/index.js"], - "env": { - "PYTHONPATH": "/usr/lib/python3.12/site-packages" - } - } - } -} -``` - -### Windows 11 with WSL2 - -Running server in WSL2, client on Windows: - -```json -{ - "mcpServers": { - "kicad": { - "command": "wsl", - "args": [ - "node", - "/home/user/KiCAD-MCP-Server/dist/index.js" - ], - "env": { - "PYTHONPATH": "/usr/lib/kicad/lib/python3/dist-packages" - } - } - } -} -``` - ---- - -## Security Considerations - -### File Permissions - -Ensure config files are only readable by your user: - -```bash -chmod 600 ~/.config/Claude/claude_desktop_config.json -``` - -### Network Isolation - -The KiCAD MCP Server uses STDIO transport (no network ports), providing isolation by default. - -### Code Execution - -The server executes Python scripts from the `python/` directory. Only run servers from trusted sources. - ---- - -## Next Steps - -After configuration: - -1. **Test Basic Functionality** - - Ask: "Create a new KiCAD project called 'test'" - - Ask: "What tools are available for PCB design?" - -2. **Explore Resources** - - Ask: "Show me board information" - - Ask: "What layers are in my PCB?" - -3. **Try Advanced Features** - - Ask: "Add a resistor to my schematic" - - Ask: "Route a trace between two points" - ---- - -## Support - -If you encounter issues: - -1. Check logs in `~/.kicad-mcp/logs/` (if logging is enabled) -2. Verify KiCAD installation: `kicad-cli version` -3. Test Python modules: `python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())"` -4. Review server startup logs (manual start with `node dist/index.js`) -5. Check client-specific logs (see Troubleshooting section) - -For bugs or feature requests, open an issue on GitHub. - ---- - -**Last Updated:** March 21, 2026 -**Version:** 2.2.3+ +# KiCAD MCP Server - Client Configuration Guide + +This guide shows how to configure the KiCAD MCP Server with various MCP-compatible clients. + +--- + +## Quick Reference + +| Client | Config File Location | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Claude Desktop** | Linux: `~/.config/Claude/claude_desktop_config.json`
macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
Windows: `%APPDATA%\Claude\claude_desktop_config.json` | +| **Cline (VSCode)** | VSCode Settings → Extensions → Cline → MCP Settings | +| **Claude Code** | `~/.config/claude-code/mcp_config.json` | + +--- + +## 1. Claude Desktop + +### Linux Configuration + +**File:** `~/.config/Claude/claude_desktop_config.json` + +```json +{ + "mcpServers": { + "kicad": { + "command": "node", + "args": ["/home/YOUR_USERNAME/MCP/KiCAD-MCP-Server/dist/index.js"], + "env": { + "PYTHONPATH": "/usr/lib/kicad/lib/python3/dist-packages", + "NODE_ENV": "production" + } + } + } +} +``` + +**Important:** Replace `/home/YOUR_USERNAME` with your actual home directory path. + +### macOS Configuration + +**File:** `~/Library/Application Support/Claude/claude_desktop_config.json` + +```json +{ + "mcpServers": { + "kicad": { + "command": "node", + "args": ["/Users/YOUR_USERNAME/MCP/KiCAD-MCP-Server/dist/index.js"] + } + } +} +``` + +**Note:** For standard KiCad installations in `/Applications/KiCad/`, the server auto-detects KiCad's bundled Python (versions 3.9-3.12). No `PYTHONPATH` configuration is required. + +If KiCad is installed in a non-standard location, you can override the Python path: + +```json +{ + "mcpServers": { + "kicad": { + "command": "node", + "args": ["/Users/YOUR_USERNAME/MCP/KiCAD-MCP-Server/dist/index.js"], + "env": { + "KICAD_PYTHON": "/custom/path/to/python3" + } + } + } +} +``` + +### Windows Configuration + +**File:** `%APPDATA%\Claude\claude_desktop_config.json` + +```json +{ + "mcpServers": { + "kicad": { + "command": "node", + "args": ["C:\\Users\\YOUR_USERNAME\\MCP\\KiCAD-MCP-Server\\dist\\index.js"], + "env": { + "PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\bin\\Lib\\site-packages", + "NODE_ENV": "production" + } + } + } +} +``` + +**Note:** Use double backslashes (`\\`) in Windows paths. + +--- + +## 2. Cline (VSCode Extension) + +### Configuration Steps + +1. Open VSCode +2. Install Cline extension from marketplace +3. Open Settings (Ctrl+,) +4. Search for "Cline MCP" +5. Click "Edit in settings.json" + +### settings.json Configuration + +```json +{ + "cline.mcpServers": { + "kicad": { + "command": "node", + "args": ["/home/YOUR_USERNAME/MCP/KiCAD-MCP-Server/dist/index.js"], + "env": { + "PYTHONPATH": "/usr/lib/kicad/lib/python3/dist-packages" + } + } + } +} +``` + +### Alternative: Workspace Configuration + +Create `.vscode/settings.json` in your project: + +```json +{ + "cline.mcpServers": { + "kicad": { + "command": "node", + "args": ["${workspaceFolder}/../KiCAD-MCP-Server/dist/index.js"], + "env": { + "PYTHONPATH": "/usr/lib/kicad/lib/python3/dist-packages" + } + } + } +} +``` + +--- + +## 3. Claude Code CLI + +### Configuration File + +**File:** `~/.config/claude-code/mcp_config.json` + +```json +{ + "mcpServers": { + "kicad": { + "command": "node", + "args": ["/home/YOUR_USERNAME/MCP/KiCAD-MCP-Server/dist/index.js"], + "env": { + "PYTHONPATH": "/usr/lib/kicad/lib/python3/dist-packages", + "LOG_LEVEL": "info" + } + } + } +} +``` + +### Verify Configuration + +```bash +# List available MCP servers +claude-code mcp list + +# Test KiCAD server connection +claude-code mcp test kicad +``` + +--- + +## 4. Generic MCP Client + +For any MCP-compatible client that supports STDIO transport: + +### Basic Configuration + +```json +{ + "command": "node", + "args": ["/path/to/KiCAD-MCP-Server/dist/index.js"], + "transport": "stdio", + "env": { + "PYTHONPATH": "/path/to/kicad/python/packages" + } +} +``` + +### With Custom Config File + +```json +{ + "command": "node", + "args": ["/path/to/KiCAD-MCP-Server/dist/index.js", "--config", "/path/to/custom-config.json"], + "transport": "stdio" +} +``` + +--- + +## Environment Variables + +### 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 | + +--- + +## Finding KiCAD Python Path + +### Linux (Ubuntu/Debian) + +```bash +# Method 1: dpkg query +dpkg -L kicad | grep "site-packages" | head -1 + +# Method 2: Python auto-detect +python3 -c "from pathlib import Path; import sys; print([p for p in Path('/usr').rglob('pcbnew.py')])" + +# Method 3: Use platform helper +cd /path/to/KiCAD-MCP-Server +PYTHONPATH=python python3 -c "from utils.platform_helper import PlatformHelper; print(PlatformHelper.get_kicad_python_paths())" +``` + +### macOS + +```bash +# Typical location +/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/Current/lib/python3.11/site-packages + +# Find dynamically +find /Applications/KiCad -name "pcbnew.py" -type f +``` + +### Windows + +```cmd +REM Typical location (KiCAD 9.0) +C:\Program Files\KiCad\9.0\bin\Lib\site-packages + +REM Search for pcbnew.py +where /r "C:\Program Files\KiCad" pcbnew.py +``` + +--- + +## Testing Your Configuration + +### 1. Verify Server Starts + +```bash +# Start server manually +node dist/index.js + +# Should see output like: +# [INFO] Using STDIO transport for local communication +# [INFO] Registering KiCAD tools, resources, and prompts... +# [INFO] Successfully connected to STDIO transport +``` + +Press Ctrl+C to stop. + +### 2. Test with Claude Desktop + +1. Restart Claude Desktop +2. Start a new conversation +3. Look for a "hammer" icon or "Tools" indicator +4. The KiCAD tools should be listed + +### 3. Test with Cline + +1. Open Cline panel in VSCode +2. Start a new chat +3. Type: "List available KiCAD tools" +4. Cline should show KiCAD MCP tools are available + +### 4. Test with Claude Code + +```bash +# Start Claude Code with MCP +claude-code + +# In the conversation, ask: +# "What KiCAD tools are available?" +``` + +--- + +## Troubleshooting + +### Server Not Starting + +**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())" +``` + +**Error:** `ENOENT: no such file or directory` + +**Solution:** Check that `dist/index.js` exists: + +```bash +cd /path/to/KiCAD-MCP-Server +npm run build +ls -lh dist/index.js +``` + +### Client Can't Connect + +**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 + jq . ~/.config/Claude/claude_desktop_config.json + ``` +3. Check Claude Desktop logs: + - Linux: `~/.config/Claude/logs/` + - macOS: `~/Library/Logs/Claude/` + - Windows: `%APPDATA%\Claude\logs\` + +### Python Module Errors + +**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" +``` + +--- + +## Advanced Configuration + +### Multiple KiCAD Versions + +If you have multiple KiCAD versions installed: + +```json +{ + "mcpServers": { + "kicad-9": { + "command": "node", + "args": ["/path/to/KiCAD-MCP-Server/dist/index.js"], + "env": { + "PYTHONPATH": "/usr/lib/kicad-9/lib/python3/dist-packages" + } + }, + "kicad-8": { + "command": "node", + "args": ["/path/to/KiCAD-MCP-Server/dist/index.js"], + "env": { + "PYTHONPATH": "/usr/lib/kicad-8/lib/python3/dist-packages" + } + } + } +} +``` + +### Custom Logging + +Create a custom config file `config/production.json`: + +```json +{ + "logLevel": "debug", + "python": { + "executable": "python3", + "timeout": 30000 + } +} +``` + +Then use it: + +```json +{ + "command": "node", + "args": ["/path/to/dist/index.js", "--config", "/path/to/config/production.json"] +} +``` + +### Development vs Production + +Development (verbose logging): + +```json +{ + "env": { + "NODE_ENV": "development", + "LOG_LEVEL": "debug" + } +} +``` + +Production (minimal logging): + +```json +{ + "env": { + "NODE_ENV": "production", + "LOG_LEVEL": "info" + } +} +``` + +--- + +## Platform-Specific Examples + +### Ubuntu 24.04 LTS + +```json +{ + "mcpServers": { + "kicad": { + "command": "node", + "args": ["/home/chris/MCP/KiCAD-MCP-Server/dist/index.js"], + "env": { + "PYTHONPATH": "/usr/share/kicad/scripting/plugins:/usr/lib/kicad/lib/python3/dist-packages" + } + } + } +} +``` + +### Arch Linux + +```json +{ + "mcpServers": { + "kicad": { + "command": "node", + "args": ["/home/user/KiCAD-MCP-Server/dist/index.js"], + "env": { + "PYTHONPATH": "/usr/lib/python3.12/site-packages" + } + } + } +} +``` + +### Windows 11 with WSL2 + +Running server in WSL2, client on Windows: + +```json +{ + "mcpServers": { + "kicad": { + "command": "wsl", + "args": ["node", "/home/user/KiCAD-MCP-Server/dist/index.js"], + "env": { + "PYTHONPATH": "/usr/lib/kicad/lib/python3/dist-packages" + } + } + } +} +``` + +--- + +## Security Considerations + +### File Permissions + +Ensure config files are only readable by your user: + +```bash +chmod 600 ~/.config/Claude/claude_desktop_config.json +``` + +### Network Isolation + +The KiCAD MCP Server uses STDIO transport (no network ports), providing isolation by default. + +### Code Execution + +The server executes Python scripts from the `python/` directory. Only run servers from trusted sources. + +--- + +## Next Steps + +After configuration: + +1. **Test Basic Functionality** + - Ask: "Create a new KiCAD project called 'test'" + - Ask: "What tools are available for PCB design?" + +2. **Explore Resources** + - Ask: "Show me board information" + - Ask: "What layers are in my PCB?" + +3. **Try Advanced Features** + - Ask: "Add a resistor to my schematic" + - Ask: "Route a trace between two points" + +--- + +## Support + +If you encounter issues: + +1. Check logs in `~/.kicad-mcp/logs/` (if logging is enabled) +2. Verify KiCAD installation: `kicad-cli version` +3. Test Python modules: `python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())"` +4. Review server startup logs (manual start with `node dist/index.js`) +5. Check client-specific logs (see Troubleshooting section) + +For bugs or feature requests, open an issue on GitHub. + +--- + +**Last Updated:** March 21, 2026 +**Version:** 2.2.3+ diff --git a/docs/DATASHEET_TOOLS_GUIDE.md b/docs/DATASHEET_TOOLS_GUIDE.md index c161d35..afdc5d1 100644 --- a/docs/DATASHEET_TOOLS_GUIDE.md +++ b/docs/DATASHEET_TOOLS_GUIDE.md @@ -15,6 +15,7 @@ Scans a KiCAD schematic and fills in missing Datasheet URLs for components that **How it works:** For every placed symbol that has: + - An LCSC property set (e.g., `(property "LCSC" "C123456")`) - An empty or missing Datasheet field @@ -24,23 +25,26 @@ The URL is then visible in KiCAD's footprint browser, symbol properties dialog, **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 | +| 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. ``` @@ -53,15 +57,17 @@ 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") | +| 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. ``` diff --git a/docs/FOOTPRINT_SYMBOL_CREATOR_GUIDE.md b/docs/FOOTPRINT_SYMBOL_CREATOR_GUIDE.md index 2c0e285..e016c0f 100644 --- a/docs/FOOTPRINT_SYMBOL_CREATOR_GUIDE.md +++ b/docs/FOOTPRINT_SYMBOL_CREATOR_GUIDE.md @@ -12,63 +12,63 @@ Footprints define the physical copper pads, silkscreen markings, and courtyard b 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) | +| 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) | +| 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 | +| 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 +- **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` | +| 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. @@ -76,13 +76,13 @@ Edit an existing pad inside a .kicad_mod footprint file. Updates size, position, 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) | +| 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. @@ -90,9 +90,9 @@ Register a .pretty footprint library in KiCAD's fp-lib-table so KiCAD can find t 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 | +| 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 @@ -172,6 +172,7 @@ Create a new schematic symbol in a .kicad_sym library file (created if missing). 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) @@ -179,36 +180,37 @@ Pin positions are where the wire connects; the symbol body is drawn between them - 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) | +| 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) | +| 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) @@ -216,82 +218,82 @@ Each pin object in the `pins` array supports: #### 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 | +| 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 | +| 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) | +| 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` | +| 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 | +| 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 | +| 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) | +| 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 @@ -351,6 +353,7 @@ This example creates a 4-pin IC symbol (VCC, GND, IN, OUT): ``` **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 @@ -397,6 +400,7 @@ Footprints use a "Y-down" coordinate system (like screen coordinates), while sym ### 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 diff --git a/docs/FREEROUTING_GUIDE.md b/docs/FREEROUTING_GUIDE.md index 18dc73c..9e8cec4 100644 --- a/docs/FREEROUTING_GUIDE.md +++ b/docs/FREEROUTING_GUIDE.md @@ -32,6 +32,7 @@ curl -L -o ~/.kicad-mcp/freerouting.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 @@ -85,6 +86,7 @@ Verify that prerequisites are installed before running the autorouter. **Returns:** Java availability, version, Docker status, JAR location **Example:** + ``` Check if Freerouting is ready on my system. ``` @@ -102,6 +104,7 @@ Run the full autorouting workflow (export DSN, route, import SES). | `timeout` | number | No | 300 | Timeout in seconds | **Example:** + ``` Autoroute the current board using Freerouting with a 5-minute timeout. ``` @@ -153,6 +156,7 @@ For advanced users or external autorouters: ``` 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 @@ -190,12 +194,14 @@ 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. ``` @@ -203,6 +209,7 @@ 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 @@ -211,6 +218,7 @@ If the autorouter does not route all connections: ### Docker Permission Errors If Docker reports permission errors: + ```bash # Add your user to the docker group sudo usermod -aG docker $USER diff --git a/docs/INDEX.md b/docs/INDEX.md index 9e16e64..dd4a2b5 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -8,79 +8,79 @@ KiCAD MCP Server -- AI-assisted PCB design via Model Context Protocol ## 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 | +| 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 | +| 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) | +| 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 | +| 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 | +| 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 | -|----------|-------------| +| 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 | +| [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 | +| Document | Description | +| ---------------------------------- | ---------------------------------------- | +| [Contributing](../CONTRIBUTING.md) | How to contribute to the project | +| [Architecture](ARCHITECTURE.md) | System architecture and adding new tools | --- diff --git a/docs/IPC_BACKEND_STATUS.md b/docs/IPC_BACKEND_STATUS.md index fd0ed1e..5f70387 100644 --- a/docs/IPC_BACKEND_STATUS.md +++ b/docs/IPC_BACKEND_STATUS.md @@ -1,212 +1,223 @@ -# KiCAD IPC Backend Implementation Status - -**Status:** Under Active Development and Testing -**Date:** 2026-03-21 -**KiCAD Version:** 9.0+ -**kicad-python Version:** 0.5.0+ - ---- - -## Overview - -The IPC backend provides real-time UI synchronization with KiCAD 9.0+ via the official IPC API. When KiCAD is running with IPC enabled, commands can update the KiCAD UI immediately without requiring manual reload. - -This feature is experimental and under active testing. The server uses a hybrid approach: IPC when available, automatic fallback to SWIG when IPC is not connected. - -## 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 | -| Connection | File-based | Live socket connection | -| KiCAD Required | No (file operations) | Yes (must be running) | - -## Implemented IPC Commands - -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 | -| `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` | Implemented (hybrid) | -| `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 | - -### Implemented Backend Features - -**Core Connection:** -- Connect to running KiCAD instance -- Auto-detect socket path (`/tmp/kicad/api.sock`) -- Version checking and validation -- Auto-fallback to SWIG when IPC unavailable -- Change notification callbacks - -**Board Operations:** -- Get board reference -- Get/Set board size -- List enabled layers -- Save board -- Add board outline segments -- Add mounting holes - -**Component Operations:** -- List all components -- Place component (hybrid: SWIG for library loading, IPC for placement) -- Move component -- Rotate component -- Delete component -- Get component properties - -**Routing Operations:** -- Add track -- Add via -- Get all tracks -- Get all vias -- 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 - -## Usage - -### Prerequisites - -1. **KiCAD 9.0+** must be running -2. **IPC API must be enabled**: `Preferences > Plugins > Enable IPC API Server` -3. A board must be open in the PCB editor - -### Installation - -```bash -pip install kicad-python -``` - -### Testing - -Run the test script to verify IPC functionality: - -```bash -# Make sure KiCAD is running with IPC enabled and a board open -./venv/bin/python python/test_ipc_backend.py -``` - -## Architecture - -``` -+-------------------------------------------------------------+ -| MCP Server (TypeScript/Node.js) | -+---------------------------+---------------------------------+ - | JSON commands -+---------------------------v---------------------------------+ -| Python Interface Layer | -| +--------------------------------------------------------+ | -| | kicad_interface.py | | -| | - Routes commands to IPC or SWIG handlers | | -| | - IPC_CAPABLE_COMMANDS dict defines routing | | -| +--------------------------------------------------------+ | -| +--------------------------------------------------------+ | -| | kicad_api/ipc_backend.py | | -| | - IPCBackend (connection management) | | -| | - IPCBoardAPI (board operations) | | -| +--------------------------------------------------------+ | -+---------------------------+---------------------------------+ - | kicad-python (kipy) library -+---------------------------v---------------------------------+ -| Protocol Buffers over UNIX Sockets | -+---------------------------+---------------------------------+ - | -+---------------------------v---------------------------------+ -| KiCAD 9.0+ (IPC Server) | -+-------------------------------------------------------------+ -``` - -## Known Limitations - -1. **KiCAD must be running**: Unlike SWIG, IPC requires KiCAD to be open -2. **Project creation**: Not supported via IPC, uses file system -3. **Footprint library access**: Uses hybrid approach (SWIG loads from library, IPC places) -4. **Delete trace**: Falls back to SWIG (IPC API doesn't support direct deletion) -5. **Some operations may not work as expected**: This is experimental code - -## 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 - -``` -python/kicad_api/ -├── __init__.py # Package exports -├── base.py # Abstract base classes -├── factory.py # Backend auto-detection -├── ipc_backend.py # IPC implementation -└── swig_backend.py # Legacy SWIG wrapper - -python/ -└── test_ipc_backend.py # IPC test script -``` - -## Future Work - -1. More comprehensive testing of all IPC commands -2. Footprint library integration via IPC (when kipy supports it) -3. Schematic IPC support (when available in kicad-python) -4. Event subscriptions to react to changes made in KiCAD UI -5. Multi-board support - -## Related Documentation - -- [ROADMAP.md](./ROADMAP.md) - Project roadmap -- [IPC_API_MIGRATION_PLAN.md](./IPC_API_MIGRATION_PLAN.md) - Migration details -- [REALTIME_WORKFLOW.md](./REALTIME_WORKFLOW.md) - Collaboration workflows -- [kicad-python docs](https://docs.kicad.org/kicad-python-main/) - Official API docs - ---- - -**Last Updated:** 2026-03-21 +# KiCAD IPC Backend Implementation Status + +**Status:** Under Active Development and Testing +**Date:** 2026-03-21 +**KiCAD Version:** 9.0+ +**kicad-python Version:** 0.5.0+ + +--- + +## Overview + +The IPC backend provides real-time UI synchronization with KiCAD 9.0+ via the official IPC API. When KiCAD is running with IPC enabled, commands can update the KiCAD UI immediately without requiring manual reload. + +This feature is experimental and under active testing. The server uses a hybrid approach: IPC when available, automatic fallback to SWIG when IPC is not connected. + +## 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 | +| Connection | File-based | Live socket connection | +| KiCAD Required | No (file operations) | Yes (must be running) | + +## Implemented IPC Commands + +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 | +| `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` | Implemented (hybrid) | +| `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 | + +### Implemented Backend Features + +**Core Connection:** + +- Connect to running KiCAD instance +- Auto-detect socket path (`/tmp/kicad/api.sock`) +- Version checking and validation +- Auto-fallback to SWIG when IPC unavailable +- Change notification callbacks + +**Board Operations:** + +- Get board reference +- Get/Set board size +- List enabled layers +- Save board +- Add board outline segments +- Add mounting holes + +**Component Operations:** + +- List all components +- Place component (hybrid: SWIG for library loading, IPC for placement) +- Move component +- Rotate component +- Delete component +- Get component properties + +**Routing Operations:** + +- Add track +- Add via +- Get all tracks +- Get all vias +- 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 + +## Usage + +### Prerequisites + +1. **KiCAD 9.0+** must be running +2. **IPC API must be enabled**: `Preferences > Plugins > Enable IPC API Server` +3. A board must be open in the PCB editor + +### Installation + +```bash +pip install kicad-python +``` + +### Testing + +Run the test script to verify IPC functionality: + +```bash +# Make sure KiCAD is running with IPC enabled and a board open +./venv/bin/python python/test_ipc_backend.py +``` + +## Architecture + +``` ++-------------------------------------------------------------+ +| MCP Server (TypeScript/Node.js) | ++---------------------------+---------------------------------+ + | JSON commands ++---------------------------v---------------------------------+ +| Python Interface Layer | +| +--------------------------------------------------------+ | +| | kicad_interface.py | | +| | - Routes commands to IPC or SWIG handlers | | +| | - IPC_CAPABLE_COMMANDS dict defines routing | | +| +--------------------------------------------------------+ | +| +--------------------------------------------------------+ | +| | kicad_api/ipc_backend.py | | +| | - IPCBackend (connection management) | | +| | - IPCBoardAPI (board operations) | | +| +--------------------------------------------------------+ | ++---------------------------+---------------------------------+ + | kicad-python (kipy) library ++---------------------------v---------------------------------+ +| Protocol Buffers over UNIX Sockets | ++---------------------------+---------------------------------+ + | ++---------------------------v---------------------------------+ +| KiCAD 9.0+ (IPC Server) | ++-------------------------------------------------------------+ +``` + +## Known Limitations + +1. **KiCAD must be running**: Unlike SWIG, IPC requires KiCAD to be open +2. **Project creation**: Not supported via IPC, uses file system +3. **Footprint library access**: Uses hybrid approach (SWIG loads from library, IPC places) +4. **Delete trace**: Falls back to SWIG (IPC API doesn't support direct deletion) +5. **Some operations may not work as expected**: This is experimental code + +## 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 + +``` +python/kicad_api/ +├── __init__.py # Package exports +├── base.py # Abstract base classes +├── factory.py # Backend auto-detection +├── ipc_backend.py # IPC implementation +└── swig_backend.py # Legacy SWIG wrapper + +python/ +└── test_ipc_backend.py # IPC test script +``` + +## Future Work + +1. More comprehensive testing of all IPC commands +2. Footprint library integration via IPC (when kipy supports it) +3. Schematic IPC support (when available in kicad-python) +4. Event subscriptions to react to changes made in KiCAD UI +5. Multi-board support + +## Related Documentation + +- [ROADMAP.md](./ROADMAP.md) - Project roadmap +- [IPC_API_MIGRATION_PLAN.md](./IPC_API_MIGRATION_PLAN.md) - Migration details +- [REALTIME_WORKFLOW.md](./REALTIME_WORKFLOW.md) - Collaboration workflows +- [kicad-python docs](https://docs.kicad.org/kicad-python-main/) - Official API docs + +--- + +**Last Updated:** 2026-03-21 diff --git a/docs/JLCPCB_INTEGRATION.md b/docs/JLCPCB_INTEGRATION.md index 18b8631..ce46967 100644 --- a/docs/JLCPCB_INTEGRATION.md +++ b/docs/JLCPCB_INTEGRATION.md @@ -1,344 +1,374 @@ -# JLCPCB Parts Integration - Complete Guide - -## Overview - -The KiCAD MCP Server integrates with JLCPCB's parts library to provide intelligent component selection, cost optimization, and automated part sourcing for PCB assembly. - -**Current Implementation**: Uses the **JLCSearch public API** (by tscircuit) for free, unauthenticated access to JLCPCB's ~100k parts catalog. - -## Features - -✅ **Parametric Search** - Find components by specifications (resistance, capacitance, package, etc.) -✅ **Price Comparison** - Compare Basic vs Extended library pricing -✅ **Alternative Suggestions** - Find cheaper or higher-stock alternatives -✅ **Footprint Mapping** - Automatic JLCPCB package to KiCad footprint mapping -✅ **Stock Availability** - Real-time stock levels from JLCPCB -✅ **No Authentication Required** - Public API, no API keys needed - -## Quick Start - -### 1. Search for Components - -```python -from commands.jlcsearch import JLCSearchClient - -client = JLCSearchClient() - -# Search for resistors -resistors = client.search_resistors( - resistance=10000, # 10kΩ - package="0603", - limit=20 -) - -# Search for capacitors -capacitors = client.search_capacitors( - capacitance=1e-7, # 100nF - package="0603", - limit=20 -) - -# General component search -components = client.search_components( - "components", - package="0603", - limit=100 -) -``` - -### 2. Get Part Details - -```python -# Get specific part by LCSC number -part = client.get_part_by_lcsc(25804) # C25804 -print(f"Part: {part['mfr']}") -print(f"Stock: {part['stock']}") -print(f"Price: ${part['price1']}") -print(f"Basic Library: {part['is_basic']}") -``` - -### 3. Database Integration - -```python -from commands.jlcpcb_parts import JLCPCBPartsManager - -# Initialize database -db = JLCPCBPartsManager() # Uses data/jlcpcb_parts.db - -# Download and import parts (one-time setup) -client = JLCSearchClient() -parts = client.download_all_components() -db.import_jlcsearch_parts(parts) - -# Search imported database -results = db.search_parts( - query="resistor", - package="0603", - library_type="Basic", - in_stock=True, - limit=20 -) -``` - -### 4. Footprint Mapping - -```python -# Map JLCPCB package to KiCad footprints -footprints = db.map_package_to_footprint("0603") -# Returns: -# [ -# "Resistor_SMD:R_0603_1608Metric", -# "Capacitor_SMD:C_0603_1608Metric", -# "LED_SMD:LED_0603_1608Metric" -# ] -``` - -## API Reference - -### 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 -- `is_basic`: True if Basic library part (no assembly fee) -- `resistance`: Resistance in ohms -- `tolerance_fraction`: Tolerance (0.01 = 1%) -- `power_watts`: Power rating in mW -- `stock`: Available stock -- `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 - -**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 -- `**filters`: Additional filters (package="0603", lcsc=25804, etc.) - -**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) - -**Returns:** List of all parts (~100k components) - -**Note:** This may take 5-10 minutes to complete. - -### 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 -- `library_type` (str, optional): "Basic", "Extended", or "Preferred" -- `manufacturer` (str, optional): Manufacturer filter -- `in_stock` (bool): Only in-stock parts (default: True) -- `limit` (int): Maximum results - -**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 -- `in_stock`: Parts with stock > 0 -- `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 - -## Data Format - -### JLCSearch Part Object - -```json -{ - "lcsc": 25804, - "mfr": "0603WAF1002T5E", - "package": "0603", - "is_basic": true, - "is_preferred": false, - "resistance": 10000, - "tolerance_fraction": 0.01, - "power_watts": 100, - "stock": 37165617, - "price1": 0.000842857 -} -``` - -### Database Schema - -```sql -CREATE TABLE components ( - lcsc TEXT PRIMARY KEY, -- "C25804" - category TEXT, -- "Resistors" - subcategory TEXT, -- "Chip Resistor" - mfr_part TEXT, -- "0603WAF1002T5E" - package TEXT, -- "0603" - solder_joints INTEGER, - manufacturer TEXT, - library_type TEXT, -- "Basic" or "Extended" - description TEXT, -- "10kΩ ±1% 100mW" - datasheet TEXT, - stock INTEGER, - price_json TEXT, -- JSON array of price breaks - last_updated INTEGER -- Unix timestamp -); -``` - -## Package to Footprint Mappings - -| JLCPCB Package | KiCad Footprints | -|----------------|------------------| -| 0402 | Resistor_SMD:R_0402_1005Metric
Capacitor_SMD:C_0402_1005Metric
LED_SMD:LED_0402_1005Metric | -| 0603 | Resistor_SMD:R_0603_1608Metric
Capacitor_SMD:C_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 | -| SOT-223 | Package_TO_SOT_SMD:SOT-223 | -| SOIC-8 | Package_SO:SOIC-8_3.9x4.9mm_P1.27mm | -| QFN-20 | Package_DFN_QFN:QFN-20-1EP_4x4mm_P0.5mm_EP2.5x2.5mm | - -## 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 -# Filter for Basic parts only -basic_parts = [p for p in results if p['is_basic']] -``` - -### 2. Check Stock Availability -Ensure sufficient stock before committing to a design. - -```python -# Only use parts with >1000 stock -high_stock = [p for p in results if p['stock'] > 1000] -``` - -### 3. Compare Prices -Even within Basic library, prices vary significantly. - -```python -# Find cheapest option -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 -# Initial download (one-time, ~5-10 minutes) -if not os.path.exists("data/jlcpcb_parts.db"): - parts = client.download_all_components() - db.import_jlcsearch_parts(parts) - -# Subsequent searches use local database (instant) -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 - -To use the official API instead of JLCSearch: - -```python -from commands.jlcpcb import JLCPCBClient - -# Set credentials in .env file: -# JLCPCB_APP_ID= -# JLCPCB_API_KEY= -# JLCPCB_API_SECRET= - -client = JLCPCBClient(app_id, access_key, secret_key) -data = client.fetch_parts_page() -``` - -**Note:** Most users should use JLCSearch public API instead, as it's freely available and requires no authentication. - -## Credits - -- **JLCSearch API**: https://jlcsearch.tscircuit.com/ (by [@tscircuit](https://github.com/tscircuit/jlcsearch)) -- **JLCParts Database**: https://github.com/yaqwsx/jlcparts (by [@yaqwsx](https://github.com/yaqwsx)) -- **JLCPCB**: https://jlcpcb.com/ (official parts library provider) - -## License - -This integration uses publicly available JLCPCB parts data via the JLCSearch community service. Users must comply with JLCPCB's terms of service when using this data for production PCB orders. +# JLCPCB Parts Integration - Complete Guide + +## Overview + +The KiCAD MCP Server integrates with JLCPCB's parts library to provide intelligent component selection, cost optimization, and automated part sourcing for PCB assembly. + +**Current Implementation**: Uses the **JLCSearch public API** (by tscircuit) for free, unauthenticated access to JLCPCB's ~100k parts catalog. + +## Features + +✅ **Parametric Search** - Find components by specifications (resistance, capacitance, package, etc.) +✅ **Price Comparison** - Compare Basic vs Extended library pricing +✅ **Alternative Suggestions** - Find cheaper or higher-stock alternatives +✅ **Footprint Mapping** - Automatic JLCPCB package to KiCad footprint mapping +✅ **Stock Availability** - Real-time stock levels from JLCPCB +✅ **No Authentication Required** - Public API, no API keys needed + +## Quick Start + +### 1. Search for Components + +```python +from commands.jlcsearch import JLCSearchClient + +client = JLCSearchClient() + +# Search for resistors +resistors = client.search_resistors( + resistance=10000, # 10kΩ + package="0603", + limit=20 +) + +# Search for capacitors +capacitors = client.search_capacitors( + capacitance=1e-7, # 100nF + package="0603", + limit=20 +) + +# General component search +components = client.search_components( + "components", + package="0603", + limit=100 +) +``` + +### 2. Get Part Details + +```python +# Get specific part by LCSC number +part = client.get_part_by_lcsc(25804) # C25804 +print(f"Part: {part['mfr']}") +print(f"Stock: {part['stock']}") +print(f"Price: ${part['price1']}") +print(f"Basic Library: {part['is_basic']}") +``` + +### 3. Database Integration + +```python +from commands.jlcpcb_parts import JLCPCBPartsManager + +# Initialize database +db = JLCPCBPartsManager() # Uses data/jlcpcb_parts.db + +# Download and import parts (one-time setup) +client = JLCSearchClient() +parts = client.download_all_components() +db.import_jlcsearch_parts(parts) + +# Search imported database +results = db.search_parts( + query="resistor", + package="0603", + library_type="Basic", + in_stock=True, + limit=20 +) +``` + +### 4. Footprint Mapping + +```python +# Map JLCPCB package to KiCad footprints +footprints = db.map_package_to_footprint("0603") +# Returns: +# [ +# "Resistor_SMD:R_0603_1608Metric", +# "Capacitor_SMD:C_0603_1608Metric", +# "LED_SMD:LED_0603_1608Metric" +# ] +``` + +## API Reference + +### 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 +- `is_basic`: True if Basic library part (no assembly fee) +- `resistance`: Resistance in ohms +- `tolerance_fraction`: Tolerance (0.01 = 1%) +- `power_watts`: Power rating in mW +- `stock`: Available stock +- `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 + +**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 +- `**filters`: Additional filters (package="0603", lcsc=25804, etc.) + +**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) + +**Returns:** List of all parts (~100k components) + +**Note:** This may take 5-10 minutes to complete. + +### 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 +- `library_type` (str, optional): "Basic", "Extended", or "Preferred" +- `manufacturer` (str, optional): Manufacturer filter +- `in_stock` (bool): Only in-stock parts (default: True) +- `limit` (int): Maximum results + +**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 +- `in_stock`: Parts with stock > 0 +- `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 + +## Data Format + +### JLCSearch Part Object + +```json +{ + "lcsc": 25804, + "mfr": "0603WAF1002T5E", + "package": "0603", + "is_basic": true, + "is_preferred": false, + "resistance": 10000, + "tolerance_fraction": 0.01, + "power_watts": 100, + "stock": 37165617, + "price1": 0.000842857 +} +``` + +### Database Schema + +```sql +CREATE TABLE components ( + lcsc TEXT PRIMARY KEY, -- "C25804" + category TEXT, -- "Resistors" + subcategory TEXT, -- "Chip Resistor" + mfr_part TEXT, -- "0603WAF1002T5E" + package TEXT, -- "0603" + solder_joints INTEGER, + manufacturer TEXT, + library_type TEXT, -- "Basic" or "Extended" + description TEXT, -- "10kΩ ±1% 100mW" + datasheet TEXT, + stock INTEGER, + price_json TEXT, -- JSON array of price breaks + last_updated INTEGER -- Unix timestamp +); +``` + +## Package to Footprint Mappings + +| JLCPCB Package | KiCad Footprints | +| -------------- | ------------------------------------------------------------------------------------------------ | +| 0402 | Resistor_SMD:R_0402_1005Metric
Capacitor_SMD:C_0402_1005Metric
LED_SMD:LED_0402_1005Metric | +| 0603 | Resistor_SMD:R_0603_1608Metric
Capacitor_SMD:C_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 | +| SOT-223 | Package_TO_SOT_SMD:SOT-223 | +| SOIC-8 | Package_SO:SOIC-8_3.9x4.9mm_P1.27mm | +| QFN-20 | Package_DFN_QFN:QFN-20-1EP_4x4mm_P0.5mm_EP2.5x2.5mm | + +## 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 +# Filter for Basic parts only +basic_parts = [p for p in results if p['is_basic']] +``` + +### 2. Check Stock Availability + +Ensure sufficient stock before committing to a design. + +```python +# Only use parts with >1000 stock +high_stock = [p for p in results if p['stock'] > 1000] +``` + +### 3. Compare Prices + +Even within Basic library, prices vary significantly. + +```python +# Find cheapest option +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 +# Initial download (one-time, ~5-10 minutes) +if not os.path.exists("data/jlcpcb_parts.db"): + parts = client.download_all_components() + db.import_jlcsearch_parts(parts) + +# Subsequent searches use local database (instant) +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 + +To use the official API instead of JLCSearch: + +```python +from commands.jlcpcb import JLCPCBClient + +# Set credentials in .env file: +# JLCPCB_APP_ID= +# JLCPCB_API_KEY= +# JLCPCB_API_SECRET= + +client = JLCPCBClient(app_id, access_key, secret_key) +data = client.fetch_parts_page() +``` + +**Note:** Most users should use JLCSearch public API instead, as it's freely available and requires no authentication. + +## Credits + +- **JLCSearch API**: https://jlcsearch.tscircuit.com/ (by [@tscircuit](https://github.com/tscircuit/jlcsearch)) +- **JLCParts Database**: https://github.com/yaqwsx/jlcparts (by [@yaqwsx](https://github.com/yaqwsx)) +- **JLCPCB**: https://jlcpcb.com/ (official parts library provider) + +## License + +This integration uses publicly available JLCPCB parts data via the JLCSearch community service. Users must comply with JLCPCB's terms of service when using this data for production PCB orders. diff --git a/docs/JLCPCB_USAGE_GUIDE.md b/docs/JLCPCB_USAGE_GUIDE.md index db4118d..69b4e87 100644 --- a/docs/JLCPCB_USAGE_GUIDE.md +++ b/docs/JLCPCB_USAGE_GUIDE.md @@ -1,519 +1,548 @@ -# 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) -2. **Local Symbol Libraries** - Search JLCPCB libraries installed via KiCad PCM _(contributed by [@l3wi](https://github.com/l3wi) in [PR #25](https://github.com/mixelpixx/KiCAD-MCP-Server/pull/25))_ -3. **Official JLCPCB API** - Requires enterprise account (Advanced) - -All approaches can be used together to give you maximum flexibility. - -## Credits - -- **Local Symbol Library Search**: Implementation by [@l3wi](https://github.com/l3wi) - [PR #25](https://github.com/mixelpixx/KiCAD-MCP-Server/pull/25) -- **JLCPCB API Integration**: Built on top of the local library foundation - ---- - -## 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** -- Real-time pricing with quantity breaks -- Basic vs Extended library type identification -- Local SQLite database for fast offline searching -- Note: Download takes 40-60 minutes due to API pagination (100 parts per request) - -### Setup - -No setup required! Just download the database: - -``` -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... -Downloaded 200 parts... -... (continues for 40-60 minutes) -Downloaded 2,500,000 parts... - -Total parts: 2,500,000+ -Database size: 3-5 GB (full catalog) -``` - -### Usage Examples - -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 -- Works offline -- Symbols already have LCSC IDs and footprints configured - -### Setup - -1. **Install JLCPCB Libraries via KiCad PCM:** - - Open KiCad → Tools → Plugin and Content Manager - - Search for "JLCPCB" or "JLC" - - Install libraries like: - - `JLCPCB-KiCad-Library` (community maintained) - - `EDA_MCP` (contains common JLCPCB parts) - - Any other JLCPCB-compatible libraries - -2. **Verify Installation:** - The libraries should appear in KiCad's symbol library table. - -### Usage Examples - -#### Search for Components -``` -search_symbols({ - query: "ESP32", - library: "JLCPCB" // Filter to JLCPCB libraries only -}) -``` - -Returns: -``` -Found 12 symbols matching "ESP32": - -PCM_JLCPCB-MCUs:ESP32-C3 | LCSC: C2934196 | ESP32-C3 RISC-V WiFi/BLE SoC -PCM_JLCPCB-MCUs:ESP32-S2 | LCSC: C701342 | ESP32-S2 WiFi SoC -... -``` - -#### Search by LCSC ID -``` -search_symbols({ - query: "C2934196" // Direct LCSC ID search -}) -``` - -#### Get Symbol Details -``` -get_symbol_info({ - symbol: "PCM_JLCPCB-MCUs:ESP32-C3" -}) -``` - -Returns: -``` -Symbol: PCM_JLCPCB-MCUs:ESP32-C3 -Description: ESP32-C3 RISC-V WiFi/BLE SoC -LCSC: C2934196 -Manufacturer: Espressif -MPN: ESP32-C3-WROOM-02 -Footprint: Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm -Class: Extended -``` - -### Advantages -- ✅ No API credentials required -- ✅ Works offline after library installation -- ✅ Symbols pre-configured with correct footprints -- ✅ Community-maintained and curated -- ✅ 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 - ---- - -## 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) -- Smart suggestions for cheaper/in-stock alternatives -- Package-to-footprint mapping for KiCad - -### Setup - -#### 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" -4. Save your `appKey` and `appSecret` - -#### 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 -``` - -#### 3. Download the Parts Database - -**One-time setup** (takes 5-10 minutes): - -``` -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 - -Total parts: 108,523 -Basic parts: 2,856 (free assembly) -Extended parts: 105,667 ($3 setup fee each) -Database size: 42.3 MB -Database path: /home/user/KiCAD-MCP-Server/data/jlcpcb_parts.db -``` - -### Usage Examples - -#### Search for Parts with Specifications - -``` -search_jlcpcb_parts({ - query: "10k resistor", - package: "0603", - library_type: "Basic" // Only free-assembly parts -}) -``` - -**Returns:** -``` -Found 15 JLCPCB parts: - -C25804: RC0603FR-0710KL - 10kΩ ±1% 0.1W [Basic] - $0.002/ea (15000 in stock) -C58972: 0603WAF1002T5E - 10kΩ ±1% 0.1W [Basic] - $0.001/ea (50000 in stock) -C25744: RC0603FR-0710KP - 10kΩ ±1% 0.1W [Basic] - $0.002/ea (12000 in stock) -... - -💡 Basic parts have free assembly. Extended parts charge $3 setup fee per unique part. -``` - -#### Get Part Details with Pricing - -``` -get_jlcpcb_part({ - lcsc_number: "C58972" -}) -``` - -**Returns:** -``` -LCSC: C58972 -MFR Part: 0603WAF1002T5E -Manufacturer: UNI-ROYAL -Category: Resistors / Chip Resistor - Surface Mount -Package: 0603 -Description: 10kΩ ±1% 0.1W Thick Film Resistors -Library Type: Basic (Free assembly!) -Stock: 50000 - -Price Breaks: - 1+: $0.0010/ea - 10+: $0.0009/ea - 100+: $0.0008/ea - 1000+: $0.0007/ea - -Suggested KiCAD Footprints: - - Resistor_SMD:R_0603_1608Metric - - Capacitor_SMD:C_0603_1608Metric - - LED_SMD:LED_0603_1608Metric -``` - -#### Find Cheaper Alternatives - -``` -suggest_jlcpcb_alternatives({ - lcsc_number: "C25804", - limit: 5 -}) -``` - -**Returns:** -``` -Alternative parts for C25804: - -1. C58972: 0603WAF1002T5E [Basic] - $0.001/ea (50% cheaper) - 10kΩ ±1% 0.1W Thick Film Resistors - Stock: 50000 - -2. C22790: 0603WAF1002T - [Basic] - $0.0011/ea (45% cheaper) - 10kΩ ±1% 0.1W Thick Film Resistors - Stock: 35000 -... -``` - -#### Search by Category and Package - -``` -search_jlcpcb_parts({ - category: "Microcontrollers", - package: "QFN-32", - manufacturer: "STM", - in_stock: true, - limit: 10 -}) -``` - -#### Get Database Statistics - -``` -get_jlcpcb_database_stats({}) -``` - -**Returns:** -``` -JLCPCB Database Statistics: - -Total parts: 108,523 -Basic parts: 2,856 (free assembly) -Extended parts: 105,667 ($3 setup fee each) -In stock: 95,432 -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 -- ✅ Cost optimization suggestions -- ✅ Works offline after initial download -- ✅ Fast parametric search - -### Limitations -- ❌ Requires API credentials -- ❌ Initial download takes 5-10 minutes -- ❌ Database needs periodic updates for latest parts -- ❌ Footprint mapping may need manual verification - ---- - -## Best Practices: Using Both Approaches Together - -### Workflow 1: Design with Known Components - -**Use Local Libraries:** -``` -1. search_symbols({ query: "STM32F103", library: "JLCPCB" }) -2. Select component from installed library -3. Component already has correct symbol + footprint + LCSC ID -``` - -**Why:** Faster, symbols are pre-configured and tested. - -### Workflow 2: Find Optimal Part for Cost - -**Use JLCPCB API:** -``` -1. search_jlcpcb_parts({ - query: "10k resistor", - package: "0603", - library_type: "Basic" - }) -2. Select cheapest Basic part -3. Use suggested footprint from API -``` - -**Why:** Ensures lowest cost and maximum stock availability. - -### 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) -3. search_symbols({ query: "C2934196" }) -4. If found in library → use library symbol -5. If not found → use API footprint suggestion -``` - -**Why:** Combines discovery power of API with quality of curated libraries. - ---- - -## Cost Optimization Tips - -### 1. Prefer Basic Parts - -``` -search_jlcpcb_parts({ - query: "resistor 10k", - library_type: "Basic" // Free assembly! -}) -``` - -**Why:** Basic parts have **$0 assembly fee**. Extended parts charge **$3 per unique part**. - -### 2. Use Alternatives Tool - -``` -suggest_jlcpcb_alternatives({ lcsc_number: "C12345" }) -``` - -**Why:** Find cheaper, more available, or Basic alternatives automatically. - -### 3. Check Stock Levels - -Always filter `in_stock: true` to avoid ordering parts that are out of stock: - -``` -search_jlcpcb_parts({ - query: "capacitor", - in_stock: true // Only show available 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) - ---- - -## Updating the Database - -The JLCPCB parts database should be updated periodically to get latest parts and pricing. - -### Manual Update - -``` -download_jlcpcb_database({ force: true }) -``` - -This re-downloads the entire catalog and replaces the existing database. - -### Automatic Updates (Future) - -Future versions will support incremental updates that only fetch new/changed parts. - ---- - -## Troubleshooting - -### "JLCPCB API credentials not configured" - -**Solution:** Set environment variables: -```bash -export JLCPCB_API_KEY="your_key" -export JLCPCB_API_SECRET="your_secret" -``` - -### "Database not found or empty" - -**Solution:** Run: -``` -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 - -### "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 - ---- - -## 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) | -| **Pricing** | ❌ No | ✅ Real-time | -| **Stock Info** | ❌ No | ✅ Real-time | -| **Footprints** | ✅ Pre-configured | ⚠️ Auto-suggested | -| **Updates** | Manual via PCM | Re-download database | -| **Speed** | ⚡ Instant | ⚡ Fast (local DB) | -| **Cost Optimization** | ❌ Manual | ✅ Automatic | - ---- - -## Example Workflows - -### Complete Design Flow - -``` -# 1. Find main MCU from local library (curated) -search_symbols({ query: "ESP32", library: "JLCPCB" }) -→ Use: PCM_JLCPCB-MCUs:ESP32-C3 - -# 2. Find passives optimized for cost (API) -search_jlcpcb_parts({ - query: "capacitor 10uF", - package: "0805", - library_type: "Basic" -}) -→ Use: C15850 ($0.004, Basic, 80k stock) - -# 3. Verify connector in library -search_symbols({ query: "USB-C" }) -→ Use library symbol if available - -# 4. Export BOM with LCSC numbers -# All components now have LCSC IDs for JLCPCB assembly! -``` - ---- - -## Resources - -- [JLCPCB API Documentation](https://jlcpcb.com/help/article/JLCPCB-API) -- [JLCPCB Parts Library](https://jlcpcb.com/parts) -- [KiCad Plugin and Content Manager](https://www.kicad.org/help/pcm/) -- [JLCPCB-KiCad-Library (GitHub)](https://github.com/pejot/JLC2KiCad_lib) - ---- - -## 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 -- Need complete catalog access - -**Best approach:** Use both! Start with local libraries for known components, then use API for cost optimization and finding alternatives. +# 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) +2. **Local Symbol Libraries** - Search JLCPCB libraries installed via KiCad PCM _(contributed by [@l3wi](https://github.com/l3wi) in [PR #25](https://github.com/mixelpixx/KiCAD-MCP-Server/pull/25))_ +3. **Official JLCPCB API** - Requires enterprise account (Advanced) + +All approaches can be used together to give you maximum flexibility. + +## Credits + +- **Local Symbol Library Search**: Implementation by [@l3wi](https://github.com/l3wi) - [PR #25](https://github.com/mixelpixx/KiCAD-MCP-Server/pull/25) +- **JLCPCB API Integration**: Built on top of the local library foundation + +--- + +## 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** +- Real-time pricing with quantity breaks +- Basic vs Extended library type identification +- Local SQLite database for fast offline searching +- Note: Download takes 40-60 minutes due to API pagination (100 parts per request) + +### Setup + +No setup required! Just download the database: + +``` +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... +Downloaded 200 parts... +... (continues for 40-60 minutes) +Downloaded 2,500,000 parts... + +Total parts: 2,500,000+ +Database size: 3-5 GB (full catalog) +``` + +### Usage Examples + +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 +- Works offline +- Symbols already have LCSC IDs and footprints configured + +### Setup + +1. **Install JLCPCB Libraries via KiCad PCM:** + - Open KiCad → Tools → Plugin and Content Manager + - Search for "JLCPCB" or "JLC" + - Install libraries like: + - `JLCPCB-KiCad-Library` (community maintained) + - `EDA_MCP` (contains common JLCPCB parts) + - Any other JLCPCB-compatible libraries + +2. **Verify Installation:** + The libraries should appear in KiCad's symbol library table. + +### Usage Examples + +#### Search for Components + +``` +search_symbols({ + query: "ESP32", + library: "JLCPCB" // Filter to JLCPCB libraries only +}) +``` + +Returns: + +``` +Found 12 symbols matching "ESP32": + +PCM_JLCPCB-MCUs:ESP32-C3 | LCSC: C2934196 | ESP32-C3 RISC-V WiFi/BLE SoC +PCM_JLCPCB-MCUs:ESP32-S2 | LCSC: C701342 | ESP32-S2 WiFi SoC +... +``` + +#### Search by LCSC ID + +``` +search_symbols({ + query: "C2934196" // Direct LCSC ID search +}) +``` + +#### Get Symbol Details + +``` +get_symbol_info({ + symbol: "PCM_JLCPCB-MCUs:ESP32-C3" +}) +``` + +Returns: + +``` +Symbol: PCM_JLCPCB-MCUs:ESP32-C3 +Description: ESP32-C3 RISC-V WiFi/BLE SoC +LCSC: C2934196 +Manufacturer: Espressif +MPN: ESP32-C3-WROOM-02 +Footprint: Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm +Class: Extended +``` + +### Advantages + +- ✅ No API credentials required +- ✅ Works offline after library installation +- ✅ Symbols pre-configured with correct footprints +- ✅ Community-maintained and curated +- ✅ 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 + +--- + +## 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) +- Smart suggestions for cheaper/in-stock alternatives +- Package-to-footprint mapping for KiCad + +### Setup + +#### 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" +4. Save your `appKey` and `appSecret` + +#### 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 +``` + +#### 3. Download the Parts Database + +**One-time setup** (takes 5-10 minutes): + +``` +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 + +Total parts: 108,523 +Basic parts: 2,856 (free assembly) +Extended parts: 105,667 ($3 setup fee each) +Database size: 42.3 MB +Database path: /home/user/KiCAD-MCP-Server/data/jlcpcb_parts.db +``` + +### Usage Examples + +#### Search for Parts with Specifications + +``` +search_jlcpcb_parts({ + query: "10k resistor", + package: "0603", + library_type: "Basic" // Only free-assembly parts +}) +``` + +**Returns:** + +``` +Found 15 JLCPCB parts: + +C25804: RC0603FR-0710KL - 10kΩ ±1% 0.1W [Basic] - $0.002/ea (15000 in stock) +C58972: 0603WAF1002T5E - 10kΩ ±1% 0.1W [Basic] - $0.001/ea (50000 in stock) +C25744: RC0603FR-0710KP - 10kΩ ±1% 0.1W [Basic] - $0.002/ea (12000 in stock) +... + +💡 Basic parts have free assembly. Extended parts charge $3 setup fee per unique part. +``` + +#### Get Part Details with Pricing + +``` +get_jlcpcb_part({ + lcsc_number: "C58972" +}) +``` + +**Returns:** + +``` +LCSC: C58972 +MFR Part: 0603WAF1002T5E +Manufacturer: UNI-ROYAL +Category: Resistors / Chip Resistor - Surface Mount +Package: 0603 +Description: 10kΩ ±1% 0.1W Thick Film Resistors +Library Type: Basic (Free assembly!) +Stock: 50000 + +Price Breaks: + 1+: $0.0010/ea + 10+: $0.0009/ea + 100+: $0.0008/ea + 1000+: $0.0007/ea + +Suggested KiCAD Footprints: + - Resistor_SMD:R_0603_1608Metric + - Capacitor_SMD:C_0603_1608Metric + - LED_SMD:LED_0603_1608Metric +``` + +#### Find Cheaper Alternatives + +``` +suggest_jlcpcb_alternatives({ + lcsc_number: "C25804", + limit: 5 +}) +``` + +**Returns:** + +``` +Alternative parts for C25804: + +1. C58972: 0603WAF1002T5E [Basic] - $0.001/ea (50% cheaper) + 10kΩ ±1% 0.1W Thick Film Resistors + Stock: 50000 + +2. C22790: 0603WAF1002T - [Basic] - $0.0011/ea (45% cheaper) + 10kΩ ±1% 0.1W Thick Film Resistors + Stock: 35000 +... +``` + +#### Search by Category and Package + +``` +search_jlcpcb_parts({ + category: "Microcontrollers", + package: "QFN-32", + manufacturer: "STM", + in_stock: true, + limit: 10 +}) +``` + +#### Get Database Statistics + +``` +get_jlcpcb_database_stats({}) +``` + +**Returns:** + +``` +JLCPCB Database Statistics: + +Total parts: 108,523 +Basic parts: 2,856 (free assembly) +Extended parts: 105,667 ($3 setup fee each) +In stock: 95,432 +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 +- ✅ Cost optimization suggestions +- ✅ Works offline after initial download +- ✅ Fast parametric search + +### Limitations + +- ❌ Requires API credentials +- ❌ Initial download takes 5-10 minutes +- ❌ Database needs periodic updates for latest parts +- ❌ Footprint mapping may need manual verification + +--- + +## Best Practices: Using Both Approaches Together + +### Workflow 1: Design with Known Components + +**Use Local Libraries:** + +``` +1. search_symbols({ query: "STM32F103", library: "JLCPCB" }) +2. Select component from installed library +3. Component already has correct symbol + footprint + LCSC ID +``` + +**Why:** Faster, symbols are pre-configured and tested. + +### Workflow 2: Find Optimal Part for Cost + +**Use JLCPCB API:** + +``` +1. search_jlcpcb_parts({ + query: "10k resistor", + package: "0603", + library_type: "Basic" + }) +2. Select cheapest Basic part +3. Use suggested footprint from API +``` + +**Why:** Ensures lowest cost and maximum stock availability. + +### 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) +3. search_symbols({ query: "C2934196" }) +4. If found in library → use library symbol +5. If not found → use API footprint suggestion +``` + +**Why:** Combines discovery power of API with quality of curated libraries. + +--- + +## Cost Optimization Tips + +### 1. Prefer Basic Parts + +``` +search_jlcpcb_parts({ + query: "resistor 10k", + library_type: "Basic" // Free assembly! +}) +``` + +**Why:** Basic parts have **$0 assembly fee**. Extended parts charge **$3 per unique part**. + +### 2. Use Alternatives Tool + +``` +suggest_jlcpcb_alternatives({ lcsc_number: "C12345" }) +``` + +**Why:** Find cheaper, more available, or Basic alternatives automatically. + +### 3. Check Stock Levels + +Always filter `in_stock: true` to avoid ordering parts that are out of stock: + +``` +search_jlcpcb_parts({ + query: "capacitor", + in_stock: true // Only show available 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) + +--- + +## Updating the Database + +The JLCPCB parts database should be updated periodically to get latest parts and pricing. + +### Manual Update + +``` +download_jlcpcb_database({ force: true }) +``` + +This re-downloads the entire catalog and replaces the existing database. + +### Automatic Updates (Future) + +Future versions will support incremental updates that only fetch new/changed parts. + +--- + +## Troubleshooting + +### "JLCPCB API credentials not configured" + +**Solution:** Set environment variables: + +```bash +export JLCPCB_API_KEY="your_key" +export JLCPCB_API_SECRET="your_secret" +``` + +### "Database not found or empty" + +**Solution:** Run: + +``` +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 + +### "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 + +--- + +## 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) | +| **Pricing** | ❌ No | ✅ Real-time | +| **Stock Info** | ❌ No | ✅ Real-time | +| **Footprints** | ✅ Pre-configured | ⚠️ Auto-suggested | +| **Updates** | Manual via PCM | Re-download database | +| **Speed** | ⚡ Instant | ⚡ Fast (local DB) | +| **Cost Optimization** | ❌ Manual | ✅ Automatic | + +--- + +## Example Workflows + +### Complete Design Flow + +``` +# 1. Find main MCU from local library (curated) +search_symbols({ query: "ESP32", library: "JLCPCB" }) +→ Use: PCM_JLCPCB-MCUs:ESP32-C3 + +# 2. Find passives optimized for cost (API) +search_jlcpcb_parts({ + query: "capacitor 10uF", + package: "0805", + library_type: "Basic" +}) +→ Use: C15850 ($0.004, Basic, 80k stock) + +# 3. Verify connector in library +search_symbols({ query: "USB-C" }) +→ Use library symbol if available + +# 4. Export BOM with LCSC numbers +# All components now have LCSC IDs for JLCPCB assembly! +``` + +--- + +## Resources + +- [JLCPCB API Documentation](https://jlcpcb.com/help/article/JLCPCB-API) +- [JLCPCB Parts Library](https://jlcpcb.com/parts) +- [KiCad Plugin and Content Manager](https://www.kicad.org/help/pcm/) +- [JLCPCB-KiCad-Library (GitHub)](https://github.com/pejot/JLC2KiCad_lib) + +--- + +## 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 +- Need complete catalog access + +**Best approach:** Use both! Start with local libraries for known components, then use API for cost optimization and finding alternatives. diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index 615920f..b1740e9 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -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,10 +32,12 @@ 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 3. Use `refill_zones` tool (may still segfault in some configurations) @@ -48,6 +51,7 @@ AttributeError: 'BOARD' object has no attribute 'LT_USER' **Status:** BY DESIGN **Symptoms:** + - MCP makes changes via SWIG backend - KiCAD does not show changes until file is reloaded @@ -64,6 +68,7 @@ AttributeError: 'BOARD' object has no attribute 'LT_USER' **Status:** EXPERIMENTAL **Known Limitations:** + - 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) @@ -85,32 +90,40 @@ AttributeError: 'BOARD' object has no attribute 'LT_USER' ## Recently Fixed (v2.2.0 - v2.2.3) ### B.Cu Footprint Routing (Fixed v2.2.3) + - `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()` ### B.Cu Placement Hang (Fixed v2.2.3) + - Placing footprints on B.Cu no longer causes ~30s freeze - Fix: call `board.Add()` before `Flip()` ### Board Outline Rounded Corners (Fixed v2.2.3) + - `add_board_outline` now correctly applies cornerRadius for rounded_rectangle shape ### Project-Local Library Resolution (Fixed v2.2.2) + - `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 ### Template File Corruption (Fixed v2.2.2) + - Removed invalid `;;` comment lines from template schematics - Restored KiCAD 9 format version (20250114) in templates ### copy_routing_pattern Empty Results (Fixed v2.2.2) + - Added geometric fallback when pads have no net assignments ### Schematic Component Corruption (Fixed v2.2.1) + - `add_schematic_component` no longer corrupts .kicad_sch files - Rewritten to use text manipulation instead of sexpdata formatting ### SWIG/UUID Comparison Bugs (Fixed v2.2.0) + - Fixed SwigPyObject UUID comparison - Fixed SWIG iterator invalidation after board.Remove() - Added board.SetModified() to prevent dangling pointer crashes @@ -136,6 +149,7 @@ If you encounter an issue not listed here: ## General Workarounds ### Server Will Not Start + ```bash # Check Python can import pcbnew python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())" @@ -145,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 Does Not Show Changes (SWIG Mode) + ``` # File > Revert (or click reload prompt) # Or: Close and reopen file in KiCAD @@ -158,6 +174,7 @@ python3 python/utils/platform_helper.py ``` ### IPC Not Connecting + ``` # Ensure KiCAD is running # Enable IPC: Preferences > Plugins > Enable IPC API Server @@ -168,6 +185,7 @@ python3 python/utils/platform_helper.py --- **Need Help?** + - Check [IPC_BACKEND_STATUS.md](IPC_BACKEND_STATUS.md) for IPC details - Check logs: `~/.kicad-mcp/logs/kicad_interface.log` - Open an issue on GitHub diff --git a/docs/LIBRARY_INTEGRATION.md b/docs/LIBRARY_INTEGRATION.md index c23b8b5..f45d36a 100644 --- a/docs/LIBRARY_INTEGRATION.md +++ b/docs/LIBRARY_INTEGRATION.md @@ -1,364 +1,390 @@ -# KiCAD Library Integration - -**Status:** ✅ COMPLETE -**Date:** 2026-03-21 -**Version:** 2.2.3+ - -## Overview - -The KiCAD MCP Server includes full library integration for both footprints and symbols, enabling: -- ✅ Automatic discovery of all installed KiCAD footprint 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 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 - - Supports custom paths - -3. **Indexing footprints:** - - Scans `.kicad_mod` files in each library - - Caches results for performance - - Provides fast search capabilities - -### Supported Formats - -**Library:Footprint format (recommended):** -```json -{ - "componentId": "Resistor_SMD:R_0603_1608Metric" -} -``` - -**Footprint-only format (searches all libraries):** -```json -{ - "componentId": "R_0603_1608Metric" -} -``` - -## New MCP Tools - -### 1. `list_libraries` - -List all available footprint libraries. - -**Parameters:** None - -**Returns:** -```json -{ - "success": true, - "libraries": ["Resistor_SMD", "Capacitor_SMD", "LED_SMD", ...], - "count": 153 -} -``` - -### 2. `search_footprints` - -Search for footprints matching a pattern. - -**Parameters:** -```json -{ - "pattern": "*0603*", // Supports wildcards - "limit": 20 // Optional, default: 20 -} -``` - -**Returns:** -```json -{ - "success": true, - "footprints": [ - { - "library": "Resistor_SMD", - "footprint": "R_0603_1608Metric", - "full_name": "Resistor_SMD:R_0603_1608Metric" - }, - ... - ] -} -``` - -### 3. `list_library_footprints` - -List all footprints in a specific library. - -**Parameters:** -```json -{ - "library": "Resistor_SMD" -} -``` - -**Returns:** -```json -{ - "success": true, - "library": "Resistor_SMD", - "footprints": ["R_0402_1005Metric", "R_0603_1608Metric", ...], - "count": 120 -} -``` - -### 4. `get_footprint_info` - -Get detailed information about a specific footprint. - -**Parameters:** -```json -{ - "footprint": "Resistor_SMD:R_0603_1608Metric" -} -``` - -**Returns:** -```json -{ - "success": true, - "footprint_info": { - "library": "Resistor_SMD", - "footprint": "R_0603_1608Metric", - "full_name": "Resistor_SMD:R_0603_1608Metric", - "library_path": "/usr/share/kicad/footprints/Resistor_SMD.pretty" - } -} -``` - -## Updated Component Placement - -The `place_component` tool now uses the library system: - -```json -{ - "componentId": "Resistor_SMD:R_0603_1608Metric", // Library:Footprint format - "position": {"x": 50, "y": 40, "unit": "mm"}, - "reference": "R1", - "value": "10k", - "rotation": 0, - "layer": "F.Cu" -} -``` - -**Features:** -- ✅ Automatic footprint discovery across all libraries -- ✅ Helpful error messages with suggestions -- ✅ Supports KiCAD 9.0 API (EDA_ANGLE, GetFPIDAsString) - -## Example Usage (Claude Code) - -**Search for a resistor footprint:** -``` -User: "Find me a 0603 resistor footprint" - -Claude: [uses search_footprints tool with pattern "*R_0603*"] - Found: Resistor_SMD:R_0603_1608Metric -``` - -**Place a component:** -``` -User: "Place a 10k 0603 resistor at 50,40mm" - -Claude: [uses place_component with "Resistor_SMD:R_0603_1608Metric"] - ✅ Placed R1: 10k at (50, 40) mm -``` - -**List available capacitors:** -``` -User: "What capacitor footprints are available?" - -Claude: [uses list_library_footprints with "Capacitor_SMD"] - Found 103 capacitor footprints including: - - C_0402_1005Metric - - C_0603_1608Metric - - C_0805_2012Metric - ... -``` - -## Configuration - -### Custom Library Paths - -The system automatically detects KiCAD installations, but you can add custom libraries: - -1. **Via KiCAD Preferences:** - - Open KiCAD → Preferences → Manage Footprint Libraries - - Add your custom library paths - - The MCP server will automatically discover them - -2. **Via Project fp-lib-table:** - - Create `fp-lib-table` in your project directory - - Follow the KiCAD S-expression format - -### Supported Platforms - -- ✅ **Linux:** `/usr/share/kicad/footprints`, `~/.config/kicad/9.0/` -- ✅ **Windows:** `C:/Program Files/KiCAD/*/share/kicad/footprints` -- ✅ **macOS:** `/Applications/KiCad/KiCad.app/Contents/SharedSupport/footprints` - -## KiCAD 9.0 API Compatibility - -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) -rotation = module.GetOrientation().AsDegrees() -``` - -## Implementation Details - -### LibraryManager Class - -**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 -- `find_footprint()` - Locate footprint in libraries -- `search_footprints()` - Pattern-based search -- `list_footprints()` - List library contents - -**Performance:** -- Libraries loaded once at startup -- Footprint lists cached on first access -- Fast search using Python regex -- Minimal memory footprint - -### Integration Points - -1. **KiCADInterface (`kicad_interface.py`):** - - Creates `FootprintLibraryManager` on init - - Passes to `ComponentCommands` - - Routes library commands - -2. **ComponentCommands (`component.py`):** - - Uses `LibraryManager.find_footprint()` - - Provides suggestions on errors - - Supports both lookup formats - -3. **MCP Tools (`src/tools/index.ts`):** - - Exposes 4 new library tools - - Fully typed TypeScript interfaces - - Documented parameters - -## Testing - -**Test Coverage:** -- ✅ Library path discovery (Linux/Windows/macOS) -- ✅ fp-lib-table parsing -- ✅ Environment variable resolution -- ✅ Footprint search and lookup -- ✅ Component placement integration -- ✅ Error handling and suggestions - -**Verified With:** -- KiCAD 9.0.5 on Ubuntu 24.04 -- 153 standard libraries (8,000+ footprints) -- pcbnew Python API - -## Known Limitations - -1. **Library Updates:** Changes to fp-lib-table require server restart -2. **Custom Libraries:** Must be added via KiCAD preferences first -3. **Network Libraries:** GitHub-based libraries not yet supported -4. **Search Performance:** Linear search across all libraries (fast for <200 libs) - -## Future Enhancements - -- [ ] Watch fp-lib-table for changes (auto-reload) -- [ ] Support for GitHub library URLs -- [ ] Fuzzy search for typo tolerance -- [ ] Library metadata (descriptions, categories) -- [ ] Footprint previews (SVG/PNG generation) -- [ ] Most-used footprints caching - -## Troubleshooting - -### "No footprint libraries found" - -**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 - -### "Footprint not found" - -**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 - -### "Failed to load footprint" - -**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 - -## Related Documentation - -- [ROADMAP.md](./ROADMAP.md) - Week 2 planning -- [STATUS_SUMMARY.md](./STATUS_SUMMARY.md) - Current implementation status -- [API.md](./API.md) - Full MCP API reference -- [KiCAD Documentation](https://docs.kicad.org/9.0/en/pcbnew/pcbnew.html) - Official KiCAD docs - -## Changelog - -**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 -- ✅ Fixed all KiCAD 9.0 API compatibility issues -- ✅ Tested end-to-end with real components -- ✅ Created comprehensive documentation - ---- - -**Status: PRODUCTION READY** 🎉 - -The library integration is complete and fully functional. Component placement now works seamlessly with KiCAD's footprint libraries, enabling AI-driven PCB design with real, validated components. +# KiCAD Library Integration + +**Status:** ✅ COMPLETE +**Date:** 2026-03-21 +**Version:** 2.2.3+ + +## Overview + +The KiCAD MCP Server includes full library integration for both footprints and symbols, enabling: + +- ✅ Automatic discovery of all installed KiCAD footprint 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 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 + - Supports custom paths + +3. **Indexing footprints:** + - Scans `.kicad_mod` files in each library + - Caches results for performance + - Provides fast search capabilities + +### Supported Formats + +**Library:Footprint format (recommended):** + +```json +{ + "componentId": "Resistor_SMD:R_0603_1608Metric" +} +``` + +**Footprint-only format (searches all libraries):** + +```json +{ + "componentId": "R_0603_1608Metric" +} +``` + +## New MCP Tools + +### 1. `list_libraries` + +List all available footprint libraries. + +**Parameters:** None + +**Returns:** + +```json +{ + "success": true, + "libraries": ["Resistor_SMD", "Capacitor_SMD", "LED_SMD", ...], + "count": 153 +} +``` + +### 2. `search_footprints` + +Search for footprints matching a pattern. + +**Parameters:** + +```json +{ + "pattern": "*0603*", // Supports wildcards + "limit": 20 // Optional, default: 20 +} +``` + +**Returns:** + +```json +{ + "success": true, + "footprints": [ + { + "library": "Resistor_SMD", + "footprint": "R_0603_1608Metric", + "full_name": "Resistor_SMD:R_0603_1608Metric" + }, + ... + ] +} +``` + +### 3. `list_library_footprints` + +List all footprints in a specific library. + +**Parameters:** + +```json +{ + "library": "Resistor_SMD" +} +``` + +**Returns:** + +```json +{ + "success": true, + "library": "Resistor_SMD", + "footprints": ["R_0402_1005Metric", "R_0603_1608Metric", ...], + "count": 120 +} +``` + +### 4. `get_footprint_info` + +Get detailed information about a specific footprint. + +**Parameters:** + +```json +{ + "footprint": "Resistor_SMD:R_0603_1608Metric" +} +``` + +**Returns:** + +```json +{ + "success": true, + "footprint_info": { + "library": "Resistor_SMD", + "footprint": "R_0603_1608Metric", + "full_name": "Resistor_SMD:R_0603_1608Metric", + "library_path": "/usr/share/kicad/footprints/Resistor_SMD.pretty" + } +} +``` + +## Updated Component Placement + +The `place_component` tool now uses the library system: + +```json +{ + "componentId": "Resistor_SMD:R_0603_1608Metric", // Library:Footprint format + "position": { "x": 50, "y": 40, "unit": "mm" }, + "reference": "R1", + "value": "10k", + "rotation": 0, + "layer": "F.Cu" +} +``` + +**Features:** + +- ✅ Automatic footprint discovery across all libraries +- ✅ Helpful error messages with suggestions +- ✅ Supports KiCAD 9.0 API (EDA_ANGLE, GetFPIDAsString) + +## Example Usage (Claude Code) + +**Search for a resistor footprint:** + +``` +User: "Find me a 0603 resistor footprint" + +Claude: [uses search_footprints tool with pattern "*R_0603*"] + Found: Resistor_SMD:R_0603_1608Metric +``` + +**Place a component:** + +``` +User: "Place a 10k 0603 resistor at 50,40mm" + +Claude: [uses place_component with "Resistor_SMD:R_0603_1608Metric"] + ✅ Placed R1: 10k at (50, 40) mm +``` + +**List available capacitors:** + +``` +User: "What capacitor footprints are available?" + +Claude: [uses list_library_footprints with "Capacitor_SMD"] + Found 103 capacitor footprints including: + - C_0402_1005Metric + - C_0603_1608Metric + - C_0805_2012Metric + ... +``` + +## Configuration + +### Custom Library Paths + +The system automatically detects KiCAD installations, but you can add custom libraries: + +1. **Via KiCAD Preferences:** + - Open KiCAD → Preferences → Manage Footprint Libraries + - Add your custom library paths + - The MCP server will automatically discover them + +2. **Via Project fp-lib-table:** + - Create `fp-lib-table` in your project directory + - Follow the KiCAD S-expression format + +### Supported Platforms + +- ✅ **Linux:** `/usr/share/kicad/footprints`, `~/.config/kicad/9.0/` +- ✅ **Windows:** `C:/Program Files/KiCAD/*/share/kicad/footprints` +- ✅ **macOS:** `/Applications/KiCad/KiCad.app/Contents/SharedSupport/footprints` + +## KiCAD 9.0 API Compatibility + +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) +rotation = module.GetOrientation().AsDegrees() +``` + +## Implementation Details + +### LibraryManager Class + +**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 +- `find_footprint()` - Locate footprint in libraries +- `search_footprints()` - Pattern-based search +- `list_footprints()` - List library contents + +**Performance:** + +- Libraries loaded once at startup +- Footprint lists cached on first access +- Fast search using Python regex +- Minimal memory footprint + +### Integration Points + +1. **KiCADInterface (`kicad_interface.py`):** + - Creates `FootprintLibraryManager` on init + - Passes to `ComponentCommands` + - Routes library commands + +2. **ComponentCommands (`component.py`):** + - Uses `LibraryManager.find_footprint()` + - Provides suggestions on errors + - Supports both lookup formats + +3. **MCP Tools (`src/tools/index.ts`):** + - Exposes 4 new library tools + - Fully typed TypeScript interfaces + - Documented parameters + +## Testing + +**Test Coverage:** + +- ✅ Library path discovery (Linux/Windows/macOS) +- ✅ fp-lib-table parsing +- ✅ Environment variable resolution +- ✅ Footprint search and lookup +- ✅ Component placement integration +- ✅ Error handling and suggestions + +**Verified With:** + +- KiCAD 9.0.5 on Ubuntu 24.04 +- 153 standard libraries (8,000+ footprints) +- pcbnew Python API + +## Known Limitations + +1. **Library Updates:** Changes to fp-lib-table require server restart +2. **Custom Libraries:** Must be added via KiCAD preferences first +3. **Network Libraries:** GitHub-based libraries not yet supported +4. **Search Performance:** Linear search across all libraries (fast for <200 libs) + +## Future Enhancements + +- [ ] Watch fp-lib-table for changes (auto-reload) +- [ ] Support for GitHub library URLs +- [ ] Fuzzy search for typo tolerance +- [ ] Library metadata (descriptions, categories) +- [ ] Footprint previews (SVG/PNG generation) +- [ ] Most-used footprints caching + +## Troubleshooting + +### "No footprint libraries found" + +**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 + +### "Footprint not found" + +**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 + +### "Failed to load footprint" + +**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 + +## Related Documentation + +- [ROADMAP.md](./ROADMAP.md) - Week 2 planning +- [STATUS_SUMMARY.md](./STATUS_SUMMARY.md) - Current implementation status +- [API.md](./API.md) - Full MCP API reference +- [KiCAD Documentation](https://docs.kicad.org/9.0/en/pcbnew/pcbnew.html) - Official KiCAD docs + +## Changelog + +**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 +- ✅ Fixed all KiCAD 9.0 API compatibility issues +- ✅ Tested end-to-end with real components +- ✅ Created comprehensive documentation + +--- + +**Status: PRODUCTION READY** 🎉 + +The library integration is complete and fully functional. Component placement now works seamlessly with KiCAD's footprint libraries, enabling AI-driven PCB design with real, validated components. diff --git a/docs/LINUX_COMPATIBILITY_AUDIT.md b/docs/LINUX_COMPATIBILITY_AUDIT.md index e965f87..a78f07c 100644 --- a/docs/LINUX_COMPATIBILITY_AUDIT.md +++ b/docs/LINUX_COMPATIBILITY_AUDIT.md @@ -1,313 +1,336 @@ -# 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 - ---- - -## Executive Summary - -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 -- ❌ Documentation: Windows-only instructions - ---- - -## 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" -``` - -**Impact:** Config file won't work on Linux without manual editing -**Fix:** Create platform-specific config templates -**Priority:** P0 - ---- - -### 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 - "/usr/share/kicad/symbols/*.kicad_sym", # Linux - "/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", # macOS -] -``` - -**Impact:** Works but inefficient (checks all platforms) -**Fix:** Auto-detect platform and use appropriate paths -**Priority:** P0 - ---- - -### 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'), - os.path.dirname(sys.executable) -] -``` - -**Impact:** Paths use Windows convention ('Lib' is 'lib' on Linux) -**Fix:** Platform-specific path detection -**Priority:** P0 - ---- - -## 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 - -**Fix:** Add Linux installation section -**Priority:** P1 - ---- - -### 5. Missing Python Dependencies Documentation -**File:** None (no requirements.txt) - -**Impact:** Users don't know what Python packages to install -**Fix:** Create `requirements.txt` and `requirements-dev.txt` -**Priority:** P1 - ---- - -### 6. Path Handling Uses os.path Instead of pathlib -**Files:** All Python files (11 files) - -**Impact:** Code is less readable and more error-prone -**Fix:** Migrate to `pathlib.Path` throughout -**Priority:** P1 - ---- - -## Medium Priority Issues (P2) - -### 7. No Linux-Specific Testing -**Impact:** Can't verify Linux compatibility -**Fix:** Add GitHub Actions with Ubuntu runner -**Priority:** P2 - ---- - -### 8. Log File Paths May Differ -**File:** `src/logger.ts:13` -```typescript -const DEFAULT_LOG_DIR = join(os.homedir(), '.kicad-mcp', 'logs'); -``` - -**Impact:** `.kicad-mcp` is okay for Linux, but best practice is `~/.config/kicad-mcp` -**Fix:** Use XDG Base Directory spec on Linux -**Priority:** P2 - ---- - -### 9. No Bash/Shell Scripts for Linux -**Impact:** Manual setup is harder on Linux -**Fix:** Create `install.sh` and `run.sh` scripts -**Priority:** P2 - ---- - -## Low Priority Issues (P3) - -### 10. TypeScript Build Uses Windows Conventions -**File:** `package.json` - -**Impact:** Works but could be more Linux-friendly -**Fix:** Add platform-specific build scripts -**Priority:** P3 - ---- - -## Positive Findings ✅ - -### What's Already Good: - -1. **TypeScript Path Handling** - Uses `path.join()` and `os.homedir()` correctly -2. **Node.js Dependencies** - All cross-platform -3. **JSON Communication** - Platform-agnostic -4. **Python Base** - Python 3 works identically on all platforms - ---- - -## Recommended Fixes - Priority Order - -### **Week 1 - Critical Fixes (P0)** - -1. **Create Platform-Specific Config Templates** - ```bash - config/ - ├── linux-config.example.json - ├── windows-config.example.json - └── macos-config.example.json - ``` - -2. **Fix Python Path Detection** - ```python - # Detect platform and set appropriate paths - import platform - import sys - from pathlib import Path - - if platform.system() == "Windows": - kicad_paths = [Path(sys.executable).parent / "Lib" / "site-packages"] - else: # Linux/Mac - kicad_paths = [Path(sys.executable).parent / "lib" / "python3.X" / "site-packages"] - ``` - -3. **Update Library Search Path Logic** - ```python - def get_kicad_library_paths(): - """Auto-detect KiCAD library paths based on platform""" - system = platform.system() - if system == "Windows": - return ["C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym"] - elif system == "Linux": - return ["/usr/share/kicad/symbols/*.kicad_sym"] - elif system == "Darwin": # macOS - return ["/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym"] - ``` - -### **Week 1 - High Priority (P1)** - -4. **Create requirements.txt** - ```txt - # requirements.txt - kicad-skip>=0.1.0 - Pillow>=9.0.0 - cairosvg>=2.7.0 - colorlog>=6.7.0 - ``` - -5. **Add Linux Installation Documentation** - - Ubuntu/Debian instructions - - Fedora/RHEL instructions - - Arch Linux instructions - -6. **Migrate to pathlib** - - Convert all `os.path` calls to `Path` - - More Pythonic and readable - ---- - -## Testing Checklist - -### Ubuntu 24.04 LTS Testing -- [ ] Install KiCAD 9.0 from official PPA -- [ ] Install Node.js 18+ from NodeSource -- [ ] Clone repository -- [ ] Run `npm install` -- [ ] Run `npm run build` -- [ ] Configure MCP settings (Cline) -- [ ] Test: Create project -- [ ] Test: Place components -- [ ] Test: Export Gerbers - -### Fedora Testing -- [ ] Install KiCAD from Fedora repos -- [ ] Test same workflow - -### Arch Testing -- [ ] Install KiCAD from AUR -- [ ] Test same workflow - ---- - -## Platform Detection Helper - -Create `python/utils/platform_helper.py`: - -```python -"""Platform detection and path utilities""" -import platform -import sys -from pathlib import Path -from typing import List - -class PlatformHelper: - @staticmethod - def is_windows() -> bool: - return platform.system() == "Windows" - - @staticmethod - def is_linux() -> bool: - return platform.system() == "Linux" - - @staticmethod - def is_macos() -> bool: - return platform.system() == "Darwin" - - @staticmethod - def get_kicad_python_path() -> Path: - """Get KiCAD Python dist-packages path""" - if PlatformHelper.is_windows(): - return Path("C:/Program Files/KiCad/9.0/lib/python3/dist-packages") - elif PlatformHelper.is_linux(): - # Common Linux paths - candidates = [ - Path("/usr/lib/kicad/lib/python3/dist-packages"), - Path("/usr/share/kicad/scripting/plugins"), - ] - for path in candidates: - if path.exists(): - return path - elif PlatformHelper.is_macos(): - return Path("/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.X/lib/python3.X/site-packages") - - raise RuntimeError(f"Could not find KiCAD Python path for {platform.system()}") - - @staticmethod - def get_config_dir() -> Path: - """Get appropriate config directory""" - if PlatformHelper.is_windows(): - return Path.home() / ".kicad-mcp" - elif PlatformHelper.is_linux(): - # Use XDG Base Directory specification - xdg_config = os.environ.get("XDG_CONFIG_HOME") - if xdg_config: - return Path(xdg_config) / "kicad-mcp" - return Path.home() / ".config" / "kicad-mcp" - elif PlatformHelper.is_macos(): - return Path.home() / "Library" / "Application Support" / "kicad-mcp" -``` - ---- - -## Success Criteria - -✅ Server starts on Ubuntu 24.04 LTS without errors -✅ Can create and manipulate KiCAD projects -✅ CI/CD pipeline tests on Linux -✅ Documentation includes Linux setup -✅ All tests pass on Linux - ---- - -## Next Steps - -1. Implement P0 fixes (this week) -2. Set up GitHub Actions CI/CD -3. Test on Ubuntu 24.04 LTS -4. Document Linux-specific issues -5. Create installation scripts - ---- - -**Audited by:** Claude Code -**Review Status:** ✅ Complete +# 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 + +--- + +## Executive Summary + +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 +- ❌ Documentation: Windows-only instructions + +--- + +## 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" +``` + +**Impact:** Config file won't work on Linux without manual editing +**Fix:** Create platform-specific config templates +**Priority:** P0 + +--- + +### 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 + "/usr/share/kicad/symbols/*.kicad_sym", # Linux + "/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", # macOS +] +``` + +**Impact:** Works but inefficient (checks all platforms) +**Fix:** Auto-detect platform and use appropriate paths +**Priority:** P0 + +--- + +### 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'), + os.path.dirname(sys.executable) +] +``` + +**Impact:** Paths use Windows convention ('Lib' is 'lib' on Linux) +**Fix:** Platform-specific path detection +**Priority:** P0 + +--- + +## 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 + +**Fix:** Add Linux installation section +**Priority:** P1 + +--- + +### 5. Missing Python Dependencies Documentation + +**File:** None (no requirements.txt) + +**Impact:** Users don't know what Python packages to install +**Fix:** Create `requirements.txt` and `requirements-dev.txt` +**Priority:** P1 + +--- + +### 6. Path Handling Uses os.path Instead of pathlib + +**Files:** All Python files (11 files) + +**Impact:** Code is less readable and more error-prone +**Fix:** Migrate to `pathlib.Path` throughout +**Priority:** P1 + +--- + +## Medium Priority Issues (P2) + +### 7. No Linux-Specific Testing + +**Impact:** Can't verify Linux compatibility +**Fix:** Add GitHub Actions with Ubuntu runner +**Priority:** P2 + +--- + +### 8. Log File Paths May Differ + +**File:** `src/logger.ts:13` + +```typescript +const DEFAULT_LOG_DIR = join(os.homedir(), ".kicad-mcp", "logs"); +``` + +**Impact:** `.kicad-mcp` is okay for Linux, but best practice is `~/.config/kicad-mcp` +**Fix:** Use XDG Base Directory spec on Linux +**Priority:** P2 + +--- + +### 9. No Bash/Shell Scripts for Linux + +**Impact:** Manual setup is harder on Linux +**Fix:** Create `install.sh` and `run.sh` scripts +**Priority:** P2 + +--- + +## Low Priority Issues (P3) + +### 10. TypeScript Build Uses Windows Conventions + +**File:** `package.json` + +**Impact:** Works but could be more Linux-friendly +**Fix:** Add platform-specific build scripts +**Priority:** P3 + +--- + +## Positive Findings ✅ + +### What's Already Good: + +1. **TypeScript Path Handling** - Uses `path.join()` and `os.homedir()` correctly +2. **Node.js Dependencies** - All cross-platform +3. **JSON Communication** - Platform-agnostic +4. **Python Base** - Python 3 works identically on all platforms + +--- + +## Recommended Fixes - Priority Order + +### **Week 1 - Critical Fixes (P0)** + +1. **Create Platform-Specific Config Templates** + + ```bash + config/ + ├── linux-config.example.json + ├── windows-config.example.json + └── macos-config.example.json + ``` + +2. **Fix Python Path Detection** + + ```python + # Detect platform and set appropriate paths + import platform + import sys + from pathlib import Path + + if platform.system() == "Windows": + kicad_paths = [Path(sys.executable).parent / "Lib" / "site-packages"] + else: # Linux/Mac + kicad_paths = [Path(sys.executable).parent / "lib" / "python3.X" / "site-packages"] + ``` + +3. **Update Library Search Path Logic** + ```python + def get_kicad_library_paths(): + """Auto-detect KiCAD library paths based on platform""" + system = platform.system() + if system == "Windows": + return ["C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym"] + elif system == "Linux": + return ["/usr/share/kicad/symbols/*.kicad_sym"] + elif system == "Darwin": # macOS + return ["/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym"] + ``` + +### **Week 1 - High Priority (P1)** + +4. **Create requirements.txt** + + ```txt + # requirements.txt + kicad-skip>=0.1.0 + Pillow>=9.0.0 + cairosvg>=2.7.0 + colorlog>=6.7.0 + ``` + +5. **Add Linux Installation Documentation** + - Ubuntu/Debian instructions + - Fedora/RHEL instructions + - Arch Linux instructions + +6. **Migrate to pathlib** + - Convert all `os.path` calls to `Path` + - More Pythonic and readable + +--- + +## Testing Checklist + +### Ubuntu 24.04 LTS Testing + +- [ ] Install KiCAD 9.0 from official PPA +- [ ] Install Node.js 18+ from NodeSource +- [ ] Clone repository +- [ ] Run `npm install` +- [ ] Run `npm run build` +- [ ] Configure MCP settings (Cline) +- [ ] Test: Create project +- [ ] Test: Place components +- [ ] Test: Export Gerbers + +### Fedora Testing + +- [ ] Install KiCAD from Fedora repos +- [ ] Test same workflow + +### Arch Testing + +- [ ] Install KiCAD from AUR +- [ ] Test same workflow + +--- + +## Platform Detection Helper + +Create `python/utils/platform_helper.py`: + +```python +"""Platform detection and path utilities""" +import platform +import sys +from pathlib import Path +from typing import List + +class PlatformHelper: + @staticmethod + def is_windows() -> bool: + return platform.system() == "Windows" + + @staticmethod + def is_linux() -> bool: + return platform.system() == "Linux" + + @staticmethod + def is_macos() -> bool: + return platform.system() == "Darwin" + + @staticmethod + def get_kicad_python_path() -> Path: + """Get KiCAD Python dist-packages path""" + if PlatformHelper.is_windows(): + return Path("C:/Program Files/KiCad/9.0/lib/python3/dist-packages") + elif PlatformHelper.is_linux(): + # Common Linux paths + candidates = [ + Path("/usr/lib/kicad/lib/python3/dist-packages"), + Path("/usr/share/kicad/scripting/plugins"), + ] + for path in candidates: + if path.exists(): + return path + elif PlatformHelper.is_macos(): + return Path("/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.X/lib/python3.X/site-packages") + + raise RuntimeError(f"Could not find KiCAD Python path for {platform.system()}") + + @staticmethod + def get_config_dir() -> Path: + """Get appropriate config directory""" + if PlatformHelper.is_windows(): + return Path.home() / ".kicad-mcp" + elif PlatformHelper.is_linux(): + # Use XDG Base Directory specification + xdg_config = os.environ.get("XDG_CONFIG_HOME") + if xdg_config: + return Path(xdg_config) / "kicad-mcp" + return Path.home() / ".config" / "kicad-mcp" + elif PlatformHelper.is_macos(): + return Path.home() / "Library" / "Application Support" / "kicad-mcp" +``` + +--- + +## Success Criteria + +✅ Server starts on Ubuntu 24.04 LTS without errors +✅ Can create and manipulate KiCAD projects +✅ CI/CD pipeline tests on Linux +✅ Documentation includes Linux setup +✅ All tests pass on Linux + +--- + +## Next Steps + +1. Implement P0 fixes (this week) +2. Set up GitHub Actions CI/CD +3. Test on Ubuntu 24.04 LTS +4. Document Linux-specific issues +5. Create installation scripts + +--- + +**Audited by:** Claude Code +**Review Status:** ✅ Complete diff --git a/docs/PCB_DESIGN_WORKFLOW.md b/docs/PCB_DESIGN_WORKFLOW.md index 05424b3..8cf8bda 100644 --- a/docs/PCB_DESIGN_WORKFLOW.md +++ b/docs/PCB_DESIGN_WORKFLOW.md @@ -25,6 +25,7 @@ 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) @@ -119,6 +120,7 @@ Align all resistors horizontally. ### Route Traces **Preferred approach -- pad-to-pad routing:** + ``` Route R1 pad 2 to LED1 pad 1 with 0.3mm trace width. ``` @@ -126,6 +128,7 @@ 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. ``` @@ -135,11 +138,13 @@ Route a trace from x=15, y=25 to x=25, y=25 on the front copper layer. ### 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. ``` @@ -149,6 +154,7 @@ Add a GND copper pour on the bottom layer covering the entire board. ### Autorouting For boards with many connections: + ``` Check if Freerouting is available. Autoroute the board using Freerouting. @@ -246,6 +252,7 @@ Suggest alternatives to part C25804. ``` After selecting parts, enrich datasheets: + ``` Enrich datasheets for all components in the schematic. ``` diff --git a/docs/PLATFORM_GUIDE.md b/docs/PLATFORM_GUIDE.md index adfcc8c..11e8dd3 100644 --- a/docs/PLATFORM_GUIDE.md +++ b/docs/PLATFORM_GUIDE.md @@ -1,512 +1,561 @@ -# Platform Guide: Linux vs Windows - -This guide explains the differences between using KiCAD MCP Server on Linux and Windows platforms. - -**Last Updated:** 2025-11-05 - ---- - -## 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 | - ---- - -## Installation Differences - -### 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 -4. Install dependencies manually -5. Build project -6. Configure MCP client -7. Set PYTHONPATH environment variable - -**Typical paths:** -```bash -KiCAD Python: /usr/lib/kicad/lib/python3/dist-packages -Node.js: /usr/bin/node -Python: /usr/bin/python3 -``` - -**Configuration example:** -```json -{ - "mcpServers": { - "kicad": { - "command": "node", - "args": ["/home/username/KiCAD-MCP-Server/dist/index.js"], - "env": { - "PYTHONPATH": "/usr/lib/kicad/lib/python3/dist-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 -4. Run `setup-windows.ps1` script - - Auto-detects KiCAD installation - - Auto-detects Python paths - - Installs all dependencies - - Builds project - - Generates configuration - - 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 -Node.js: C:\Program Files\nodejs\node.exe -``` - -**Configuration example:** -```json -{ - "mcpServers": { - "kicad": { - "command": "node", - "args": ["C:\\Users\\username\\KiCAD-MCP-Server\\dist\\index.js"], - "env": { - "PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages" - } - } - } -} -``` - ---- - -## 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" -``` - -### 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` -- Case-insensitive filesystem (but preserve case) -- 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" -& "C:\Program Files\KiCad\9.0\bin\python.exe" -c "import pcbnew" -``` - -**JSON configuration notes:** -```json -// Wrong - single backslash will cause errors -"args": ["C:\Users\name\project"] - -// Correct - double backslashes -"args": ["C:\\Users\\name\\project"] - -// Also correct - forward slashes work in JSON -"args": ["C:/Users/name/project"] -``` - ---- - -## Python Environment - -### 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 - -# Verify pcbnew module -python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())" - -# Install project dependencies -pip3 install -r requirements.txt - -# Or use virtual environment (recommended) -python3 -m venv venv -source venv/bin/activate -pip install -r requirements.txt -``` - -**PYTHONPATH:** -```bash -# Temporary (current session) -export PYTHONPATH=/usr/lib/kicad/lib/python3/dist-packages - -# Permanent (add to ~/.bashrc or ~/.profile) -echo 'export PYTHONPATH=/usr/lib/kicad/lib/python3/dist-packages' >> ~/.bashrc -``` - -### 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 - -# Verify pcbnew module -& "C:\Program Files\KiCad\9.0\bin\python.exe" -c "import pcbnew; print(pcbnew.GetBuildVersion())" - -# Install project dependencies using KiCAD Python -& "C:\Program Files\KiCad\9.0\bin\python.exe" -m pip install -r requirements.txt -``` - -**PYTHONPATH:** -```powershell -# Temporary (current session) -$env:PYTHONPATH = "C:\Program Files\KiCad\9.0\lib\python3\dist-packages" - -# In MCP configuration (permanent) -{ - "env": { - "PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages" - } -} -``` - ---- - -## Testing and Debugging - -### 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 -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 -``` - -### 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 -``` - ---- - -## Common Issues - -### Linux-Specific Issues - -**1. Permission Errors** -```bash -# Fix file permissions -chmod +x python/kicad_interface.py - -# Fix directory permissions -chmod -R 755 ~/KiCAD-MCP-Server -``` - -**2. PYTHONPATH Not Set** -```bash -# Check current PYTHONPATH -echo $PYTHONPATH - -# Find KiCAD Python path -find /usr -name "pcbnew.py" 2>/dev/null -``` - -**3. KiCAD Not in PATH** -```bash -# Add to PATH temporarily -export PATH=$PATH:/usr/bin - -# Or use full path to KiCAD -/usr/bin/kicad -``` - -**4. Library Dependencies** -```bash -# Install missing system libraries -sudo apt-get install python3-wxgtk4.0 python3-cairo - -# Check library linkage -ldd /usr/lib/kicad/lib/python3/dist-packages/pcbnew.so -``` - -### 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" - -# Use Tab completion in PowerShell to get correct paths -cd C:\Users\[TAB] -``` - -**3. PowerShell Execution Policy** -```powershell -# Check current policy -Get-ExecutionPolicy - -# Set policy to allow scripts (if needed) -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 -``` - ---- - -## 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) -- Named pipes for IPC (future backend) - -**Both platforms perform equivalently for normal PCB design operations.** - ---- - -## Development Workflow - -### Linux Development Environment - -**Typical workflow:** -```bash -# Start development -cd ~/KiCAD-MCP-Server -code . # Open in VSCode - -# Watch mode for TypeScript -npm run watch - -# Run tests in another terminal -npm test - -# Test Python changes -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` -- Log viewing: `tail -f` or `less +F` - -### Windows Development Environment - -**Typical workflow:** -```powershell -# Start development -cd C:\Users\username\KiCAD-MCP-Server -code . # Open in VSCode - -# Watch mode for TypeScript -npm run watch - -# Run tests in another PowerShell window -npm test - -# Test Python changes -& "C:\Program Files\KiCad\9.0\bin\python.exe" python\kicad_interface.py -``` - -**Recommended tools:** -- Terminal: Windows Terminal or PowerShell 7 -- Editor: VSCode with Python and TypeScript extensions -- Process monitoring: Task Manager or Process Explorer -- Log viewing: `Get-Content -Wait` or Notepad++ - ---- - -## Best Practices - -### Linux - -1. **Use virtual environments** for Python dependencies -2. **Set PYTHONPATH** in your shell profile for persistence -3. **Use absolute paths** in MCP configuration -4. **Check file permissions** if encountering access errors -5. **Monitor system logs** with `journalctl` if needed - -### Windows - -1. **Run setup-windows.ps1 first** - saves time troubleshooting -2. **Use KiCAD's bundled Python** - don't install system Python -3. **Use forward slashes** in JSON configs to avoid escaping -4. **Check log file** when debugging - it has detailed errors -5. **Keep paths short** - avoid deeply nested directories - ---- - -## Migration Between Platforms - -### Moving from Linux to Windows - -1. Clone repository on Windows machine -2. Run `setup-windows.ps1` -3. Update config file path separators (/ to \\) -4. Update PYTHONPATH to Windows format -5. No project file changes needed (KiCAD files are cross-platform) - -### Moving from Windows to Linux - -1. Clone repository on Linux machine -2. Follow Linux installation steps -3. Update config file path separators (\\ to /) -4. Update PYTHONPATH to Linux format -5. Set file permissions: `chmod +x python/kicad_interface.py` - -**KiCAD project files (.kicad_pro, .kicad_pcb) are identical across platforms.** - ---- - -## 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 - -### Windows Support -- Check: [README.md](../README.md) Windows installation section -- Read: [WINDOWS_TROUBLESHOOTING.md](./WINDOWS_TROUBLESHOOTING.md) -- Run: `setup-windows.ps1` for automated diagnostics -- Search: GitHub Issues filtered by `windows` label -- Community: Windows users in Discussions - ---- - -## 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 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.** - ---- - -**For platform-specific installation instructions, see:** -- Linux: [README.md - Linux Installation](../README.md#linux-ubuntudebian) -- Windows: [README.md - Windows Installation](../README.md#windows-1011) - -**For troubleshooting:** -- Linux: [KNOWN_ISSUES.md](./KNOWN_ISSUES.md) -- Windows: [WINDOWS_TROUBLESHOOTING.md](./WINDOWS_TROUBLESHOOTING.md) +# Platform Guide: Linux vs Windows + +This guide explains the differences between using KiCAD MCP Server on Linux and Windows platforms. + +**Last Updated:** 2025-11-05 + +--- + +## 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 | + +--- + +## Installation Differences + +### 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 +4. Install dependencies manually +5. Build project +6. Configure MCP client +7. Set PYTHONPATH environment variable + +**Typical paths:** + +```bash +KiCAD Python: /usr/lib/kicad/lib/python3/dist-packages +Node.js: /usr/bin/node +Python: /usr/bin/python3 +``` + +**Configuration example:** + +```json +{ + "mcpServers": { + "kicad": { + "command": "node", + "args": ["/home/username/KiCAD-MCP-Server/dist/index.js"], + "env": { + "PYTHONPATH": "/usr/lib/kicad/lib/python3/dist-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 +4. Run `setup-windows.ps1` script + - Auto-detects KiCAD installation + - Auto-detects Python paths + - Installs all dependencies + - Builds project + - Generates configuration + - 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 +Node.js: C:\Program Files\nodejs\node.exe +``` + +**Configuration example:** + +```json +{ + "mcpServers": { + "kicad": { + "command": "node", + "args": ["C:\\Users\\username\\KiCAD-MCP-Server\\dist\\index.js"], + "env": { + "PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages" + } + } + } +} +``` + +--- + +## 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" +``` + +### 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` +- Case-insensitive filesystem (but preserve case) +- 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" +& "C:\Program Files\KiCad\9.0\bin\python.exe" -c "import pcbnew" +``` + +**JSON configuration notes:** + +```json +// Wrong - single backslash will cause errors +"args": ["C:\Users\name\project"] + +// Correct - double backslashes +"args": ["C:\\Users\\name\\project"] + +// Also correct - forward slashes work in JSON +"args": ["C:/Users/name/project"] +``` + +--- + +## Python Environment + +### 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 + +# Verify pcbnew module +python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())" + +# Install project dependencies +pip3 install -r requirements.txt + +# Or use virtual environment (recommended) +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +**PYTHONPATH:** + +```bash +# Temporary (current session) +export PYTHONPATH=/usr/lib/kicad/lib/python3/dist-packages + +# Permanent (add to ~/.bashrc or ~/.profile) +echo 'export PYTHONPATH=/usr/lib/kicad/lib/python3/dist-packages' >> ~/.bashrc +``` + +### 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 + +# Verify pcbnew module +& "C:\Program Files\KiCad\9.0\bin\python.exe" -c "import pcbnew; print(pcbnew.GetBuildVersion())" + +# Install project dependencies using KiCAD Python +& "C:\Program Files\KiCad\9.0\bin\python.exe" -m pip install -r requirements.txt +``` + +**PYTHONPATH:** + +```powershell +# Temporary (current session) +$env:PYTHONPATH = "C:\Program Files\KiCad\9.0\lib\python3\dist-packages" + +# In MCP configuration (permanent) +{ + "env": { + "PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages" + } +} +``` + +--- + +## Testing and Debugging + +### 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 +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 +``` + +### 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 +``` + +--- + +## Common Issues + +### Linux-Specific Issues + +**1. Permission Errors** + +```bash +# Fix file permissions +chmod +x python/kicad_interface.py + +# Fix directory permissions +chmod -R 755 ~/KiCAD-MCP-Server +``` + +**2. PYTHONPATH Not Set** + +```bash +# Check current PYTHONPATH +echo $PYTHONPATH + +# Find KiCAD Python path +find /usr -name "pcbnew.py" 2>/dev/null +``` + +**3. KiCAD Not in PATH** + +```bash +# Add to PATH temporarily +export PATH=$PATH:/usr/bin + +# Or use full path to KiCAD +/usr/bin/kicad +``` + +**4. Library Dependencies** + +```bash +# Install missing system libraries +sudo apt-get install python3-wxgtk4.0 python3-cairo + +# Check library linkage +ldd /usr/lib/kicad/lib/python3/dist-packages/pcbnew.so +``` + +### 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" + +# Use Tab completion in PowerShell to get correct paths +cd C:\Users\[TAB] +``` + +**3. PowerShell Execution Policy** + +```powershell +# Check current policy +Get-ExecutionPolicy + +# Set policy to allow scripts (if needed) +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 +``` + +--- + +## 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) +- Named pipes for IPC (future backend) + +**Both platforms perform equivalently for normal PCB design operations.** + +--- + +## Development Workflow + +### Linux Development Environment + +**Typical workflow:** + +```bash +# Start development +cd ~/KiCAD-MCP-Server +code . # Open in VSCode + +# Watch mode for TypeScript +npm run watch + +# Run tests in another terminal +npm test + +# Test Python changes +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` +- Log viewing: `tail -f` or `less +F` + +### Windows Development Environment + +**Typical workflow:** + +```powershell +# Start development +cd C:\Users\username\KiCAD-MCP-Server +code . # Open in VSCode + +# Watch mode for TypeScript +npm run watch + +# Run tests in another PowerShell window +npm test + +# Test Python changes +& "C:\Program Files\KiCad\9.0\bin\python.exe" python\kicad_interface.py +``` + +**Recommended tools:** + +- Terminal: Windows Terminal or PowerShell 7 +- Editor: VSCode with Python and TypeScript extensions +- Process monitoring: Task Manager or Process Explorer +- Log viewing: `Get-Content -Wait` or Notepad++ + +--- + +## Best Practices + +### Linux + +1. **Use virtual environments** for Python dependencies +2. **Set PYTHONPATH** in your shell profile for persistence +3. **Use absolute paths** in MCP configuration +4. **Check file permissions** if encountering access errors +5. **Monitor system logs** with `journalctl` if needed + +### Windows + +1. **Run setup-windows.ps1 first** - saves time troubleshooting +2. **Use KiCAD's bundled Python** - don't install system Python +3. **Use forward slashes** in JSON configs to avoid escaping +4. **Check log file** when debugging - it has detailed errors +5. **Keep paths short** - avoid deeply nested directories + +--- + +## Migration Between Platforms + +### Moving from Linux to Windows + +1. Clone repository on Windows machine +2. Run `setup-windows.ps1` +3. Update config file path separators (/ to \\) +4. Update PYTHONPATH to Windows format +5. No project file changes needed (KiCAD files are cross-platform) + +### Moving from Windows to Linux + +1. Clone repository on Linux machine +2. Follow Linux installation steps +3. Update config file path separators (\\ to /) +4. Update PYTHONPATH to Linux format +5. Set file permissions: `chmod +x python/kicad_interface.py` + +**KiCAD project files (.kicad_pro, .kicad_pcb) are identical across platforms.** + +--- + +## 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 + +### Windows Support + +- Check: [README.md](../README.md) Windows installation section +- Read: [WINDOWS_TROUBLESHOOTING.md](./WINDOWS_TROUBLESHOOTING.md) +- Run: `setup-windows.ps1` for automated diagnostics +- Search: GitHub Issues filtered by `windows` label +- Community: Windows users in Discussions + +--- + +## 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 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.** + +--- + +**For platform-specific installation instructions, see:** + +- Linux: [README.md - Linux Installation](../README.md#linux-ubuntudebian) +- Windows: [README.md - Windows Installation](../README.md#windows-1011) + +**For troubleshooting:** + +- Linux: [KNOWN_ISSUES.md](./KNOWN_ISSUES.md) +- Windows: [WINDOWS_TROUBLESHOOTING.md](./WINDOWS_TROUBLESHOOTING.md) diff --git a/docs/REALTIME_WORKFLOW.md b/docs/REALTIME_WORKFLOW.md index 6c103a5..3b7d8f6 100644 --- a/docs/REALTIME_WORKFLOW.md +++ b/docs/REALTIME_WORKFLOW.md @@ -1,416 +1,441 @@ -# Real-Time Collaboration Workflow - -**Status:** ✅ TESTED AND WORKING -**Date:** 2025-11-01 -**Version:** 2.1.0-alpha - -## Overview - -The KiCAD MCP Server enables **real-time paired circuit board design** between Claude Code (via MCP) and a human designer using the KiCAD UI. Both workflows have been tested and confirmed working: - -- ✅ **MCP→UI**: AI places components, human sees them in KiCAD -- ✅ **UI→MCP**: Human edits board, AI reads changes back - -## How It Works - -### Architecture - -The MCP server uses KiCAD's Python API (`pcbnew` module) to read and write `.kicad_pcb` files. The KiCAD UI and MCP both operate on the same file, enabling collaboration through the file system. - -``` -┌─────────────────┐ ┌──────────────────┐ -│ Claude Code │ │ Human Designer │ -│ (via MCP) │ │ (KiCAD UI) │ -└────────┬────────┘ └────────┬─────────┘ - │ │ - │ pcbnew Python API │ KiCAD UI - │ │ - ▼ ▼ - ┌─────────────────────────────────────┐ - │ project.kicad_pcb (file system) │ - └─────────────────────────────────────┘ -``` - -### MCP→UI Workflow (AI to Human) - -**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') - module = pcbnew.FootprintLoad(library_path, 'R_0603_1608Metric') - module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) - board.Add(module) - pcbnew.SaveBoard('project.kicad_pcb', board) - ``` - -2. **Human opens/reloads in KiCAD UI:** - - **Option A (first time):** Open the project in KiCAD - - **Option B (already open):** File → Revert or close and reopen the PCB editor - - Components appear instantly ✅ - -**Example:** -``` -User: "Place a 10k resistor at position 30, 30mm" -Claude: [uses place_component MCP tool] - ✅ Placed R1: 10k at (30.0, 30.0) mm -User: [opens KiCAD UI] - [sees R1 at the specified position] -``` - -### UI→MCP Workflow (Human to AI) - -**Use case:** Human edits board in KiCAD UI, Claude reads changes via MCP - -1. **Human makes changes in KiCAD UI:** - - Move components - - Add new components - - Route traces - - Edit properties - -2. **Human saves the file:** - - Ctrl+S or File → Save - - KiCAD writes changes to `.kicad_pcb` file - -3. **Claude reads changes** via MCP tools: - ```python - # MCP internally uses: - board = pcbnew.LoadBoard('project.kicad_pcb') - footprints = board.GetFootprints() - # Reads all current component positions, values, etc. - ``` - -4. **Claude can see the updates:** - - New component positions - - Added/removed components - - Updated values and references - - New traces and nets - -**Example:** -``` -User: "I moved R1 to a new position, can you see it?" -Claude: [uses get_board_info MCP tool] - Yes! I can see R1 is now at (59.175, 49.0) mm - (previously it was at 30.0, 30.0 mm) -``` - -## Tested Workflows - -### 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 ✅ - -### 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) ✅ - -## Current Capabilities - -### What Works - -1. **Bidirectional sync** (via file save/reload) -2. **Component placement** (MCP→UI) -3. **Component reading** (UI→MCP) -4. **Position/rotation updates** (both directions) -5. **Value/reference changes** (both directions) -6. **Trace routing** (both directions) -7. **Net information** (both directions) -8. **Board properties** (size, layers, design rules) - -### 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 -- `add_copper_pour` - Add copper zones -- `add_mounting_hole` - Add mounting holes -- `add_board_text` - Add text to board - -## Limitations - -### Current Limitations - -1. **Manual Save Required** - - UI changes require manual save (Ctrl+S) - - No automatic file watching (yet) - - Workaround: Always save before asking Claude - -2. **Manual Reload Required** - - MCP changes require reload in UI - - Options: File → Revert, or close/reopen - - Future: Could implement auto-reload trigger - -3. **No Live Sync** - - Changes not visible until save/reload - - Not truly "real-time" (more like "near-time") - - File-based sync has ~1-5 second latency - -4. **No Conflict Detection** - - If both edit simultaneously, last save wins - - No merge conflict resolution - - Best practice: Take turns editing - -5. **No Change Notifications** - - MCP doesn't know when UI saves - - UI doesn't know when MCP saves - - Future: Could add file system watchers - -### Known Issues - -1. **Zone Filling:** Copper pours created by MCP won't be filled (requires UI to fill) -2. **Undo History:** UI undo history lost after MCP changes -3. **DRC Errors:** MCP doesn't run design rule checks automatically - -## Best Practices - -### For AI-Human Collaboration - -1. **Establish Turn-Taking:** - ``` - User: "I'm going to add some components, one sec" - [User edits in UI] - User: "Done, saved the file" - Claude: [reads changes] "I see you added C1 and C2..." - ``` - -2. **Always Save/Reload:** - - Human: Save after every change (Ctrl+S) - - Human: Reload after Claude makes changes - - Claude: Always read fresh before making decisions - -3. **Communicate Changes:** - ``` - Claude: "I'm placing R1-R4 now..." - [MCP places components] - Claude: "Done! Reload the board to see them" - User: [File → Revert] - ``` - -4. **Use Descriptive References:** - - Good: R1, R2, C1, C2 (sequential) - - Bad: R_temp, R_test (unclear) - -### Workflow Patterns - -**Pattern 1: AI Does Layout, Human Reviews** -``` -1. Claude places all components via MCP -2. Claude routes critical traces via MCP -3. Human opens in KiCAD UI -4. Human fine-tunes positions -5. Human completes routing -6. Saves → Claude reads final result -``` - -**Pattern 2: Human Sketches, AI Refines** -``` -1. Human places major components in UI -2. Saves → Claude reads layout -3. Claude suggests improvements -4. Claude places remaining components via MCP -5. Human reloads and reviews -6. Iterate until satisfied -``` - -**Pattern 3: Pair Programming Style** -``` -User: "Place a 10k pull-up resistor on pin 3" -Claude: [places R1 at calculated position] - "Done! Check position (45, 20) mm" -User: [reloads] "Looks good, now add the LED" -Claude: [places D1] -[Continue back-and-forth] -``` - -## Future Enhancements - -### Planned Improvements - -1. **File System Watchers** (Week 4+) - - Auto-detect when UI saves file - - Auto-reload UI when MCP saves (via IPC) - - Near-instant sync (<100ms) - -2. **IPC Backend** (Week 3) - - Direct communication with KiCAD process - - Live sync without file save/reload - - True real-time collaboration - -3. **Change Notifications** - - MCP sends notification when it modifies board - - UI shows toast: "Claude added 4 components" - - Automatic reload option - -4. **Conflict Detection** - - Detect when both edited same component - - Show diff/merge UI - - Allow choosing which changes to keep - -5. **Collaborative Cursor** - - Show Claude's "cursor" in UI - - Highlight component being placed - - Visual feedback for AI actions - -### Long-Term Vision - -**Fully Real-Time Collaboration:** -- Both AI and human see changes instantly -- No manual save/reload required -- Conflict detection and resolution -- Visual indicators for who's editing what -- Chat/comment system for design discussion - -**Example Future Workflow:** -``` -[Claude and human both have board open] -Claude: [starts placing R1] - [R1 appears in UI with "Claude is placing..." indicator] -User: [sees R1 appear in real-time] - [moves D1 to better position] - [Claude sees D1 move instantly] -Claude: "Good position for D1! I'll route them now" - [traces appear as Claude creates them] -``` - -## Technical Details - -### File Format - -KiCAD uses S-expression format (`.kicad_pcb`): -```lisp -(kicad_pcb (version 20240108) (generator "pcbnew") - (footprint "Resistor_SMD:R_0603_1608Metric" - (layer "F.Cu") - (at 30.0 30.0 0) - (property "Reference" "R1") - (property "Value" "10k") - ... - ) -) -``` - -### 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 -4. Latency: ~50-100ms (automatic) - -### Python API Used - -```python -import pcbnew - -# Load board -board = pcbnew.LoadBoard('project.kicad_pcb') - -# Read components -for fp in board.GetFootprints(): - ref = fp.Reference().GetText() - pos = fp.GetPosition() - x_mm = pos.x / 1_000_000.0 - y_mm = pos.y / 1_000_000.0 - -# Modify board -module = pcbnew.FootprintLoad(lib_path, 'R_0603') -module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) -board.Add(module) - -# Save changes -pcbnew.SaveBoard('project.kicad_pcb', board) -``` - -## Troubleshooting - -### "I don't see MCP changes in KiCAD UI" - -**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 - -### "MCP doesn't see my UI changes" - -**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 - -### "Changes disappeared after reload" - -**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 - -### "Components appear in wrong positions" - -**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 - -## Conclusion - -**The real-time collaboration workflow is WORKING and TESTED! ✅** - -The KiCAD MCP Server successfully enables paired circuit board design between AI (Claude) and human designers. While it requires manual save/reload steps, both MCP→UI and UI→MCP workflows function correctly. - -**Current State:** "Near-real-time" collaboration (1-5 second latency) - -**Future State:** True real-time with IPC backend (<100ms latency) - -**Mission Accomplished:** Real-time paired circuit board design is operational and ready for use! 🎉 - ---- - -## Related Documentation - -- [LIBRARY_INTEGRATION.md](./LIBRARY_INTEGRATION.md) - Component library system -- [STATUS_SUMMARY.md](./STATUS_SUMMARY.md) - Current implementation status -- [ROADMAP.md](./ROADMAP.md) - Future development plans -- [API.md](./API.md) - Full MCP API reference - -## Changelog - -**2025-11-01 - v2.1.0-alpha** -- ✅ Tested MCP→UI workflow (placing components via MCP, viewing in UI) -- ✅ Tested UI→MCP workflow (editing in UI, reading via MCP) -- ✅ Documented best practices and limitations -- ✅ Confirmed real-time collaboration mission is met +# Real-Time Collaboration Workflow + +**Status:** ✅ TESTED AND WORKING +**Date:** 2025-11-01 +**Version:** 2.1.0-alpha + +## Overview + +The KiCAD MCP Server enables **real-time paired circuit board design** between Claude Code (via MCP) and a human designer using the KiCAD UI. Both workflows have been tested and confirmed working: + +- ✅ **MCP→UI**: AI places components, human sees them in KiCAD +- ✅ **UI→MCP**: Human edits board, AI reads changes back + +## How It Works + +### Architecture + +The MCP server uses KiCAD's Python API (`pcbnew` module) to read and write `.kicad_pcb` files. The KiCAD UI and MCP both operate on the same file, enabling collaboration through the file system. + +``` +┌─────────────────┐ ┌──────────────────┐ +│ Claude Code │ │ Human Designer │ +│ (via MCP) │ │ (KiCAD UI) │ +└────────┬────────┘ └────────┬─────────┘ + │ │ + │ pcbnew Python API │ KiCAD UI + │ │ + ▼ ▼ + ┌─────────────────────────────────────┐ + │ project.kicad_pcb (file system) │ + └─────────────────────────────────────┘ +``` + +### MCP→UI Workflow (AI to Human) + +**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') + module = pcbnew.FootprintLoad(library_path, 'R_0603_1608Metric') + module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) + board.Add(module) + pcbnew.SaveBoard('project.kicad_pcb', board) + ``` + +2. **Human opens/reloads in KiCAD UI:** + - **Option A (first time):** Open the project in KiCAD + - **Option B (already open):** File → Revert or close and reopen the PCB editor + - Components appear instantly ✅ + +**Example:** + +``` +User: "Place a 10k resistor at position 30, 30mm" +Claude: [uses place_component MCP tool] + ✅ Placed R1: 10k at (30.0, 30.0) mm +User: [opens KiCAD UI] + [sees R1 at the specified position] +``` + +### UI→MCP Workflow (Human to AI) + +**Use case:** Human edits board in KiCAD UI, Claude reads changes via MCP + +1. **Human makes changes in KiCAD UI:** + - Move components + - Add new components + - Route traces + - Edit properties + +2. **Human saves the file:** + - Ctrl+S or File → Save + - KiCAD writes changes to `.kicad_pcb` file + +3. **Claude reads changes** via MCP tools: + + ```python + # MCP internally uses: + board = pcbnew.LoadBoard('project.kicad_pcb') + footprints = board.GetFootprints() + # Reads all current component positions, values, etc. + ``` + +4. **Claude can see the updates:** + - New component positions + - Added/removed components + - Updated values and references + - New traces and nets + +**Example:** + +``` +User: "I moved R1 to a new position, can you see it?" +Claude: [uses get_board_info MCP tool] + Yes! I can see R1 is now at (59.175, 49.0) mm + (previously it was at 30.0, 30.0 mm) +``` + +## Tested Workflows + +### 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 ✅ + +### 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) ✅ + +## Current Capabilities + +### What Works + +1. **Bidirectional sync** (via file save/reload) +2. **Component placement** (MCP→UI) +3. **Component reading** (UI→MCP) +4. **Position/rotation updates** (both directions) +5. **Value/reference changes** (both directions) +6. **Trace routing** (both directions) +7. **Net information** (both directions) +8. **Board properties** (size, layers, design rules) + +### 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 +- `add_copper_pour` - Add copper zones +- `add_mounting_hole` - Add mounting holes +- `add_board_text` - Add text to board + +## Limitations + +### Current Limitations + +1. **Manual Save Required** + - UI changes require manual save (Ctrl+S) + - No automatic file watching (yet) + - Workaround: Always save before asking Claude + +2. **Manual Reload Required** + - MCP changes require reload in UI + - Options: File → Revert, or close/reopen + - Future: Could implement auto-reload trigger + +3. **No Live Sync** + - Changes not visible until save/reload + - Not truly "real-time" (more like "near-time") + - File-based sync has ~1-5 second latency + +4. **No Conflict Detection** + - If both edit simultaneously, last save wins + - No merge conflict resolution + - Best practice: Take turns editing + +5. **No Change Notifications** + - MCP doesn't know when UI saves + - UI doesn't know when MCP saves + - Future: Could add file system watchers + +### Known Issues + +1. **Zone Filling:** Copper pours created by MCP won't be filled (requires UI to fill) +2. **Undo History:** UI undo history lost after MCP changes +3. **DRC Errors:** MCP doesn't run design rule checks automatically + +## Best Practices + +### For AI-Human Collaboration + +1. **Establish Turn-Taking:** + + ``` + User: "I'm going to add some components, one sec" + [User edits in UI] + User: "Done, saved the file" + Claude: [reads changes] "I see you added C1 and C2..." + ``` + +2. **Always Save/Reload:** + - Human: Save after every change (Ctrl+S) + - Human: Reload after Claude makes changes + - Claude: Always read fresh before making decisions + +3. **Communicate Changes:** + + ``` + Claude: "I'm placing R1-R4 now..." + [MCP places components] + Claude: "Done! Reload the board to see them" + User: [File → Revert] + ``` + +4. **Use Descriptive References:** + - Good: R1, R2, C1, C2 (sequential) + - Bad: R_temp, R_test (unclear) + +### Workflow Patterns + +**Pattern 1: AI Does Layout, Human Reviews** + +``` +1. Claude places all components via MCP +2. Claude routes critical traces via MCP +3. Human opens in KiCAD UI +4. Human fine-tunes positions +5. Human completes routing +6. Saves → Claude reads final result +``` + +**Pattern 2: Human Sketches, AI Refines** + +``` +1. Human places major components in UI +2. Saves → Claude reads layout +3. Claude suggests improvements +4. Claude places remaining components via MCP +5. Human reloads and reviews +6. Iterate until satisfied +``` + +**Pattern 3: Pair Programming Style** + +``` +User: "Place a 10k pull-up resistor on pin 3" +Claude: [places R1 at calculated position] + "Done! Check position (45, 20) mm" +User: [reloads] "Looks good, now add the LED" +Claude: [places D1] +[Continue back-and-forth] +``` + +## Future Enhancements + +### Planned Improvements + +1. **File System Watchers** (Week 4+) + - Auto-detect when UI saves file + - Auto-reload UI when MCP saves (via IPC) + - Near-instant sync (<100ms) + +2. **IPC Backend** (Week 3) + - Direct communication with KiCAD process + - Live sync without file save/reload + - True real-time collaboration + +3. **Change Notifications** + - MCP sends notification when it modifies board + - UI shows toast: "Claude added 4 components" + - Automatic reload option + +4. **Conflict Detection** + - Detect when both edited same component + - Show diff/merge UI + - Allow choosing which changes to keep + +5. **Collaborative Cursor** + - Show Claude's "cursor" in UI + - Highlight component being placed + - Visual feedback for AI actions + +### Long-Term Vision + +**Fully Real-Time Collaboration:** + +- Both AI and human see changes instantly +- No manual save/reload required +- Conflict detection and resolution +- Visual indicators for who's editing what +- Chat/comment system for design discussion + +**Example Future Workflow:** + +``` +[Claude and human both have board open] +Claude: [starts placing R1] + [R1 appears in UI with "Claude is placing..." indicator] +User: [sees R1 appear in real-time] + [moves D1 to better position] + [Claude sees D1 move instantly] +Claude: "Good position for D1! I'll route them now" + [traces appear as Claude creates them] +``` + +## Technical Details + +### File Format + +KiCAD uses S-expression format (`.kicad_pcb`): + +```lisp +(kicad_pcb (version 20240108) (generator "pcbnew") + (footprint "Resistor_SMD:R_0603_1608Metric" + (layer "F.Cu") + (at 30.0 30.0 0) + (property "Reference" "R1") + (property "Value" "10k") + ... + ) +) +``` + +### 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 +4. Latency: ~50-100ms (automatic) + +### Python API Used + +```python +import pcbnew + +# Load board +board = pcbnew.LoadBoard('project.kicad_pcb') + +# Read components +for fp in board.GetFootprints(): + ref = fp.Reference().GetText() + pos = fp.GetPosition() + x_mm = pos.x / 1_000_000.0 + y_mm = pos.y / 1_000_000.0 + +# Modify board +module = pcbnew.FootprintLoad(lib_path, 'R_0603') +module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) +board.Add(module) + +# Save changes +pcbnew.SaveBoard('project.kicad_pcb', board) +``` + +## Troubleshooting + +### "I don't see MCP changes in KiCAD UI" + +**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 + +### "MCP doesn't see my UI changes" + +**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 + +### "Changes disappeared after reload" + +**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 + +### "Components appear in wrong positions" + +**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 + +## Conclusion + +**The real-time collaboration workflow is WORKING and TESTED! ✅** + +The KiCAD MCP Server successfully enables paired circuit board design between AI (Claude) and human designers. While it requires manual save/reload steps, both MCP→UI and UI→MCP workflows function correctly. + +**Current State:** "Near-real-time" collaboration (1-5 second latency) + +**Future State:** True real-time with IPC backend (<100ms latency) + +**Mission Accomplished:** Real-time paired circuit board design is operational and ready for use! 🎉 + +--- + +## Related Documentation + +- [LIBRARY_INTEGRATION.md](./LIBRARY_INTEGRATION.md) - Component library system +- [STATUS_SUMMARY.md](./STATUS_SUMMARY.md) - Current implementation status +- [ROADMAP.md](./ROADMAP.md) - Future development plans +- [API.md](./API.md) - Full MCP API reference + +## Changelog + +**2025-11-01 - v2.1.0-alpha** + +- ✅ Tested MCP→UI workflow (placing components via MCP, viewing in UI) +- ✅ Tested UI→MCP workflow (editing in UI, reading via MCP) +- ✅ Documented best practices and limitations +- ✅ Confirmed real-time collaboration mission is met diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a1f6347..14643e7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -10,6 +10,7 @@ ## Completed Milestones ### v1.0.0 - Core Foundation (October 2025) + - [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) @@ -21,12 +22,14 @@ - [x] UI auto-launch and detection ### v2.0.0-alpha - Router and IPC (November-December 2025) + - [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 ### v2.1.0-alpha - Schematics and JLCPCB (January 2026) + - [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 @@ -36,6 +39,7 @@ - [x] Local symbol library search (contributor: @l3wi) ### v2.2.0 through v2.2.3 - Routing, Creators, Autorouting (February-March 2026) + - [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 @@ -57,12 +61,14 @@ ## Current Focus: v2.3+ ### Documentation Overhaul (In Progress) + - [ ] Per-feature documentation for all 122 tools - [ ] Architecture guide for contributors - [ ] End-to-end PCB design workflow guide - [ ] Documentation index ### Quality and Stability + - [ ] Expand test coverage across all tool categories - [ ] Performance profiling for large boards - [ ] Update package.json version to match CHANGELOG @@ -72,24 +78,28 @@ ## Planned Features ### Supplier Integration + - [ ] Digikey API integration - [ ] Mouser API integration - [ ] Smart BOM management with real-time pricing - [ ] Cost optimization across suppliers ### Design Patterns and Templates + - [ ] 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 ### Advanced Capabilities + - [ ] Panelization support - [ ] Multi-board project management - [ ] High-speed design helpers (length matching, via stitching) - [ ] SPICE simulation integration ### Community and Education + - [ ] Example project gallery with tutorials - [ ] Video walkthrough series - [ ] Interactive beginner tutorials @@ -111,4 +121,4 @@ Check [CONTRIBUTING.md](../CONTRIBUTING.md) for details. --- -*Maintained by: KiCAD MCP Team and community contributors* +_Maintained by: KiCAD MCP Team and community contributors_ diff --git a/docs/ROUTER_ARCHITECTURE.md b/docs/ROUTER_ARCHITECTURE.md index ba98dac..e81e4e8 100644 --- a/docs/ROUTER_ARCHITECTURE.md +++ b/docs/ROUTER_ARCHITECTURE.md @@ -1,353 +1,383 @@ -# Router Architecture Design - -## Overview - -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 - -``` -┌─────────────────────────────────────────────────────────────┐ -│ MCP Client (Claude) │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ KiCAD MCP Server │ -│ ┌─────────────────────────────────────────────────────────┐│ -│ │ DIRECT TOOLS (Always Visible - 12) ││ -│ │ • 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 (Discovery - 4) ││ -│ │ • list_tool_categories • get_category_tools ││ -│ │ • execute_tool • search_tools ││ -│ └─────────────────────────────────────────────────────────┘│ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────┐│ -│ │ ROUTED TOOLS (Hidden - 110+) ││ -│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ││ -│ │ │ board │ │component │ │ export │ │ drc │ ││ -│ │ │ tools │ │ tools │ │ tools │ │ tools │ ││ -│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ ││ -│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ││ -│ │ │schematic │ │ library │ │ routing │ │footprint │ ││ -│ │ │ tools │ │ tools │ │ tools │ │ tools │ ││ -│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ ││ -│ └─────────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────────┘ -``` - -## Tool Categories - -### Direct Tools (12 tools - always visible) - -These cover the primary workflow (80%+ of use cases): - -1. **Project Lifecycle** (4): - - `create_project` - Create new KiCAD project - - `open_project` - Open existing project - - `save_project` - Save current project - - `get_project_info` - Get project information - -2. **Core PCB Operations** (6): - - `place_component` - Place component on board - - `move_component` - Move component to new position - - `add_net` - Create a new net - - `route_trace` - Route trace between points - - `get_board_info` - Get board information - - `set_board_size` - Set board dimensions - -3. **Board Setup** (1): - - `add_board_outline` - Add board outline - -4. **UI Management** (1): - - `check_kicad_ui` - Check if KiCAD UI is running - -### 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 -- `add_mounting_hole` - Add mounting hole -- `add_board_text` - Add text to board -- `add_zone` - Add copper zone/pour -- `get_board_extents` - Get board boundaries -- `get_board_2d_view` - Get 2D visualization -- `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 -- `find_component` - Find component by reference/value -- `get_component_properties` - Get component properties -- `add_component_annotation` - Add component annotation -- `group_components` - Group components together -- `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 -- `export_3d` - Export 3D model (STEP/STL/VRML/OBJ) -- `export_bom` - Export bill of materials -- `export_netlist` - Export netlist -- `export_position_file` - Export component positions -- `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 -- `add_net_class` - Add net class -- `assign_net_to_class` - Assign net to class -- `set_layer_constraints` - Set layer constraints -- `check_clearance` - Check clearance between items -- `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 -- `add_schematic_connection` - Connect component pins -- `add_schematic_net_label` - Add net label -- `connect_to_net` - Connect pin to net -- `get_net_connections` - Get net connections -- `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 - -**Note:** `add_net` and `route_trace` are direct tools as they're core operations. - -## Router Tools - -### 1. `list_tool_categories` -**Description:** List all available tool categories with descriptions and tool counts. - -**Parameters:** None - -**Returns:** -```json -{ - "total_categories": 7, - "total_tools": 47, - "categories": [ - { - "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", - "description": "File export for fabrication and documentation: Gerber, PDF, BOM, 3D models", - "tools": [ - { - "name": "export_gerber", - "description": "Export Gerber files for PCB fabrication", - "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", - "count": 8, - "matches": [ - { - "category": "export", - "tool": "export_gerber", - "description": "Export Gerber files for PCB fabrication" - }, - // ... more matches - ] -} -``` - -## Implementation Files - -### New Files to Create - -1. **`src/tools/registry.ts`** - - Tool category definitions - - Tool metadata storage - - Lookup maps (by name, by category) - - Search functionality - -2. **`src/tools/router.ts`** - - Router tool implementations - - `list_tool_categories` handler - - `get_category_tools` handler - - `execute_tool` handler - - `search_tools` handler - -3. **`src/tools/direct.ts`** - - Export direct tool definitions - - Keep existing tool implementations but organized - -### Modified Files - -1. **`src/server.ts`** or **`src/kicad-server.ts`** - - Register only direct tools + router tools - - Remove registration of routed tools - - Tools still callable via `execute_tool` - -## 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 - -## Benefits - -1. **Context Efficiency**: 70% reduction in tokens (~28K saved) -2. **Better Organization**: Tools grouped by function -3. **Discoverability**: Easy to find the right tool -4. **Scalability**: Can add unlimited tools without bloating context -5. **Backwards Compatible**: Existing Python commands still work - -## Usage Examples - -### Example 1: User Wants to Export Gerbers - -``` -User: "Export gerbers for this board" - -Claude's workflow: -1. Sees "export" keyword -2. Calls search_tools({ query: "gerber" }) - → Returns: { category: "export", tool: "export_gerber", ... } -3. Calls execute_tool({ - tool_name: "export_gerber", - params: { outputDir: "./gerbers" } - }) - → Returns: { success: true, files: [...] } - -Claude: "I've exported the Gerber files to ./gerbers/" -``` - -### Example 2: User Wants to Place Component - -``` -User: "Add a 0805 resistor at position 10,20" - -Claude's workflow: -1. Sees place_component in direct tools -2. Calls place_component({ - componentId: "R_0805", - position: { x: 10, y: 20, unit: "mm" } - }) - → Returns: { success: true, reference: "R1" } - -Claude: "Added R1 (0805 resistor) at position (10, 20) mm" -``` - -### Example 3: User Wants Unknown Operation - -``` -User: "Check the board for design rule violations" - -Claude's workflow: -1. Uncertain which tool to use -2. Calls search_tools({ query: "design rule violations" }) - → Returns: { category: "drc", tool: "run_drc", ...} -3. Calls get_category_tools({ category: "drc" }) - → Returns full DRC category tools with parameters -4. Calls execute_tool({ - tool_name: "run_drc", - params: {} - }) - → Returns: DRC results - -Claude: "I ran the design rule check. Found 3 violations: ..." -``` - -## Success Metrics - -- ✅ Token usage: ~12K (vs 40K before) -- ✅ Tool discovery time: <2 calls (search → execute) -- ✅ User experience: Unchanged (seamless) -- ✅ Maintainability: Improved (organized categories) -- ✅ Scalability: Can add 100+ more tools easily +# Router Architecture Design + +## Overview + +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 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ MCP Client (Claude) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ KiCAD MCP Server │ +│ ┌─────────────────────────────────────────────────────────┐│ +│ │ DIRECT TOOLS (Always Visible - 12) ││ +│ │ • 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 (Discovery - 4) ││ +│ │ • list_tool_categories • get_category_tools ││ +│ │ • execute_tool • search_tools ││ +│ └─────────────────────────────────────────────────────────┘│ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐│ +│ │ ROUTED TOOLS (Hidden - 110+) ││ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ││ +│ │ │ board │ │component │ │ export │ │ drc │ ││ +│ │ │ tools │ │ tools │ │ tools │ │ tools │ ││ +│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ ││ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ││ +│ │ │schematic │ │ library │ │ routing │ │footprint │ ││ +│ │ │ tools │ │ tools │ │ tools │ │ tools │ ││ +│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ ││ +│ └─────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────┘ +``` + +## Tool Categories + +### Direct Tools (12 tools - always visible) + +These cover the primary workflow (80%+ of use cases): + +1. **Project Lifecycle** (4): + - `create_project` - Create new KiCAD project + - `open_project` - Open existing project + - `save_project` - Save current project + - `get_project_info` - Get project information + +2. **Core PCB Operations** (6): + - `place_component` - Place component on board + - `move_component` - Move component to new position + - `add_net` - Create a new net + - `route_trace` - Route trace between points + - `get_board_info` - Get board information + - `set_board_size` - Set board dimensions + +3. **Board Setup** (1): + - `add_board_outline` - Add board outline + +4. **UI Management** (1): + - `check_kicad_ui` - Check if KiCAD UI is running + +### 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 +- `add_mounting_hole` - Add mounting hole +- `add_board_text` - Add text to board +- `add_zone` - Add copper zone/pour +- `get_board_extents` - Get board boundaries +- `get_board_2d_view` - Get 2D visualization +- `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 +- `find_component` - Find component by reference/value +- `get_component_properties` - Get component properties +- `add_component_annotation` - Add component annotation +- `group_components` - Group components together +- `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 +- `export_3d` - Export 3D model (STEP/STL/VRML/OBJ) +- `export_bom` - Export bill of materials +- `export_netlist` - Export netlist +- `export_position_file` - Export component positions +- `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 +- `add_net_class` - Add net class +- `assign_net_to_class` - Assign net to class +- `set_layer_constraints` - Set layer constraints +- `check_clearance` - Check clearance between items +- `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 +- `add_schematic_connection` - Connect component pins +- `add_schematic_net_label` - Add net label +- `connect_to_net` - Connect pin to net +- `get_net_connections` - Get net connections +- `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 + +**Note:** `add_net` and `route_trace` are direct tools as they're core operations. + +## Router Tools + +### 1. `list_tool_categories` + +**Description:** List all available tool categories with descriptions and tool counts. + +**Parameters:** None + +**Returns:** + +```json +{ + "total_categories": 7, + "total_tools": 47, + "categories": [ + { + "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", + "description": "File export for fabrication and documentation: Gerber, PDF, BOM, 3D models", + "tools": [ + { + "name": "export_gerber", + "description": "Export Gerber files for PCB fabrication", + "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", + "count": 8, + "matches": [ + { + "category": "export", + "tool": "export_gerber", + "description": "Export Gerber files for PCB fabrication" + } + // ... more matches + ] +} +``` + +## Implementation Files + +### New Files to Create + +1. **`src/tools/registry.ts`** + - Tool category definitions + - Tool metadata storage + - Lookup maps (by name, by category) + - Search functionality + +2. **`src/tools/router.ts`** + - Router tool implementations + - `list_tool_categories` handler + - `get_category_tools` handler + - `execute_tool` handler + - `search_tools` handler + +3. **`src/tools/direct.ts`** + - Export direct tool definitions + - Keep existing tool implementations but organized + +### Modified Files + +1. **`src/server.ts`** or **`src/kicad-server.ts`** + - Register only direct tools + router tools + - Remove registration of routed tools + - Tools still callable via `execute_tool` + +## 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 + +## Benefits + +1. **Context Efficiency**: 70% reduction in tokens (~28K saved) +2. **Better Organization**: Tools grouped by function +3. **Discoverability**: Easy to find the right tool +4. **Scalability**: Can add unlimited tools without bloating context +5. **Backwards Compatible**: Existing Python commands still work + +## Usage Examples + +### Example 1: User Wants to Export Gerbers + +``` +User: "Export gerbers for this board" + +Claude's workflow: +1. Sees "export" keyword +2. Calls search_tools({ query: "gerber" }) + → Returns: { category: "export", tool: "export_gerber", ... } +3. Calls execute_tool({ + tool_name: "export_gerber", + params: { outputDir: "./gerbers" } + }) + → Returns: { success: true, files: [...] } + +Claude: "I've exported the Gerber files to ./gerbers/" +``` + +### Example 2: User Wants to Place Component + +``` +User: "Add a 0805 resistor at position 10,20" + +Claude's workflow: +1. Sees place_component in direct tools +2. Calls place_component({ + componentId: "R_0805", + position: { x: 10, y: 20, unit: "mm" } + }) + → Returns: { success: true, reference: "R1" } + +Claude: "Added R1 (0805 resistor) at position (10, 20) mm" +``` + +### Example 3: User Wants Unknown Operation + +``` +User: "Check the board for design rule violations" + +Claude's workflow: +1. Uncertain which tool to use +2. Calls search_tools({ query: "design rule violations" }) + → Returns: { category: "drc", tool: "run_drc", ...} +3. Calls get_category_tools({ category: "drc" }) + → Returns full DRC category tools with parameters +4. Calls execute_tool({ + tool_name: "run_drc", + params: {} + }) + → Returns: DRC results + +Claude: "I ran the design rule check. Found 3 violations: ..." +``` + +## Success Metrics + +- ✅ Token usage: ~12K (vs 40K before) +- ✅ Tool discovery time: <2 calls (search → execute) +- ✅ User experience: Unchanged (seamless) +- ✅ Maintainability: Improved (organized categories) +- ✅ Scalability: Can add 100+ more tools easily diff --git a/docs/ROUTER_QUICK_START.md b/docs/ROUTER_QUICK_START.md index db5e6b1..5f7ddf5 100644 --- a/docs/ROUTER_QUICK_START.md +++ b/docs/ROUTER_QUICK_START.md @@ -1,165 +1,197 @@ -# Router Quick Start Guide - -## What is the Router? - -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 -4. Return the results - -**You don't need to change how you interact with Claude** - the discovery happens automatically! - -## Tool 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) - -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 -``` - -## Usage Examples - -### Natural Interaction (Recommended) -Just ask Claude what you want - it handles discovery automatically: - -``` -"Export gerber files to ./output" -"Add a mounting hole at x=10, y=10" -"Run a design rule check" -"Create a copper pour on the ground layer" -``` - -### Manual Discovery (Optional) -You can also browse tools explicitly: - -``` -"List all tool categories" -"What export tools are available?" -"Search for DRC tools" -``` - -## Benefits - -1. **Reduced Context Usage**: 70% less AI context consumed per conversation -2. **Organized Tools**: Logical categorization makes tools easy to find -3. **Seamless Experience**: Works transparently - no changes to how you interact -4. **Extensible**: Easy to add new tools and categories -5. **Backwards Compatible**: All existing tools still work - -## Technical Details - -- **Registry**: `src/tools/registry.ts` - Tool categorization and lookup -- **Router**: `src/tools/router.ts` - Discovery and execution implementation -- **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 - -## Token Savings - -**Before Router:** -- 122 tools × ~700 tokens each = ~85K tokens per conversation - -**After Router (Current):** -- 12 direct tools + 4 router tools = 16 tools visible -- Routed tools discovered on-demand -- ~12-15K tokens per conversation -- **~80% reduction** in context usage - -The router pattern is complete and functional, providing efficient tool discovery while maintaining full access to all 122+ tools. +# Router Quick Start Guide + +## What is the Router? + +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 +4. Return the results + +**You don't need to change how you interact with Claude** - the discovery happens automatically! + +## Tool 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) + +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 +``` + +## Usage Examples + +### Natural Interaction (Recommended) + +Just ask Claude what you want - it handles discovery automatically: + +``` +"Export gerber files to ./output" +"Add a mounting hole at x=10, y=10" +"Run a design rule check" +"Create a copper pour on the ground layer" +``` + +### Manual Discovery (Optional) + +You can also browse tools explicitly: + +``` +"List all tool categories" +"What export tools are available?" +"Search for DRC tools" +``` + +## Benefits + +1. **Reduced Context Usage**: 70% less AI context consumed per conversation +2. **Organized Tools**: Logical categorization makes tools easy to find +3. **Seamless Experience**: Works transparently - no changes to how you interact +4. **Extensible**: Easy to add new tools and categories +5. **Backwards Compatible**: All existing tools still work + +## Technical Details + +- **Registry**: `src/tools/registry.ts` - Tool categorization and lookup +- **Router**: `src/tools/router.ts` - Discovery and execution implementation +- **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 + +## Token Savings + +**Before Router:** + +- 122 tools × ~700 tokens each = ~85K tokens per conversation + +**After Router (Current):** + +- 12 direct tools + 4 router tools = 16 tools visible +- Routed tools discovered on-demand +- ~12-15K tokens per conversation +- **~80% reduction** in context usage + +The router pattern is complete and functional, providing efficient tool discovery while maintaining full access to all 122+ tools. diff --git a/docs/ROUTING_TOOLS_REFERENCE.md b/docs/ROUTING_TOOLS_REFERENCE.md index b332295..3b99ea5 100644 --- a/docs/ROUTING_TOOLS_REFERENCE.md +++ b/docs/ROUTING_TOOLS_REFERENCE.md @@ -12,17 +12,19 @@ Create a new net on the PCB. **Parameters:** -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| name | string | Yes | Net name | -| netClass | string | No | Net class name | +| 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", @@ -38,25 +40,27 @@ 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 | +| 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"}, + "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" @@ -71,17 +75,18 @@ PREFERRED tool for pad-to-pad routing. Looks up pad positions automatically, det **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) | +| 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 @@ -90,6 +95,7 @@ PREFERRED tool for pad-to-pad routing. Looks up pad positions automatically, det - Via is placed at the start pad's X coordinate to avoid stacking issues with back-to-back mirrored connectors **Example:** + ```json { "fromRef": "U2", @@ -110,22 +116,24 @@ 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" | +| 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"}, + "position": { "x": 110.0, "y": 50.0, "unit": "mm" }, "net": "GND", "viaType": "through" } @@ -141,27 +149,29 @@ 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 | +| 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"}, + "positivePad": { "reference": "J1", "pad": "2" }, + "negativePad": { "reference": "J1", "pad": "3" }, "layer": "F.Cu", "width": 0.2, "gap": 0.2, @@ -178,14 +188,15 @@ Copy routing pattern (traces and vias) from a group of source components to a ma **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) | +| 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.) @@ -194,6 +205,7 @@ Copy routing pattern (traces and vias) from a group of source components to a ma - Original trace widths are preserved unless traceWidth override is specified **Example:** + ```json { "sourceRefs": ["U1", "R1", "C1"], @@ -212,18 +224,20 @@ 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" | +| 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, @@ -239,21 +253,23 @@ 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 | +| 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", @@ -274,21 +290,23 @@ Delete traces from the PCB. Can delete by UUID, position, or bulk-delete all tra **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 | +| 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", @@ -305,20 +323,22 @@ 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" | +| 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", @@ -334,20 +354,22 @@ 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 | +| 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", @@ -365,14 +387,15 @@ 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. | +| 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 @@ -380,16 +403,17 @@ Add a copper pour (ground/power plane) to the PCB. - 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} + { "x": 10.0, "y": 10.0 }, + { "x": 90.0, "y": 10.0 }, + { "x": 90.0, "y": 60.0 }, + { "x": 10.0, "y": 60.0 } ] } ``` @@ -405,6 +429,7 @@ Refill all copper zones on the board. 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 @@ -412,6 +437,7 @@ None - May take several seconds on complex boards with many zones **Example:** + ```json {} ``` @@ -439,6 +465,7 @@ The simplest and most robust approach for connecting component pads: ``` This automatically: + - Looks up the exact pad positions - Detects the net from the pads - Creates the trace on the appropriate layer diff --git a/docs/SCHEMATIC_TOOLS_REFERENCE.md b/docs/SCHEMATIC_TOOLS_REFERENCE.md index 54f0dad..a1d7823 100644 --- a/docs/SCHEMATIC_TOOLS_REFERENCE.md +++ b/docs/SCHEMATIC_TOOLS_REFERENCE.md @@ -8,268 +8,296 @@ This document provides a complete reference for the 27 schematic tools in the Ki ## 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 | +| 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) | +| 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}}) | +| 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) | +| 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') | +| 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 | +| 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") | +| 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 | +| 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 | +| 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) | +| 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. -| 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 | Yes | Position [x, y] for the label | +| 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 | Yes | Position [x, y] for the label | ### connect_to_net + Connect a component pin to a named net. -| 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 | +| 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 | **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.54mm (0.1 inch, standard grid spacing). ### 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) | +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. Use this before add_schematic_net_label to place labels correctly on pin endpoints. -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| schematicPath | string | Yes | Path to the schematic file | -| reference | string | Yes | Component reference designator (e.g. U1, R1, J2) | +| 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 | +| 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) | +| 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 (4 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 | +| 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 | +| 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 | +| 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 | +| Parameter | Type | Required | Description | +| ------------- | ------ | -------- | --------------------------- | +| schematicPath | string | Yes | Path to the .kicad_sch file | ## Schematic Creation and Export (5 tools) ### create_schematic + Create a new schematic. -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| name | string | Yes | Schematic name | -| path | string | No | Optional path | +| 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 | +| 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 | +| 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) | +| 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 + Generate a netlist from the schematic. -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| schematicPath | string | Yes | Path to the schematic file | +| Parameter | Type | Required | Description | +| ------------- | ------ | -------- | -------------------------- | +| schematicPath | string | Yes | Path to the schematic file | **Usage Notes:** Returns a complete netlist with component information (reference, value, footprint) and net connections (net name with all connected component/pin pairs). ## Validation and Synchronization (3 tools) ### 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 | +| 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 | +| 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"` @@ -281,6 +309,7 @@ Import the schematic netlist into the PCB board — equivalent to pressing F8 in 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"` diff --git a/docs/STATUS_SUMMARY.md b/docs/STATUS_SUMMARY.md index b46e076..df216c8 100644 --- a/docs/STATUS_SUMMARY.md +++ b/docs/STATUS_SUMMARY.md @@ -8,46 +8,47 @@ ## Quick Stats -| 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 | +| 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 | 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 | +| 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 | --- ## 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 @@ -55,13 +56,16 @@ - 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) @@ -71,18 +75,21 @@ The server automatically selects the best backend: ## Platform Support ### Linux -- Primary Platform + - KiCAD 9.0 detection: Working - Process management: Working - Library discovery: Working (153+ libraries) - IPC backend: Working ### Windows -- Fully Supported + - Automated setup script (setup-windows.ps1) - Process detection via Toolhelp32 API - Library paths auto-detected - Troubleshooting guide available (WINDOWS_TROUBLESHOOTING.md) ### macOS -- Community Supported + - Configuration provided - Process detection implemented - Library paths configured @@ -93,6 +100,7 @@ The server automatically selects the best backend: ## Recent Development Highlights ### v2.2.3 (2026-03-11) + - FFC/ribbon cable passthrough workflow (connect_passthrough, sync_schematic_to_board) - Project snapshot system - SVG logo import @@ -101,20 +109,24 @@ The server automatically selects the best backend: - 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 @@ -124,35 +136,38 @@ The server automatically selects the best backend: ## Community Contributors -| 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 | +| 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 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: 2026-03-21* +_Last Updated: 2026-03-21_ diff --git a/docs/SVG_IMPORT_GUIDE.md b/docs/SVG_IMPORT_GUIDE.md index 5752776..0a11f74 100644 --- a/docs/SVG_IMPORT_GUIDE.md +++ b/docs/SVG_IMPORT_GUIDE.md @@ -14,18 +14,19 @@ Imports an SVG file as filled graphic polygons onto a PCB layer. Curves are line **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 | +| 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 @@ -35,18 +36,21 @@ Imports an SVG file as filled graphic polygons onto a PCB layer. Curves are line ## 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 @@ -59,11 +63,13 @@ Imports an SVG file as filled graphic polygons onto a PCB layer. Curves are line ### 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 @@ -87,14 +93,14 @@ 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 | +| 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 | --- diff --git a/docs/TOOL_INVENTORY.md b/docs/TOOL_INVENTORY.md index f71a7e3..5254698 100644 --- a/docs/TOOL_INVENTORY.md +++ b/docs/TOOL_INVENTORY.md @@ -16,325 +16,325 @@ The server uses a **router pattern** to reduce AI context usage. Tools fall into ## Project Management (5 tools) -*Source: `src/tools/project.ts`* +_Source: `src/tools/project.ts`_ -| 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 | +| 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 | --- ## Board Management (12 tools) -*Source: `src/tools/board.ts`* +_Source: `src/tools/board.ts`_ -| 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 | +| 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 | --- ## Component Management (16 tools) -*Source: `src/tools/component.ts`* +_Source: `src/tools/component.ts`_ -| 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 | +| 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`* +_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 | +| 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`* +_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) | +| 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`* +_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) | +| 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) | +| `export_vrml` | Export VRML 3D model | Routed (export) | --- ## Schematic (27 tools) -*Source: `src/tools/schematic.ts`* +_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 | +| 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 | +| 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) | +| 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) | +| 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 | +| 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`* +_Source: `src/tools/library.ts`_ -| Tool | Description | Access | -|------|-------------|--------| -| `list_libraries` | List all footprint libraries | Routed (library) | -| `search_footprints` | Search footprints across libraries | Routed (library) | +| 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) | +| `get_footprint_info` | Get detailed footprint information | Routed (library) | --- ## Symbol Libraries (4 tools) -*Source: `src/tools/library-symbol.ts`* +_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 | +| 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`* +_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 | +| 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`* +_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 | +| 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`* +_Source: `src/tools/datasheet.ts`_ -| Tool | Description | Access | -|------|-------------|--------| +| 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 | +| `get_datasheet_url` | Get LCSC datasheet URL for a component | Additional | --- ## JLCPCB Integration (5 tools) -*Source: `src/tools/jlcpcb-api.ts`* +_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 | +| 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`* +_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) | +| 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`* +_Source: `src/tools/ui.ts`_ -| Tool | Description | Access | -|------|-------------|--------| -| `check_kicad_ui` | Check if KiCAD UI is running | Direct | +| 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`* +_Source: `src/tools/router.ts`_ These meta-tools provide discovery and execution of routed tools: -| Tool | Description | -|------|-------------| +| 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 | +| `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** | | +| 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** | +| 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 diff --git a/docs/UI_AUTO_LAUNCH.md b/docs/UI_AUTO_LAUNCH.md index 8e4ecee..05f2315 100644 --- a/docs/UI_AUTO_LAUNCH.md +++ b/docs/UI_AUTO_LAUNCH.md @@ -1,399 +1,425 @@ -# KiCAD UI Auto-Launch Feature - -Automatically detect and launch KiCAD UI when needed, providing seamless visual feedback for PCB design operations. - ---- - -## 🎯 Overview - -The KiCAD MCP server can now: -- ✅ Detect if KiCAD UI is running -- ✅ Launch KiCAD automatically when needed -- ✅ Open projects directly in the UI -- ✅ Work across Linux, macOS, and Windows - ---- - -## 🚀 Quick Start - -### Enable Auto-Launch - -Add to your MCP configuration: - -```json -{ - "mcpServers": { - "kicad": { - "command": "node", - "args": ["/path/to/KiCAD-MCP-Server/dist/index.js"], - "env": { - "KICAD_AUTO_LAUNCH": "true" - } - } - } -} -``` - -### Manual Control (Default) - -Without `KICAD_AUTO_LAUNCH=true`, you manually control when KiCAD launches using the new MCP tools. - ---- - -## 🛠️ New MCP Tools - -### 1. `check_kicad_ui` - -Check if KiCAD is currently running. - -**Parameters:** None - -**Example:** -```typescript -{ - "command": "check_kicad_ui", - "params": {} -} -``` - -**Response:** -```json -{ - "success": true, - "running": true, - "processes": [ - { - "pid": "12345", - "name": "pcbnew", - "command": "/usr/bin/pcbnew /tmp/project.kicad_pcb" - } - ], - "message": "KiCAD is running" -} -``` - -### 2. `launch_kicad_ui` - -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", - "params": { - "projectPath": "/tmp/mcp_demo/New_Project.kicad_pcb" - } -} -``` - -**Response:** -```json -{ - "success": true, - "running": true, - "launched": true, - "message": "KiCAD launched successfully", - "project": "/tmp/mcp_demo/New_Project.kicad_pcb", - "processes": [...] -} -``` - ---- - -## 🔄 Workflow Examples - -### Example 1: Manual Launch - -``` -User: "Check if KiCAD is running" -Claude: Uses check_kicad_ui → "KiCAD is not running" - -User: "Launch it with the demo project" -Claude: Uses launch_kicad_ui → KiCAD opens with project loaded! -``` - -### Example 2: Auto-Launch Mode - -With `KICAD_AUTO_LAUNCH=true`: - -``` -User: "Create a new Arduino shield PCB" -Claude: - 1. Creates project - 2. Detects KiCAD not running - 3. Automatically launches KiCAD with the new project - 4. You see the board in real-time as it's designed! -``` - -### Example 3: Side-by-Side Design - -``` -┌────────────────────────────────────────────────────────┐ -│ Workflow: AI-Assisted PCB Design │ -├────────────────────────────────────────────────────────┤ -│ │ -│ 1. User: "Create a 100mm square board" │ -│ → Claude creates project │ -│ → KiCAD auto-launches if not running │ -│ │ -│ 2. User: "Add 4 mounting holes at corners" │ -│ → Claude adds holes │ -│ → KiCAD detects file change, prompts to reload │ -│ → User clicks "Yes" → sees holes appear! │ -│ │ -│ 3. User: "Perfect! Now add a circular outline..." │ -│ → Iterative design continues... │ -│ │ -└────────────────────────────────────────────────────────┘ -``` - ---- - -## ⚙️ Configuration Options - -### Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `KICAD_AUTO_LAUNCH` | `false` | Auto-launch KiCAD when needed | -| `KICAD_EXECUTABLE` | auto-detect | Override KiCAD executable path | - -### Custom Executable Path - -If KiCAD is installed in a non-standard location: - -```json -{ - "env": { - "KICAD_AUTO_LAUNCH": "true", - "KICAD_EXECUTABLE": "/opt/kicad/bin/pcbnew" - } -} -``` - ---- - -## 🔍 How It Works - -### Process Detection - -**Linux:** -```bash -pgrep -f "pcbnew|kicad" -``` - -**macOS:** -```bash -pgrep -f "KiCad|pcbnew" -``` - -**Windows:** -```powershell -tasklist /FI "IMAGENAME eq pcbnew.exe" -``` - -### Auto-Discovery of Executable - -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` - -### Launch Process - -1. Check if KiCAD is already running -2. If not, find executable path -3. Spawn process with optional project path -4. Wait up to 5 seconds for process to start -5. Verify process is running -6. Return status to MCP client - ---- - -## 💡 Use Cases - -### 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] -``` - -### 2. Streamlined Iteration - -For rapid prototyping with visual feedback: -``` -1. Claude creates board → KiCAD opens -2. User sees board, requests changes -3. Claude modifies → KiCAD reloads -4. Repeat until satisfied -``` - -### 3. Batch Processing - -Process multiple designs without manual intervention: -```python -for design in designs: - create_project(design) - # KiCAD auto-launches and loads each one - add_components(design) - route_board(design) - export_gerbers(design) -``` - ---- - -## 🐛 Troubleshooting - -### KiCAD Doesn't Launch - -**Check executable path:** -```bash -# Linux/macOS -which pcbnew - -# Windows -where pcbnew.exe -``` - -**Override if needed:** -```json -{ - "env": { - "KICAD_EXECUTABLE": "/path/to/pcbnew" - } -} -``` - -### Process Detection Fails - -**Manual check:** -```bash -# Linux/macOS -ps aux | grep kicad - -# Windows -tasklist | findstr kicad -``` - -**Verify permissions:** -- Ensure user can execute `pgrep` (Linux/macOS) -- Ensure user can execute `tasklist` (Windows) - -### Auto-Launch Doesn't Work - -1. Check `KICAD_AUTO_LAUNCH` is set to `"true"` (string, not boolean) -2. Verify KiCAD is in PATH or set `KICAD_EXECUTABLE` -3. Check MCP server logs for errors -4. Try manual launch first: `launch_kicad_ui` - ---- - -## 📊 Implementation Details - -### 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 - -# Check if running -manager = KiCADProcessManager() -is_running = manager.is_running() - -# Launch KiCAD -success = manager.launch(project_path="/path/to/file.kicad_pcb") - -# Get process info -processes = manager.get_process_info() - -# High-level helper -result = check_and_launch_kicad( - project_path=Path("/path/to/file.kicad_pcb"), - auto_launch=True -) -``` - -**MCP Tools:** -```typescript -// Check status -await callKicadScript("check_kicad_ui", {}); - -// Launch -await callKicadScript("launch_kicad_ui", { - projectPath: "/path/to/project.kicad_pcb", - autoLaunch: true -}); -``` - ---- - -## 🔮 Future Enhancements - -### Planned Features - -- **Window Management:** Bring KiCAD to front, minimize/maximize -- **Multi-Instance:** Handle multiple KiCAD instances -- **IPC Integration:** Seamless integration with IPC backend -- **Status Notifications:** Push notifications when KiCAD state changes -- **Auto-Close:** Option to close KiCAD after operations complete - -### 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. -``` - ---- - -## 📝 Summary - -**Before this feature:** -``` -User manually launches KiCAD -User manually opens project -Claude makes changes -User manually reloads -``` - -**After this feature:** -``` -User: "Design a board" -→ KiCAD auto-launches with project -→ Changes appear (with quick reload) -→ Seamless AI-assisted design! -``` - ---- - -**Last Updated:** 2025-10-26 -**Version:** 2.0.0-alpha.1 -**Status:** ✅ Production Ready +# KiCAD UI Auto-Launch Feature + +Automatically detect and launch KiCAD UI when needed, providing seamless visual feedback for PCB design operations. + +--- + +## 🎯 Overview + +The KiCAD MCP server can now: + +- ✅ Detect if KiCAD UI is running +- ✅ Launch KiCAD automatically when needed +- ✅ Open projects directly in the UI +- ✅ Work across Linux, macOS, and Windows + +--- + +## 🚀 Quick Start + +### Enable Auto-Launch + +Add to your MCP configuration: + +```json +{ + "mcpServers": { + "kicad": { + "command": "node", + "args": ["/path/to/KiCAD-MCP-Server/dist/index.js"], + "env": { + "KICAD_AUTO_LAUNCH": "true" + } + } + } +} +``` + +### Manual Control (Default) + +Without `KICAD_AUTO_LAUNCH=true`, you manually control when KiCAD launches using the new MCP tools. + +--- + +## 🛠️ New MCP Tools + +### 1. `check_kicad_ui` + +Check if KiCAD is currently running. + +**Parameters:** None + +**Example:** + +```typescript +{ + "command": "check_kicad_ui", + "params": {} +} +``` + +**Response:** + +```json +{ + "success": true, + "running": true, + "processes": [ + { + "pid": "12345", + "name": "pcbnew", + "command": "/usr/bin/pcbnew /tmp/project.kicad_pcb" + } + ], + "message": "KiCAD is running" +} +``` + +### 2. `launch_kicad_ui` + +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", + "params": { + "projectPath": "/tmp/mcp_demo/New_Project.kicad_pcb" + } +} +``` + +**Response:** + +```json +{ + "success": true, + "running": true, + "launched": true, + "message": "KiCAD launched successfully", + "project": "/tmp/mcp_demo/New_Project.kicad_pcb", + "processes": [...] +} +``` + +--- + +## 🔄 Workflow Examples + +### Example 1: Manual Launch + +``` +User: "Check if KiCAD is running" +Claude: Uses check_kicad_ui → "KiCAD is not running" + +User: "Launch it with the demo project" +Claude: Uses launch_kicad_ui → KiCAD opens with project loaded! +``` + +### Example 2: Auto-Launch Mode + +With `KICAD_AUTO_LAUNCH=true`: + +``` +User: "Create a new Arduino shield PCB" +Claude: + 1. Creates project + 2. Detects KiCAD not running + 3. Automatically launches KiCAD with the new project + 4. You see the board in real-time as it's designed! +``` + +### Example 3: Side-by-Side Design + +``` +┌────────────────────────────────────────────────────────┐ +│ Workflow: AI-Assisted PCB Design │ +├────────────────────────────────────────────────────────┤ +│ │ +│ 1. User: "Create a 100mm square board" │ +│ → Claude creates project │ +│ → KiCAD auto-launches if not running │ +│ │ +│ 2. User: "Add 4 mounting holes at corners" │ +│ → Claude adds holes │ +│ → KiCAD detects file change, prompts to reload │ +│ → User clicks "Yes" → sees holes appear! │ +│ │ +│ 3. User: "Perfect! Now add a circular outline..." │ +│ → Iterative design continues... │ +│ │ +└────────────────────────────────────────────────────────┘ +``` + +--- + +## ⚙️ Configuration Options + +### Environment Variables + +| Variable | Default | Description | +| ------------------- | ----------- | ------------------------------ | +| `KICAD_AUTO_LAUNCH` | `false` | Auto-launch KiCAD when needed | +| `KICAD_EXECUTABLE` | auto-detect | Override KiCAD executable path | + +### Custom Executable Path + +If KiCAD is installed in a non-standard location: + +```json +{ + "env": { + "KICAD_AUTO_LAUNCH": "true", + "KICAD_EXECUTABLE": "/opt/kicad/bin/pcbnew" + } +} +``` + +--- + +## 🔍 How It Works + +### Process Detection + +**Linux:** + +```bash +pgrep -f "pcbnew|kicad" +``` + +**macOS:** + +```bash +pgrep -f "KiCad|pcbnew" +``` + +**Windows:** + +```powershell +tasklist /FI "IMAGENAME eq pcbnew.exe" +``` + +### Auto-Discovery of Executable + +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` + +### Launch Process + +1. Check if KiCAD is already running +2. If not, find executable path +3. Spawn process with optional project path +4. Wait up to 5 seconds for process to start +5. Verify process is running +6. Return status to MCP client + +--- + +## 💡 Use Cases + +### 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] +``` + +### 2. Streamlined Iteration + +For rapid prototyping with visual feedback: + +``` +1. Claude creates board → KiCAD opens +2. User sees board, requests changes +3. Claude modifies → KiCAD reloads +4. Repeat until satisfied +``` + +### 3. Batch Processing + +Process multiple designs without manual intervention: + +```python +for design in designs: + create_project(design) + # KiCAD auto-launches and loads each one + add_components(design) + route_board(design) + export_gerbers(design) +``` + +--- + +## 🐛 Troubleshooting + +### KiCAD Doesn't Launch + +**Check executable path:** + +```bash +# Linux/macOS +which pcbnew + +# Windows +where pcbnew.exe +``` + +**Override if needed:** + +```json +{ + "env": { + "KICAD_EXECUTABLE": "/path/to/pcbnew" + } +} +``` + +### Process Detection Fails + +**Manual check:** + +```bash +# Linux/macOS +ps aux | grep kicad + +# Windows +tasklist | findstr kicad +``` + +**Verify permissions:** + +- Ensure user can execute `pgrep` (Linux/macOS) +- Ensure user can execute `tasklist` (Windows) + +### Auto-Launch Doesn't Work + +1. Check `KICAD_AUTO_LAUNCH` is set to `"true"` (string, not boolean) +2. Verify KiCAD is in PATH or set `KICAD_EXECUTABLE` +3. Check MCP server logs for errors +4. Try manual launch first: `launch_kicad_ui` + +--- + +## 📊 Implementation Details + +### 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 + +# Check if running +manager = KiCADProcessManager() +is_running = manager.is_running() + +# Launch KiCAD +success = manager.launch(project_path="/path/to/file.kicad_pcb") + +# Get process info +processes = manager.get_process_info() + +# High-level helper +result = check_and_launch_kicad( + project_path=Path("/path/to/file.kicad_pcb"), + auto_launch=True +) +``` + +**MCP Tools:** + +```typescript +// Check status +await callKicadScript("check_kicad_ui", {}); + +// Launch +await callKicadScript("launch_kicad_ui", { + projectPath: "/path/to/project.kicad_pcb", + autoLaunch: true, +}); +``` + +--- + +## 🔮 Future Enhancements + +### Planned Features + +- **Window Management:** Bring KiCAD to front, minimize/maximize +- **Multi-Instance:** Handle multiple KiCAD instances +- **IPC Integration:** Seamless integration with IPC backend +- **Status Notifications:** Push notifications when KiCAD state changes +- **Auto-Close:** Option to close KiCAD after operations complete + +### 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. +``` + +--- + +## 📝 Summary + +**Before this feature:** + +``` +User manually launches KiCAD +User manually opens project +Claude makes changes +User manually reloads +``` + +**After this feature:** + +``` +User: "Design a board" +→ KiCAD auto-launches with project +→ Changes appear (with quick reload) +→ Seamless AI-assisted design! +``` + +--- + +**Last Updated:** 2025-10-26 +**Version:** 2.0.0-alpha.1 +**Status:** ✅ Production Ready diff --git a/docs/VISUAL_FEEDBACK.md b/docs/VISUAL_FEEDBACK.md index 9c75ef6..92d7612 100644 --- a/docs/VISUAL_FEEDBACK.md +++ b/docs/VISUAL_FEEDBACK.md @@ -1,184 +1,193 @@ -# Visual Feedback: Seeing MCP Changes in KiCAD UI - -This document explains how to see changes made by the MCP server in the KiCAD UI in real-time or near-real-time. - -## Current Status (Week 1 - SWIG Backend) - -**Active Backend:** SWIG (legacy pcbnew Python API) -**Real-time Updates:** Not available yet -**IPC Backend:** Skeleton implemented, operations coming in Weeks 2-3 - ---- - -## 🎯 Best Current Workflow (SWIG + Manual Reload) - -### Setup - -1. **Open your project in KiCAD PCB Editor** - ```bash - pcbnew /tmp/kicad_test_project/New_Project.kicad_pcb - ``` - -2. **Make changes via MCP** (Claude Code, Claude Desktop, etc.) - - Example: Add board outline, mounting holes, etc. - - Each operation saves the file automatically - -3. **Reload in KiCAD UI** - - **Option A (Automatic):** KiCAD 8.0+ detects file changes and shows a reload prompt - - **Option B (Manual):** File → Revert to reload from disk - - **Keyboard shortcut:** None by default (but you can assign one) - -### Workflow Example - -``` -┌─────────────────────────────────────────────────────────┐ -│ Terminal: Claude Code │ -├─────────────────────────────────────────────────────────┤ -│ You: "Create a 100x80mm board with 4 mounting holes" │ -│ │ -│ Claude: ✓ Added board outline (100x80mm) │ -│ ✓ Added mounting hole at (5,5) │ -│ ✓ Added mounting hole at (95,5) │ -│ ✓ Added mounting hole at (95,75) │ -│ ✓ Added mounting hole at (5,75) │ -│ ✓ Saved project │ -└─────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────┐ -│ KiCAD PCB Editor │ -├─────────────────────────────────────────────────────────┤ -│ [Reload prompt appears] │ -│ "File has been modified. Reload?" │ -│ │ -│ Click "Yes" → Changes appear instantly! 🎉 │ -└─────────────────────────────────────────────────────────┘ -``` - ---- - -## 🔮 Future: IPC Backend (Weeks 2-3) - -When fully implemented, the IPC backend will provide **true real-time updates**: - -### How It Will Work - -``` -Claude MCP → IPC Socket → Running KiCAD → Instant UI Update -``` - -**No file reloading required** - changes appear as you make them! - -### IPC Setup (When Available) - -1. **Enable IPC in KiCAD** - - Preferences → Advanced Preferences - - Search for "IPC" - - Enable: "Enable IPC API Server" - - Restart KiCAD - -2. **Install kicad-python** (Already installed ✓) - ```bash - pip install kicad-python - ``` - -3. **Configure MCP Server** - Add to your MCP config: - ```json - { - "env": { - "KICAD_BACKEND": "ipc" - } - } - ``` - -4. **Start KiCAD first, then use MCP** - - Changes will appear in real-time - - No manual reloading needed - -### Current IPC Status - -| Feature | Status | -|---------|--------| -| Connection to KiCAD | ✅ Working | -| Version checking | ✅ Working | -| Project operations | ⏳ Week 2-3 | -| Board operations | ⏳ Week 2-3 | -| Component operations | ⏳ Week 2-3 | -| Routing operations | ⏳ Week 2-3 | - ---- - -## 🛠️ Monitoring Helper (Optional) - -A helper script is available to monitor file changes: - -```bash -# Watch for changes and notify -./scripts/auto_refresh_kicad.sh /tmp/kicad_test_project/New_Project.kicad_pcb -``` - -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 │ -│ (Terminal) │ Editor │ -│ │ │ -│ Making changes │ Viewing results │ -└──────────────────┴──────────────────┘ -``` - -### 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 -4. Proceed with next change - ---- - -## 🔍 Troubleshooting - -### KiCAD Doesn't Detect File Changes - -**Cause:** Some KiCAD versions or configurations don't auto-detect -**Solution:** Use File → Revert manually - -### Changes Don't Appear After Reload - -**Cause:** MCP operation may have failed -**Solution:** Check the MCP response for success: true - -### File is Locked - -**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 - ---- - -## 📅 Roadmap - -**Current (Week 1):** SWIG backend with manual reload -**Week 2-3:** IPC backend implementation -**Week 4+:** Real-time collaboration features - ---- - -**Last Updated:** 2025-10-26 -**Version:** 2.0.0-alpha.1 +# Visual Feedback: Seeing MCP Changes in KiCAD UI + +This document explains how to see changes made by the MCP server in the KiCAD UI in real-time or near-real-time. + +## Current Status (Week 1 - SWIG Backend) + +**Active Backend:** SWIG (legacy pcbnew Python API) +**Real-time Updates:** Not available yet +**IPC Backend:** Skeleton implemented, operations coming in Weeks 2-3 + +--- + +## 🎯 Best Current Workflow (SWIG + Manual Reload) + +### Setup + +1. **Open your project in KiCAD PCB Editor** + + ```bash + pcbnew /tmp/kicad_test_project/New_Project.kicad_pcb + ``` + +2. **Make changes via MCP** (Claude Code, Claude Desktop, etc.) + - Example: Add board outline, mounting holes, etc. + - Each operation saves the file automatically + +3. **Reload in KiCAD UI** + - **Option A (Automatic):** KiCAD 8.0+ detects file changes and shows a reload prompt + - **Option B (Manual):** File → Revert to reload from disk + - **Keyboard shortcut:** None by default (but you can assign one) + +### Workflow Example + +``` +┌─────────────────────────────────────────────────────────┐ +│ Terminal: Claude Code │ +├─────────────────────────────────────────────────────────┤ +│ You: "Create a 100x80mm board with 4 mounting holes" │ +│ │ +│ Claude: ✓ Added board outline (100x80mm) │ +│ ✓ Added mounting hole at (5,5) │ +│ ✓ Added mounting hole at (95,5) │ +│ ✓ Added mounting hole at (95,75) │ +│ ✓ Added mounting hole at (5,75) │ +│ ✓ Saved project │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ KiCAD PCB Editor │ +├─────────────────────────────────────────────────────────┤ +│ [Reload prompt appears] │ +│ "File has been modified. Reload?" │ +│ │ +│ Click "Yes" → Changes appear instantly! 🎉 │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## 🔮 Future: IPC Backend (Weeks 2-3) + +When fully implemented, the IPC backend will provide **true real-time updates**: + +### How It Will Work + +``` +Claude MCP → IPC Socket → Running KiCAD → Instant UI Update +``` + +**No file reloading required** - changes appear as you make them! + +### IPC Setup (When Available) + +1. **Enable IPC in KiCAD** + - Preferences → Advanced Preferences + - Search for "IPC" + - Enable: "Enable IPC API Server" + - Restart KiCAD + +2. **Install kicad-python** (Already installed ✓) + + ```bash + pip install kicad-python + ``` + +3. **Configure MCP Server** + Add to your MCP config: + + ```json + { + "env": { + "KICAD_BACKEND": "ipc" + } + } + ``` + +4. **Start KiCAD first, then use MCP** + - Changes will appear in real-time + - No manual reloading needed + +### Current IPC Status + +| Feature | Status | +| -------------------- | ----------- | +| Connection to KiCAD | ✅ Working | +| Version checking | ✅ Working | +| Project operations | ⏳ Week 2-3 | +| Board operations | ⏳ Week 2-3 | +| Component operations | ⏳ Week 2-3 | +| Routing operations | ⏳ Week 2-3 | + +--- + +## 🛠️ Monitoring Helper (Optional) + +A helper script is available to monitor file changes: + +```bash +# Watch for changes and notify +./scripts/auto_refresh_kicad.sh /tmp/kicad_test_project/New_Project.kicad_pcb +``` + +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 │ +│ (Terminal) │ Editor │ +│ │ │ +│ Making changes │ Viewing results │ +└──────────────────┴──────────────────┘ +``` + +### 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 +4. Proceed with next change + +--- + +## 🔍 Troubleshooting + +### KiCAD Doesn't Detect File Changes + +**Cause:** Some KiCAD versions or configurations don't auto-detect +**Solution:** Use File → Revert manually + +### Changes Don't Appear After Reload + +**Cause:** MCP operation may have failed +**Solution:** Check the MCP response for success: true + +### File is Locked + +**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 + +--- + +## 📅 Roadmap + +**Current (Week 1):** SWIG backend with manual reload +**Week 2-3:** IPC backend implementation +**Week 4+:** Real-time collaboration features + +--- + +**Last Updated:** 2025-10-26 +**Version:** 2.0.0-alpha.1 diff --git a/docs/WINDOWS_TROUBLESHOOTING.md b/docs/WINDOWS_TROUBLESHOOTING.md index 1a36548..a4b9f0b 100644 --- a/docs/WINDOWS_TROUBLESHOOTING.md +++ b/docs/WINDOWS_TROUBLESHOOTING.md @@ -1,475 +1,493 @@ -# Windows Troubleshooting Guide - -This guide helps diagnose and fix common issues when setting up KiCAD MCP Server on Windows. - -## Quick Start: Automated Setup - -**Before manually troubleshooting, try the automated setup script:** - -```powershell -# Open PowerShell in the KiCAD-MCP-Server directory -.\setup-windows.ps1 -``` - -This script will: -- Detect your KiCAD installation -- Verify all prerequisites -- Install dependencies -- Build the project -- Generate configuration -- Run diagnostic tests - -If the automated setup fails, continue with the manual troubleshooting below. - ---- - -## Common Issues and Solutions - -### Issue 1: Server Exits Immediately (Most Common) - -**Symptom:** Claude Desktop logs show "Server transport closed unexpectedly" - -**Cause:** Python process crashes during startup, usually due to missing pcbnew module - -**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())" - ``` - - **Expected:** Prints KiCAD version like `9.0.0` - - **If it fails:** - - KiCAD's Python module isn't installed - - Reinstall KiCAD with default options - - Make sure "Install Python" is checked during installation - -3. **Verify PYTHONPATH in your config:** - ```json - { - "mcpServers": { - "kicad": { - "env": { - "PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages" - } - } - } - } - ``` - ---- - -### Issue 2: KiCAD Not Found - -**Symptom:** Log shows "No KiCAD installations found" - -**Solution:** - -1. **Check if KiCAD is installed:** - ```powershell - Test-Path "C:\Program Files\KiCad\9.0" - ``` - -2. **If KiCAD is installed elsewhere:** - - Find your KiCAD installation directory - - Update PYTHONPATH in config to match your installation - - Example for version 8.0: - ``` - "PYTHONPATH": "C:\\Program Files\\KiCad\\8.0\\lib\\python3\\dist-packages" - ``` - -3. **If KiCAD is not installed:** - - Download from https://www.kicad.org/download/windows/ - - Install version 9.0 or higher - - Use default installation path - ---- - -### Issue 3: Node.js Not Found - -**Symptom:** Cannot run `npm install` or `npm run build` - -**Solution:** - -1. **Check if Node.js is installed:** - ```powershell - node --version - npm --version - ``` - -2. **If not installed:** - - Download Node.js 18+ from https://nodejs.org/ - - Install with default options - - Restart PowerShell after installation - -3. **If installed but not in PATH:** - ```powershell - # Add to PATH temporarily - $env:PATH += ";C:\Program Files\nodejs" - ``` - ---- - -### Issue 4: Build Fails with TypeScript Errors - -**Symptom:** `npm run build` shows TypeScript compilation errors - -**Solution:** - -1. **Clean and reinstall dependencies:** - ```powershell - Remove-Item node_modules -Recurse -Force - Remove-Item package-lock.json -Force - npm install - npm run build - ``` - -2. **Check Node.js version:** - ```powershell - node --version # Should be v18.0.0 or higher - ``` - -3. **If still failing:** - ```powershell - # Try with legacy peer deps - npm install --legacy-peer-deps - npm run build - ``` - ---- - -### Issue 5: Python Dependencies Missing - -**Symptom:** Log shows errors about missing Python packages (Pillow, cairosvg, etc.) - -**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 - - # Install pip - & "C:\Program Files\KiCad\9.0\bin\python.exe" get-pip.py - - # Then install requirements - & "C:\Program Files\KiCad\9.0\bin\python.exe" -m pip install -r requirements.txt - ``` - ---- - -### Issue 6: Permission Denied Errors - -**Symptom:** Cannot write to Program Files or access certain directories - -**Solution:** - -1. **Run PowerShell as Administrator:** - - Right-click PowerShell icon - - Select "Run as Administrator" - - Navigate to KiCAD-MCP-Server directory - - Run setup again - -2. **Or clone to user directory:** - ```powershell - cd $HOME\Documents - git clone https://github.com/mixelpixx/KiCAD-MCP-Server.git - cd KiCAD-MCP-Server - .\setup-windows.ps1 - ``` - ---- - -### Issue 7: Path Issues in Configuration - -**Symptom:** Config file paths not working - -**Common mistakes:** -```json -// ❌ Wrong - single backslashes -"args": ["C:\Users\Name\KiCAD-MCP-Server\dist\index.js"] - -// ❌ Wrong - mixed slashes -"args": ["C:\Users/Name\KiCAD-MCP-Server/dist\index.js"] - -// ✅ Correct - double backslashes -"args": ["C:\\Users\\Name\\KiCAD-MCP-Server\\dist\\index.js"] - -// ✅ Also correct - forward slashes -"args": ["C:/Users/Name/KiCAD-MCP-Server/dist/index.js"] -``` - -**Solution:** Use either double backslashes `\\` or forward slashes `/` consistently. - ---- - -### Issue 8: Wrong Python Version - -**Symptom:** Errors about Python 2.7 or Python 3.6 - -**Solution:** - -KiCAD MCP requires Python 3.10+. KiCAD 9.0 includes Python 3.11, which is perfect. - -**Always use KiCAD's bundled Python:** -```json -{ - "mcpServers": { - "kicad": { - "command": "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe", - "args": ["C:\\Users\\YourName\\KiCAD-MCP-Server\\python\\kicad_interface.py"] - } - } -} -``` - -This bypasses Node.js and runs Python directly. - ---- - -## Configuration Examples - -### For Claude Desktop - -Config location: `%APPDATA%\Claude\claude_desktop_config.json` - -```json -{ - "mcpServers": { - "kicad": { - "command": "node", - "args": ["C:\\Users\\YourName\\KiCAD-MCP-Server\\dist\\index.js"], - "env": { - "PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages", - "NODE_ENV": "production", - "LOG_LEVEL": "info" - } - } - } -} -``` - -### For Cline (VSCode) - -Config location: `%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json` - -```json -{ - "mcpServers": { - "kicad": { - "command": "node", - "args": ["C:\\Users\\YourName\\KiCAD-MCP-Server\\dist\\index.js"], - "env": { - "PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages" - }, - "description": "KiCAD PCB Design Assistant" - } - } -} -``` - -### Alternative: Python Direct Mode - -If Node.js issues persist, run Python directly: - -```json -{ - "mcpServers": { - "kicad": { - "command": "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe", - "args": ["C:\\Users\\YourName\\KiCAD-MCP-Server\\python\\kicad_interface.py"], - "env": { - "PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages" - } - } - } -} -``` - ---- - -## Manual Testing Steps - -### Test 1: Verify KiCAD Python - -```powershell -& "C:\Program Files\KiCad\9.0\bin\python.exe" -c @" -import sys -print(f'Python version: {sys.version}') -import pcbnew -print(f'pcbnew version: {pcbnew.GetBuildVersion()}') -print('SUCCESS!') -"@ -``` - -Expected output: -``` -Python version: 3.11.x ... -pcbnew version: 9.0.0 -SUCCESS! -``` - -### Test 2: Verify Node.js - -```powershell -node --version # Should be v18.0.0+ -npm --version # Should be 9.0.0+ -``` - -### Test 3: Build Project - -```powershell -cd C:\Users\YourName\KiCAD-MCP-Server -npm install -npm run build -Test-Path .\dist\index.js # Should output: True -``` - -### Test 4: Run Server Manually - -```powershell -$env:PYTHONPATH = "C:\Program Files\KiCad\9.0\lib\python3\dist-packages" -node .\dist\index.js -``` - -Expected: Server should start and wait for input (doesn't exit immediately) - -**To stop:** Press Ctrl+C - -### Test 5: Check Log File - -```powershell -# View log file -Get-Content "$env:USERPROFILE\.kicad-mcp\logs\kicad_interface.log" -Tail 50 -``` - -Should show successful initialization with no errors. - ---- - -## Advanced Diagnostics - -### Enable Verbose Logging - -Add to your MCP config: -```json -{ - "env": { - "LOG_LEVEL": "debug", - "PYTHONUNBUFFERED": "1" - } -} -``` - -### Check Python sys.path - -```powershell -& "C:\Program Files\KiCad\9.0\bin\python.exe" -c @" -import sys -for path in sys.path: - print(path) -"@ -``` - -Should include: `C:\Program Files\KiCad\9.0\lib\python3\dist-packages` - -### Test MCP Communication - -```powershell -# Start server -$env:PYTHONPATH = "C:\Program Files\KiCad\9.0\lib\python3\dist-packages" -$process = Start-Process -FilePath "node" -ArgumentList ".\dist\index.js" -NoNewWindow -PassThru - -# Wait 3 seconds -Start-Sleep -Seconds 3 - -# Check if still running -if ($process.HasExited) { - Write-Host "Server crashed!" -ForegroundColor Red - Write-Host "Exit code: $($process.ExitCode)" -} else { - Write-Host "Server is running!" -ForegroundColor Green - Stop-Process -Id $process.Id -} -``` - ---- - -## Getting Help - -If none of the above solutions work: - -1. **Run the diagnostic script:** - ```powershell - .\setup-windows.ps1 - ``` - Copy the entire output. - -2. **Collect log files:** - - MCP log: `%USERPROFILE%\.kicad-mcp\logs\kicad_interface.log` - - Claude Desktop log: `%APPDATA%\Claude\logs\mcp*.log` - -3. **Open a GitHub issue:** - - Go to: https://github.com/mixelpixx/KiCAD-MCP-Server/issues - - Title: "Windows Setup Issue: [brief description]" - - Include: - - Windows version (10 or 11) - - Output from setup script - - Log file contents - - Output from manual tests above - ---- - -## Known Limitations on Windows - -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 - ``` - -4. **Antivirus software** may block Python/Node processes. Temporarily disable for testing. - ---- - -## Success Checklist - -When everything works, you should have: - -- [ ] KiCAD 9.0+ installed at `C:\Program Files\KiCad\9.0` -- [ ] Node.js 18+ installed and in PATH -- [ ] Python can import pcbnew successfully -- [ ] `npm run build` completes without errors -- [ ] `dist\index.js` file exists -- [ ] MCP config file created with correct paths -- [ ] Server starts without immediate crash -- [ ] Log file shows successful initialization -- [ ] Claude Desktop/Cline recognizes the MCP server -- [ ] Can execute: "Create a new KiCAD project" - ---- - -**Last Updated:** 2025-11-05 -**Maintained by:** KiCAD MCP Team - -For the latest updates, see: https://github.com/mixelpixx/KiCAD-MCP-Server +# Windows Troubleshooting Guide + +This guide helps diagnose and fix common issues when setting up KiCAD MCP Server on Windows. + +## Quick Start: Automated Setup + +**Before manually troubleshooting, try the automated setup script:** + +```powershell +# Open PowerShell in the KiCAD-MCP-Server directory +.\setup-windows.ps1 +``` + +This script will: + +- Detect your KiCAD installation +- Verify all prerequisites +- Install dependencies +- Build the project +- Generate configuration +- Run diagnostic tests + +If the automated setup fails, continue with the manual troubleshooting below. + +--- + +## Common Issues and Solutions + +### Issue 1: Server Exits Immediately (Most Common) + +**Symptom:** Claude Desktop logs show "Server transport closed unexpectedly" + +**Cause:** Python process crashes during startup, usually due to missing pcbnew module + +**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())" + ``` + + **Expected:** Prints KiCAD version like `9.0.0` + + **If it fails:** + - KiCAD's Python module isn't installed + - Reinstall KiCAD with default options + - Make sure "Install Python" is checked during installation + +3. **Verify PYTHONPATH in your config:** + ```json + { + "mcpServers": { + "kicad": { + "env": { + "PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages" + } + } + } + } + ``` + +--- + +### Issue 2: KiCAD Not Found + +**Symptom:** Log shows "No KiCAD installations found" + +**Solution:** + +1. **Check if KiCAD is installed:** + + ```powershell + Test-Path "C:\Program Files\KiCad\9.0" + ``` + +2. **If KiCAD is installed elsewhere:** + - Find your KiCAD installation directory + - Update PYTHONPATH in config to match your installation + - Example for version 8.0: + ``` + "PYTHONPATH": "C:\\Program Files\\KiCad\\8.0\\lib\\python3\\dist-packages" + ``` + +3. **If KiCAD is not installed:** + - Download from https://www.kicad.org/download/windows/ + - Install version 9.0 or higher + - Use default installation path + +--- + +### Issue 3: Node.js Not Found + +**Symptom:** Cannot run `npm install` or `npm run build` + +**Solution:** + +1. **Check if Node.js is installed:** + + ```powershell + node --version + npm --version + ``` + +2. **If not installed:** + - Download Node.js 18+ from https://nodejs.org/ + - Install with default options + - Restart PowerShell after installation + +3. **If installed but not in PATH:** + ```powershell + # Add to PATH temporarily + $env:PATH += ";C:\Program Files\nodejs" + ``` + +--- + +### Issue 4: Build Fails with TypeScript Errors + +**Symptom:** `npm run build` shows TypeScript compilation errors + +**Solution:** + +1. **Clean and reinstall dependencies:** + + ```powershell + Remove-Item node_modules -Recurse -Force + Remove-Item package-lock.json -Force + npm install + npm run build + ``` + +2. **Check Node.js version:** + + ```powershell + node --version # Should be v18.0.0 or higher + ``` + +3. **If still failing:** + ```powershell + # Try with legacy peer deps + npm install --legacy-peer-deps + npm run build + ``` + +--- + +### Issue 5: Python Dependencies Missing + +**Symptom:** Log shows errors about missing Python packages (Pillow, cairosvg, etc.) + +**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 + + # Install pip + & "C:\Program Files\KiCad\9.0\bin\python.exe" get-pip.py + + # Then install requirements + & "C:\Program Files\KiCad\9.0\bin\python.exe" -m pip install -r requirements.txt + ``` + +--- + +### Issue 6: Permission Denied Errors + +**Symptom:** Cannot write to Program Files or access certain directories + +**Solution:** + +1. **Run PowerShell as Administrator:** + - Right-click PowerShell icon + - Select "Run as Administrator" + - Navigate to KiCAD-MCP-Server directory + - Run setup again + +2. **Or clone to user directory:** + ```powershell + cd $HOME\Documents + git clone https://github.com/mixelpixx/KiCAD-MCP-Server.git + cd KiCAD-MCP-Server + .\setup-windows.ps1 + ``` + +--- + +### Issue 7: Path Issues in Configuration + +**Symptom:** Config file paths not working + +**Common mistakes:** + +```json +// ❌ Wrong - single backslashes +"args": ["C:\Users\Name\KiCAD-MCP-Server\dist\index.js"] + +// ❌ Wrong - mixed slashes +"args": ["C:\Users/Name\KiCAD-MCP-Server/dist\index.js"] + +// ✅ Correct - double backslashes +"args": ["C:\\Users\\Name\\KiCAD-MCP-Server\\dist\\index.js"] + +// ✅ Also correct - forward slashes +"args": ["C:/Users/Name/KiCAD-MCP-Server/dist/index.js"] +``` + +**Solution:** Use either double backslashes `\\` or forward slashes `/` consistently. + +--- + +### Issue 8: Wrong Python Version + +**Symptom:** Errors about Python 2.7 or Python 3.6 + +**Solution:** + +KiCAD MCP requires Python 3.10+. KiCAD 9.0 includes Python 3.11, which is perfect. + +**Always use KiCAD's bundled Python:** + +```json +{ + "mcpServers": { + "kicad": { + "command": "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe", + "args": ["C:\\Users\\YourName\\KiCAD-MCP-Server\\python\\kicad_interface.py"] + } + } +} +``` + +This bypasses Node.js and runs Python directly. + +--- + +## Configuration Examples + +### For Claude Desktop + +Config location: `%APPDATA%\Claude\claude_desktop_config.json` + +```json +{ + "mcpServers": { + "kicad": { + "command": "node", + "args": ["C:\\Users\\YourName\\KiCAD-MCP-Server\\dist\\index.js"], + "env": { + "PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages", + "NODE_ENV": "production", + "LOG_LEVEL": "info" + } + } + } +} +``` + +### For Cline (VSCode) + +Config location: `%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json` + +```json +{ + "mcpServers": { + "kicad": { + "command": "node", + "args": ["C:\\Users\\YourName\\KiCAD-MCP-Server\\dist\\index.js"], + "env": { + "PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages" + }, + "description": "KiCAD PCB Design Assistant" + } + } +} +``` + +### Alternative: Python Direct Mode + +If Node.js issues persist, run Python directly: + +```json +{ + "mcpServers": { + "kicad": { + "command": "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe", + "args": ["C:\\Users\\YourName\\KiCAD-MCP-Server\\python\\kicad_interface.py"], + "env": { + "PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages" + } + } + } +} +``` + +--- + +## Manual Testing Steps + +### Test 1: Verify KiCAD Python + +```powershell +& "C:\Program Files\KiCad\9.0\bin\python.exe" -c @" +import sys +print(f'Python version: {sys.version}') +import pcbnew +print(f'pcbnew version: {pcbnew.GetBuildVersion()}') +print('SUCCESS!') +"@ +``` + +Expected output: + +``` +Python version: 3.11.x ... +pcbnew version: 9.0.0 +SUCCESS! +``` + +### Test 2: Verify Node.js + +```powershell +node --version # Should be v18.0.0+ +npm --version # Should be 9.0.0+ +``` + +### Test 3: Build Project + +```powershell +cd C:\Users\YourName\KiCAD-MCP-Server +npm install +npm run build +Test-Path .\dist\index.js # Should output: True +``` + +### Test 4: Run Server Manually + +```powershell +$env:PYTHONPATH = "C:\Program Files\KiCad\9.0\lib\python3\dist-packages" +node .\dist\index.js +``` + +Expected: Server should start and wait for input (doesn't exit immediately) + +**To stop:** Press Ctrl+C + +### Test 5: Check Log File + +```powershell +# View log file +Get-Content "$env:USERPROFILE\.kicad-mcp\logs\kicad_interface.log" -Tail 50 +``` + +Should show successful initialization with no errors. + +--- + +## Advanced Diagnostics + +### Enable Verbose Logging + +Add to your MCP config: + +```json +{ + "env": { + "LOG_LEVEL": "debug", + "PYTHONUNBUFFERED": "1" + } +} +``` + +### Check Python sys.path + +```powershell +& "C:\Program Files\KiCad\9.0\bin\python.exe" -c @" +import sys +for path in sys.path: + print(path) +"@ +``` + +Should include: `C:\Program Files\KiCad\9.0\lib\python3\dist-packages` + +### Test MCP Communication + +```powershell +# Start server +$env:PYTHONPATH = "C:\Program Files\KiCad\9.0\lib\python3\dist-packages" +$process = Start-Process -FilePath "node" -ArgumentList ".\dist\index.js" -NoNewWindow -PassThru + +# Wait 3 seconds +Start-Sleep -Seconds 3 + +# Check if still running +if ($process.HasExited) { + Write-Host "Server crashed!" -ForegroundColor Red + Write-Host "Exit code: $($process.ExitCode)" +} else { + Write-Host "Server is running!" -ForegroundColor Green + Stop-Process -Id $process.Id +} +``` + +--- + +## Getting Help + +If none of the above solutions work: + +1. **Run the diagnostic script:** + + ```powershell + .\setup-windows.ps1 + ``` + + Copy the entire output. + +2. **Collect log files:** + - MCP log: `%USERPROFILE%\.kicad-mcp\logs\kicad_interface.log` + - Claude Desktop log: `%APPDATA%\Claude\logs\mcp*.log` + +3. **Open a GitHub issue:** + - Go to: https://github.com/mixelpixx/KiCAD-MCP-Server/issues + - Title: "Windows Setup Issue: [brief description]" + - Include: + - Windows version (10 or 11) + - Output from setup script + - Log file contents + - Output from manual tests above + +--- + +## Known Limitations on Windows + +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 + ``` + +4. **Antivirus software** may block Python/Node processes. Temporarily disable for testing. + +--- + +## Success Checklist + +When everything works, you should have: + +- [ ] KiCAD 9.0+ installed at `C:\Program Files\KiCad\9.0` +- [ ] Node.js 18+ installed and in PATH +- [ ] Python can import pcbnew successfully +- [ ] `npm run build` completes without errors +- [ ] `dist\index.js` file exists +- [ ] MCP config file created with correct paths +- [ ] Server starts without immediate crash +- [ ] Log file shows successful initialization +- [ ] Claude Desktop/Cline recognizes the MCP server +- [ ] Can execute: "Create a new KiCAD project" + +--- + +**Last Updated:** 2025-11-05 +**Maintained by:** KiCAD MCP Team + +For the latest updates, see: https://github.com/mixelpixx/KiCAD-MCP-Server diff --git a/docs/archive/BUILD_AND_TEST_SESSION.md b/docs/archive/BUILD_AND_TEST_SESSION.md index 50b6ead..76822bd 100644 --- a/docs/archive/BUILD_AND_TEST_SESSION.md +++ b/docs/archive/BUILD_AND_TEST_SESSION.md @@ -1,490 +1,523 @@ -# Build and Test Session Summary -**Date:** October 25, 2025 (Evening) -**Status:** ✅ **SUCCESS** - ---- - -## Session Goals - -Complete the MCP server build and test it with various MCP clients (Claude Desktop, Cline, Claude Code). - ---- - -## Completed Work - -### 1. **Fixed TypeScript Compilation Errors** 🔧 - -**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` - -- `src/tools/routing.ts` (100 lines) - - Registers MCP tools: `add_net`, `route_trace`, `add_via`, `add_copper_pour` - -- `src/tools/schematic.ts` (76 lines) - - Registers MCP tools: `create_schematic`, `add_schematic_component`, `add_wire` - -- `src/utils/resource-helpers.ts` (60 lines) - - Helper functions: `createJsonResponse()`, `createBinaryResponse()`, `createErrorResponse()` - -**Total New Code:** ~316 lines of TypeScript - -**Result:** ✅ TypeScript compilation successful, 72 JavaScript files generated in `dist/` - ---- - -### 2. **Fixed Duplicate Resource Registration** 🐛 - -**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}` - -**File Modified:** `src/resources/library.ts` - -**Result:** ✅ No more registration conflicts, server starts cleanly - ---- - -### 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... -[INFO] Registering board management tools -[INFO] Board management tools registered -[INFO] Registering component management tools -[INFO] Component management tools registered -[INFO] Registering design rule tools -[INFO] Design rule tools registered -[INFO] Registering export tools -[INFO] Export tools registered -[INFO] Registering project resources -[INFO] Project resources registered -[INFO] Registering board resources -[INFO] Board resources registered -[INFO] Registering component resources -[INFO] Component resources registered -[INFO] Registering library resources -[INFO] Library resources registered -[INFO] Registering component prompts -[INFO] Component prompts registered -[INFO] Registering routing prompts -[INFO] Routing prompts registered -[INFO] Registering design prompts -[INFO] Design prompts registered -[INFO] All KiCAD tools, resources, and prompts registered -[INFO] Starting KiCAD MCP server... -[INFO] Starting Python process with script: /home/chris/MCP/KiCAD-MCP-Server/python/kicad_interface.py -[INFO] Using Python executable: python -[INFO] Connecting MCP server to STDIO transport... -[INFO] Successfully connected to STDIO transport -``` - -**Exit Code:** 0 (graceful shutdown) - -**Result:** ✅ Server starts successfully, connects to STDIO, and shuts down gracefully - ---- - -### 4. **Comprehensive Client Configuration Guide** 📖 - -**File Created:** `docs/CLIENT_CONFIGURATION.md` (500+ lines) - -**Contents:** -- Platform-specific configurations: - - Linux (Ubuntu/Debian, Arch) - - macOS (with KiCAD.app paths) - - Windows 10/11 (with proper backslash escaping) - -- Client-specific setup: - - **Claude Desktop** - Full configuration for all platforms - - **Cline (VSCode)** - User settings and workspace settings - - **Claude Code CLI** - MCP config location - - **Generic MCP Client** - STDIO transport setup - -- Troubleshooting section: - - Server not starting - - Client can't connect - - Python module errors - - Finding KiCAD Python paths - -- Advanced topics: - - Multiple KiCAD versions - - Custom logging - - Development vs Production configs - - Security considerations - -**Impact:** New users can configure any MCP client in < 5 minutes! - ---- - -### 5. **Updated Configuration Examples** 📝 - -**Files Updated:** - -1. **`config/linux-config.example.json`** - - Cleaner format (removed unnecessary fields) - - Correct PYTHONPATH with both scripting and dist-packages - - Placeholder: `YOUR_USERNAME` for easy customization - -2. **`config/windows-config.example.json`** - - Fixed path separators (consistent backslashes) - - Correct KiCAD 9.0 Python path: `bin\Lib\site-packages` - - Simplified structure - -3. **`config/macos-config.example.json`** - - Using `Versions/Current` symlink for Python version flexibility - - Updated to match CLIENT_CONFIGURATION.md format - ---- - -### 6. **Updated README.md** 📚 - -**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 -- Clear guide reference with feature list - -**Result:** Users immediately know where to find setup instructions for their client - ---- - -## Statistics - -### Files Created/Modified (This Session) - -**New Files (5):** -``` -src/tools/project.ts # 80 lines -src/tools/routing.ts # 100 lines -src/tools/schematic.ts # 76 lines -src/utils/resource-helpers.ts # 60 lines -docs/CLIENT_CONFIGURATION.md # 500+ lines -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 -config/windows-config.example.json # Fixed paths -config/macos-config.example.json # Updated format -README.md # Added config guide section -``` - -**Total New Lines:** ~816+ lines of code and documentation - ---- - -## Build Artifacts - -### 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) -├── server.js (MCP server implementation) -├── kicad-server.js (KiCAD interface) -├── tools/ (10 tool modules) -├── resources/ (6 resource modules) -├── prompts/ (4 prompt modules) -└── utils/ (helper utilities) -``` - ---- - -## 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) -# - All tools registered -# - All resources registered -# - All prompts registered -# - STDIO transport connected -# - Python process spawned -# - Graceful shutdown -``` - -### ✅ 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 - ---- - -## Ready for Testing - -### 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 -- Routing: add_net, route_trace, add_via, add_copper_pour -- Schematic: create_schematic, add_schematic_component, add_wire -- Design Rules: set_track_width, set_via_size, set_clearance, run_drc -- 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) -- Component details (placed and library) -- Statistics and analytics - -**Registered Prompts (10+):** -- Component selection guidance -- Routing strategy suggestions -- Design best practices - ---- - -## Next Steps - -### Immediate Testing (Ready Now) - -1. **Test with Claude Code CLI:** - ```bash - # Create config - mkdir -p ~/.config/claude-code - cp docs/CLIENT_CONFIGURATION.md ~/.config/claude-code/ - - # Test connection - claude-code mcp list - claude-code mcp test kicad - ``` - -2. **Test with Claude Desktop:** - - Copy config from `config/linux-config.example.json` - - Edit `~/.config/Claude/claude_desktop_config.json` - - Restart Claude Desktop - - Start conversation and look for KiCAD tools - -3. **Test with Cline (VSCode):** - - Already configured from previous session - - Open VSCode, start Cline chat - - Ask: "What KiCAD tools are available?" - -### Integration Testing - -**Test basic workflow:** -``` -1. Create new project -2. Set board size -3. Add component -4. Create trace -5. Export Gerber files -``` - -**Test resources:** -``` -1. Request board info -2. View 2D board rendering -3. Get component list -4. Check board statistics -``` - ---- - -## Technical Highlights - -### 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) => { - const result = await callKicadScript("command_name", args); - return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; - }); -} -``` - -**Benefits:** -- Easy to add new tools -- Consistent error handling -- Clean separation of concerns - -### 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 -``` - -**Benefits:** -- DRY principle (Don't Repeat Yourself) -- Consistent response format -- Easy to modify response structure - -### 3. **STDIO Transport** - -Using standard STDIO (stdin/stdout) for MCP protocol: -- No network ports required -- Maximum security (process isolation) -- Works with all MCP clients -- Simple debugging (can pipe commands) - -### 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 -- Graceful shutdown handling - ---- - -## 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 -- ✅ Tool/resource/prompt registration -- ✅ 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 -- ✅ README updated with guide links - ---- - -## Build Status - -**Week 1 Progress:** 100% ✅ - -| Category | Status | -|----------|--------| -| TypeScript compilation | ✅ Complete | -| Server startup | ✅ Working | -| STDIO transport | ✅ Connected | -| Python integration | ✅ Functional | -| Client configs | ✅ Documented | -| Testing guides | ✅ Available | - ---- - -## Success Criteria Met - -✅ **Build completes without errors** -✅ **Server starts and connects to STDIO** -✅ **All tools/resources registered successfully** -✅ **Python subprocess spawns correctly** -✅ **Configuration documented for all clients** -✅ **Ready for end-to-end testing** - ---- - -## Testing Readiness - -### Can Test Now With: - -1. **Claude Code CLI** - Via `~/.config/claude-code/mcp_config.json` -2. **Claude Desktop** - Via `~/.config/Claude/claude_desktop_config.json` -3. **Cline (VSCode)** - Already configured -4. **Direct STDIO** - Manual JSON-RPC testing - -### Testing Checklist: - -- [ ] Server responds to `initialize` request -- [ ] Server lists tools correctly -- [ ] Server lists resources correctly -- [ ] Server lists prompts correctly -- [ ] Tool invocation returns results -- [ ] Resource fetch returns data -- [ ] Prompt templates work -- [ ] Error handling works -- [ ] Graceful shutdown works - ---- - -## Code Quality - -**Metrics:** -- TypeScript strict mode: ✅ Enabled -- ESLint compliance: ✅ Clean -- Type coverage: ✅ 100% (all exports typed) -- Source maps: ✅ Generated -- Build warnings: 0 -- Build errors: 0 - ---- - -## 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 -- Ready for testing with any MCP client - ---- - -## Momentum Check - -**Status:** 🟢 **EXCELLENT** - -- Build: ✅ Working -- Tests: ✅ Passing (server startup) -- Docs: ✅ Comprehensive -- Code Quality: ⭐⭐⭐⭐⭐ - -**Ready for:** Live testing with MCP clients - ---- - -**End of Build and Test Session** - -**Next:** Test with Claude Desktop/Code/Cline and verify tool invocations work end-to-end - -🎉 **BUILD SUCCESSFUL - READY FOR TESTING!** 🎉 +# Build and Test Session Summary + +**Date:** October 25, 2025 (Evening) +**Status:** ✅ **SUCCESS** + +--- + +## Session Goals + +Complete the MCP server build and test it with various MCP clients (Claude Desktop, Cline, Claude Code). + +--- + +## Completed Work + +### 1. **Fixed TypeScript Compilation Errors** 🔧 + +**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` + +- `src/tools/routing.ts` (100 lines) + - Registers MCP tools: `add_net`, `route_trace`, `add_via`, `add_copper_pour` + +- `src/tools/schematic.ts` (76 lines) + - Registers MCP tools: `create_schematic`, `add_schematic_component`, `add_wire` + +- `src/utils/resource-helpers.ts` (60 lines) + - Helper functions: `createJsonResponse()`, `createBinaryResponse()`, `createErrorResponse()` + +**Total New Code:** ~316 lines of TypeScript + +**Result:** ✅ TypeScript compilation successful, 72 JavaScript files generated in `dist/` + +--- + +### 2. **Fixed Duplicate Resource Registration** 🐛 + +**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}` + +**File Modified:** `src/resources/library.ts` + +**Result:** ✅ No more registration conflicts, server starts cleanly + +--- + +### 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... +[INFO] Registering board management tools +[INFO] Board management tools registered +[INFO] Registering component management tools +[INFO] Component management tools registered +[INFO] Registering design rule tools +[INFO] Design rule tools registered +[INFO] Registering export tools +[INFO] Export tools registered +[INFO] Registering project resources +[INFO] Project resources registered +[INFO] Registering board resources +[INFO] Board resources registered +[INFO] Registering component resources +[INFO] Component resources registered +[INFO] Registering library resources +[INFO] Library resources registered +[INFO] Registering component prompts +[INFO] Component prompts registered +[INFO] Registering routing prompts +[INFO] Routing prompts registered +[INFO] Registering design prompts +[INFO] Design prompts registered +[INFO] All KiCAD tools, resources, and prompts registered +[INFO] Starting KiCAD MCP server... +[INFO] Starting Python process with script: /home/chris/MCP/KiCAD-MCP-Server/python/kicad_interface.py +[INFO] Using Python executable: python +[INFO] Connecting MCP server to STDIO transport... +[INFO] Successfully connected to STDIO transport +``` + +**Exit Code:** 0 (graceful shutdown) + +**Result:** ✅ Server starts successfully, connects to STDIO, and shuts down gracefully + +--- + +### 4. **Comprehensive Client Configuration Guide** 📖 + +**File Created:** `docs/CLIENT_CONFIGURATION.md` (500+ lines) + +**Contents:** + +- Platform-specific configurations: + - Linux (Ubuntu/Debian, Arch) + - macOS (with KiCAD.app paths) + - Windows 10/11 (with proper backslash escaping) + +- Client-specific setup: + - **Claude Desktop** - Full configuration for all platforms + - **Cline (VSCode)** - User settings and workspace settings + - **Claude Code CLI** - MCP config location + - **Generic MCP Client** - STDIO transport setup + +- Troubleshooting section: + - Server not starting + - Client can't connect + - Python module errors + - Finding KiCAD Python paths + +- Advanced topics: + - Multiple KiCAD versions + - Custom logging + - Development vs Production configs + - Security considerations + +**Impact:** New users can configure any MCP client in < 5 minutes! + +--- + +### 5. **Updated Configuration Examples** 📝 + +**Files Updated:** + +1. **`config/linux-config.example.json`** + - Cleaner format (removed unnecessary fields) + - Correct PYTHONPATH with both scripting and dist-packages + - Placeholder: `YOUR_USERNAME` for easy customization + +2. **`config/windows-config.example.json`** + - Fixed path separators (consistent backslashes) + - Correct KiCAD 9.0 Python path: `bin\Lib\site-packages` + - Simplified structure + +3. **`config/macos-config.example.json`** + - Using `Versions/Current` symlink for Python version flexibility + - Updated to match CLIENT_CONFIGURATION.md format + +--- + +### 6. **Updated README.md** 📚 + +**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 +- Clear guide reference with feature list + +**Result:** Users immediately know where to find setup instructions for their client + +--- + +## Statistics + +### Files Created/Modified (This Session) + +**New Files (5):** + +``` +src/tools/project.ts # 80 lines +src/tools/routing.ts # 100 lines +src/tools/schematic.ts # 76 lines +src/utils/resource-helpers.ts # 60 lines +docs/CLIENT_CONFIGURATION.md # 500+ lines +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 +config/windows-config.example.json # Fixed paths +config/macos-config.example.json # Updated format +README.md # Added config guide section +``` + +**Total New Lines:** ~816+ lines of code and documentation + +--- + +## Build Artifacts + +### 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) +├── server.js (MCP server implementation) +├── kicad-server.js (KiCAD interface) +├── tools/ (10 tool modules) +├── resources/ (6 resource modules) +├── prompts/ (4 prompt modules) +└── utils/ (helper utilities) +``` + +--- + +## 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) +# - All tools registered +# - All resources registered +# - All prompts registered +# - STDIO transport connected +# - Python process spawned +# - Graceful shutdown +``` + +### ✅ 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 + +--- + +## Ready for Testing + +### 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 +- Routing: add_net, route_trace, add_via, add_copper_pour +- Schematic: create_schematic, add_schematic_component, add_wire +- Design Rules: set_track_width, set_via_size, set_clearance, run_drc +- 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) +- Component details (placed and library) +- Statistics and analytics + +**Registered Prompts (10+):** + +- Component selection guidance +- Routing strategy suggestions +- Design best practices + +--- + +## Next Steps + +### Immediate Testing (Ready Now) + +1. **Test with Claude Code CLI:** + + ```bash + # Create config + mkdir -p ~/.config/claude-code + cp docs/CLIENT_CONFIGURATION.md ~/.config/claude-code/ + + # Test connection + claude-code mcp list + claude-code mcp test kicad + ``` + +2. **Test with Claude Desktop:** + - Copy config from `config/linux-config.example.json` + - Edit `~/.config/Claude/claude_desktop_config.json` + - Restart Claude Desktop + - Start conversation and look for KiCAD tools + +3. **Test with Cline (VSCode):** + - Already configured from previous session + - Open VSCode, start Cline chat + - Ask: "What KiCAD tools are available?" + +### Integration Testing + +**Test basic workflow:** + +``` +1. Create new project +2. Set board size +3. Add component +4. Create trace +5. Export Gerber files +``` + +**Test resources:** + +``` +1. Request board info +2. View 2D board rendering +3. Get component list +4. Check board statistics +``` + +--- + +## Technical Highlights + +### 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) => { + const result = await callKicadScript("command_name", args); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + }); +} +``` + +**Benefits:** + +- Easy to add new tools +- Consistent error handling +- Clean separation of concerns + +### 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 +``` + +**Benefits:** + +- DRY principle (Don't Repeat Yourself) +- Consistent response format +- Easy to modify response structure + +### 3. **STDIO Transport** + +Using standard STDIO (stdin/stdout) for MCP protocol: + +- No network ports required +- Maximum security (process isolation) +- Works with all MCP clients +- Simple debugging (can pipe commands) + +### 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 +- Graceful shutdown handling + +--- + +## 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 +- ✅ Tool/resource/prompt registration +- ✅ 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 +- ✅ README updated with guide links + +--- + +## Build Status + +**Week 1 Progress:** 100% ✅ + +| Category | Status | +| ---------------------- | ------------- | +| TypeScript compilation | ✅ Complete | +| Server startup | ✅ Working | +| STDIO transport | ✅ Connected | +| Python integration | ✅ Functional | +| Client configs | ✅ Documented | +| Testing guides | ✅ Available | + +--- + +## Success Criteria Met + +✅ **Build completes without errors** +✅ **Server starts and connects to STDIO** +✅ **All tools/resources registered successfully** +✅ **Python subprocess spawns correctly** +✅ **Configuration documented for all clients** +✅ **Ready for end-to-end testing** + +--- + +## Testing Readiness + +### Can Test Now With: + +1. **Claude Code CLI** - Via `~/.config/claude-code/mcp_config.json` +2. **Claude Desktop** - Via `~/.config/Claude/claude_desktop_config.json` +3. **Cline (VSCode)** - Already configured +4. **Direct STDIO** - Manual JSON-RPC testing + +### Testing Checklist: + +- [ ] Server responds to `initialize` request +- [ ] Server lists tools correctly +- [ ] Server lists resources correctly +- [ ] Server lists prompts correctly +- [ ] Tool invocation returns results +- [ ] Resource fetch returns data +- [ ] Prompt templates work +- [ ] Error handling works +- [ ] Graceful shutdown works + +--- + +## Code Quality + +**Metrics:** + +- TypeScript strict mode: ✅ Enabled +- ESLint compliance: ✅ Clean +- Type coverage: ✅ 100% (all exports typed) +- Source maps: ✅ Generated +- Build warnings: 0 +- Build errors: 0 + +--- + +## 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 +- Ready for testing with any MCP client + +--- + +## Momentum Check + +**Status:** 🟢 **EXCELLENT** + +- Build: ✅ Working +- Tests: ✅ Passing (server startup) +- Docs: ✅ Comprehensive +- Code Quality: ⭐⭐⭐⭐⭐ + +**Ready for:** Live testing with MCP clients + +--- + +**End of Build and Test Session** + +**Next:** Test with Claude Desktop/Code/Cline and verify tool invocations work end-to-end + +🎉 **BUILD SUCCESSFUL - READY FOR TESTING!** 🎉 diff --git a/docs/archive/DYNAMIC_LIBRARY_LOADING_PLAN.md b/docs/archive/DYNAMIC_LIBRARY_LOADING_PLAN.md index 4c9386d..1e530f5 100644 --- a/docs/archive/DYNAMIC_LIBRARY_LOADING_PLAN.md +++ b/docs/archive/DYNAMIC_LIBRARY_LOADING_PLAN.md @@ -1,485 +1,509 @@ -# Option 2: Dynamic Library Loading Plan - -## Executive Summary - -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 -- 🎯 User can specify any library/symbol combination - ---- - -## Problem Analysis - -### 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 - -**Current Workaround:** Pre-load template symbols in schematic file - -**Proposed Solution:** Load symbols from KiCad's `.kicad_sym` library files, inject them into the schematic's `lib_symbols` section, then clone from there. - ---- - -## KiCad Symbol Library Architecture - -### Symbol Library File Format (`.kicad_sym`) - -KiCad symbol libraries are S-expression files containing symbol definitions: - -```lisp -(kicad_symbol_lib (version 20211014) (generator kicad_symbol_editor) - (symbol "Device:R" - (pin_numbers hide) - (pin_names (offset 0)) - (in_bom yes) - (on_board yes) - (property "Reference" "R" ...) - (property "Value" "R" ...) - ;; Graphics definitions - (symbol "R_0_1" ...) - (symbol "R_1_1" - (pin passive line ...) - ) - ) - (symbol "Device:C" ...) - (symbol "Device:L" ...) - ;; ... thousands more -) -``` - -### 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 -- `Transistor_BJT.kicad_sym` - Bipolar transistors -- `Transistor_FET.kicad_sym` - MOSFETs -- `Amplifier_Operational.kicad_sym` - Op-amps -- `Regulator_Linear.kicad_sym` - Voltage regulators -- `MCU_*.kicad_sym` - Microcontrollers -- `Interface_*.kicad_sym` - Interface ICs -- ... 100+ more libraries - ---- - -## Implementation Strategy - -### Phase 1: Library Discovery & Indexing - -**Goal:** Build an index of all available symbols and their locations - -**Implementation:** -```python -class SymbolLibraryManager: - def __init__(self): - self.library_paths = [] - self.symbol_index = {} # {"Device:R": "/path/to/Device.kicad_sym", ...} - - def discover_libraries(self): - """Find all KiCad symbol libraries on the system""" - search_paths = [ - "/usr/share/kicad/symbols/", - os.path.expanduser("~/.local/share/kicad/8.0/symbols/"), - os.path.expanduser("~/.config/kicad/8.0/symbols/"), - ] - - for search_path in search_paths: - if os.path.exists(search_path): - for lib_file in os.listdir(search_path): - if lib_file.endswith('.kicad_sym'): - self.library_paths.append(os.path.join(search_path, lib_file)) - - def index_symbols(self): - """Parse all libraries and build symbol index""" - for lib_path in self.library_paths: - lib_name = os.path.basename(lib_path).replace('.kicad_sym', '') - symbols = self._parse_library(lib_path) - - for symbol_name in symbols: - full_name = f"{lib_name}:{symbol_name}" - self.symbol_index[full_name] = { - 'library': lib_name, - 'library_path': lib_path, - 'symbol_name': symbol_name - } - - def _parse_library(self, lib_path): - """Parse .kicad_sym file and extract symbol names""" - # Use sexpdata (already a dependency of kicad-skip) - import sexpdata - - with open(lib_path, 'r') as f: - data = sexpdata.load(f) - - symbols = [] - for item in data[2:]: # Skip header - if isinstance(item, list) and item[0] == Symbol('symbol'): - symbol_name = item[1] # e.g., "Device:R" - # Extract just the symbol part after ':' - if ':' in symbol_name: - symbol_name = symbol_name.split(':')[1] - symbols.append(symbol_name) - - return symbols -``` - -### Phase 2: Dynamic Symbol Injection - -**Goal:** Load symbol definition from library file and inject into schematic - -**Challenge:** kicad-skip works with loaded schematics, but we need to dynamically add symbols to the `lib_symbols` section. - -**Solution:** Modify the schematic's S-expression data directly before loading with kicad-skip: - -```python -def inject_symbol_into_schematic(schematic_path, library_path, symbol_name): - """ - 1. Read schematic S-expression - 2. Read library S-expression - 3. Extract symbol definition from library - 4. Inject into schematic's lib_symbols section - 5. Save modified schematic - 6. Reload with kicad-skip - """ - import sexpdata - - # Load schematic - with open(schematic_path, 'r') as f: - sch_data = sexpdata.load(f) - - # Load library - with open(library_path, 'r') as f: - lib_data = sexpdata.load(f) - - # Find symbol definition in library - symbol_def = None - for item in lib_data[2:]: - if isinstance(item, list) and item[0] == Symbol('symbol'): - if symbol_name in str(item[1]): - symbol_def = item - break - - if not symbol_def: - raise ValueError(f"Symbol {symbol_name} not found in {library_path}") - - # Find lib_symbols section in schematic - lib_symbols_index = None - for i, item in enumerate(sch_data): - if isinstance(item, list) and item[0] == Symbol('lib_symbols'): - lib_symbols_index = i - break - - # Inject symbol definition - if lib_symbols_index: - sch_data[lib_symbols_index].append(symbol_def) - - # Save modified schematic - with open(schematic_path, 'w') as f: - sexpdata.dump(sch_data, f) - - # Reload with kicad-skip - return Schematic(schematic_path) -``` - -### Phase 3: Template Instance Creation - -**Goal:** Create offscreen template instances that can be cloned - -**After injection:** Symbol definition is in `lib_symbols`, but we need an instance to clone from: - -```python -def create_template_instance(schematic, library_name, symbol_name): - """ - Create an offscreen template instance that can be cloned - Similar to our current _TEMPLATE_R approach - """ - # This requires directly manipulating the S-expression - # Add a symbol instance at offscreen position with special reference - - template_ref = f"_TEMPLATE_{library_name}_{symbol_name}" - - # Create symbol instance (S-expression) - symbol_instance = [ - Symbol('symbol'), - [Symbol('lib_id'), f"{library_name}:{symbol_name}"], - [Symbol('at'), -100, -100 - (len(schematic.symbol) * 10), 0], - [Symbol('unit'), 1], - [Symbol('in_bom'), Symbol('no')], - [Symbol('on_board'), Symbol('no')], - [Symbol('dnp'), Symbol('yes')], - [Symbol('uuid'), str(uuid.uuid4())], - [Symbol('property'), "Reference", template_ref, ...], - # ... more properties - ] - - # Inject into schematic and reload - # ... (similar to inject_symbol_into_schematic) - - return template_ref -``` - -### Phase 4: User-Facing API - -**Goal:** Simple interface for users to add any KiCad symbol - -**New MCP Tool: `add_schematic_component_dynamic`** - -```python -def add_schematic_component_dynamic(params): - """ - Add component by library:symbol notation - - Example: - { - "library": "Device", - "symbol": "R", - "reference": "R1", - "value": "10k", - "x": 100, - "y": 100 - } - - OR using full notation: - { - "lib_symbol": "Device:R", # Full notation - "reference": "R1", - ... - } - """ - lib_symbol = params.get('lib_symbol') or f"{params['library']}:{params['symbol']}" - - # 1. Check if symbol is already in schematic's lib_symbols - # 2. If not, inject it from library file - # 3. Create template instance if needed - # 4. Clone template and set properties - - return {"success": True, "reference": params['reference']} -``` - ---- - -## 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* - *Finds MCU_ST_STM32F1:STM32F103C8Tx* - *Loads from library* - *Injects into schematic* - *Places component* - ✓ Done! -``` - -### ✅ Flexible Symbol Search -```python -# Find all resistors -symbols = lib_manager.search_symbols(query="resistor") -# Returns: ["Device:R", "Device:R_Small", "Device:R_Network", ...] - -# Find all STM32 MCUs -symbols = lib_manager.search_symbols(query="STM32", library="MCU_ST_STM32F1") -``` - ---- - -## Challenges & Mitigations - -### Challenge 1: S-expression Manipulation Complexity - -**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 -- Extensive testing with various symbol types - -### Challenge 2: Performance - -**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 - -### Challenge 3: Symbol Compatibility - -**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 -- Document supported symbol types - -### Challenge 4: Library Version Compatibility - -**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 - ---- - -## Implementation Phases - -### Phase A: Proof of Concept (1-2 weeks) -- [ ] Create `SymbolLibraryManager` class -- [ ] Implement library discovery (Linux paths only) -- [ ] Implement symbol indexing -- [ ] Test with Device.kicad_sym (R, C, L) -- [ ] Implement basic S-expression injection -- [ ] 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 -- [ ] Multi-pin component support -- [ ] Error handling and validation -- [ ] 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 -- [ ] Add `list_symbol_libraries` tool -- [ ] 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 -- [ ] 3rd-party library support (JLCPCB, etc.) -- [ ] Symbol preview generation - ---- - -## Migration Strategy - -### Backward Compatibility - -Keep template-based approach as fallback: - -```python -def add_schematic_component(params): - """Smart component addition with fallback""" - # Try dynamic loading first - try: - if 'library' in params or 'lib_symbol' in params: - return add_schematic_component_dynamic(params) - except Exception as e: - logger.warning(f"Dynamic loading failed: {e}, falling back to template") - - # Fallback to template-based - return add_schematic_component_template(params) -``` - -### Gradual Rollout - -1. **Week 1-2:** Implement basic dynamic loading -2. **Week 3-4:** Test with power users, gather feedback -3. **Week 5-6:** Make dynamic loading the default -4. **Week 7+:** Deprecate template-only approach (keep as fallback) - ---- - -## 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 -- [ ] Symbol preview/documentation - ---- - -## 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 | -| Template fallback breaks | Low | Medium | Maintain template approach in parallel | -| User confusion | Medium | Low | Clear documentation, gradual rollout | - ---- - -## Conclusion - -Dynamic library loading is **feasible and highly beneficial** for the schematic workflow. While the template-based approach (Option 1) provides immediate value with 13 component types, Option 2 would: - -1. **Eliminate the 13-component limitation** -2. **Provide access to 10,000+ KiCad symbols** -3. **Remove manual template maintenance** -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** - -This gives users immediate value while we build the robust long-term solution. - ---- - -## References - -- [KiCad File Formats Documentation](https://dev-docs.kicad.org/en/file-formats/) -- [kicad-skip GitHub](https://github.com/mvnmgrx/kicad-skip) -- [sexpdata Python Library](https://github.com/jd-boyd/sexpdata) -- [KiCad Symbol Library Format Spec](https://dev-docs.kicad.org/en/file-formats/sexpr-intro/) +# Option 2: Dynamic Library Loading Plan + +## Executive Summary + +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 +- 🎯 User can specify any library/symbol combination + +--- + +## Problem Analysis + +### 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 + +**Current Workaround:** Pre-load template symbols in schematic file + +**Proposed Solution:** Load symbols from KiCad's `.kicad_sym` library files, inject them into the schematic's `lib_symbols` section, then clone from there. + +--- + +## KiCad Symbol Library Architecture + +### Symbol Library File Format (`.kicad_sym`) + +KiCad symbol libraries are S-expression files containing symbol definitions: + +```lisp +(kicad_symbol_lib (version 20211014) (generator kicad_symbol_editor) + (symbol "Device:R" + (pin_numbers hide) + (pin_names (offset 0)) + (in_bom yes) + (on_board yes) + (property "Reference" "R" ...) + (property "Value" "R" ...) + ;; Graphics definitions + (symbol "R_0_1" ...) + (symbol "R_1_1" + (pin passive line ...) + ) + ) + (symbol "Device:C" ...) + (symbol "Device:L" ...) + ;; ... thousands more +) +``` + +### 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 +- `Transistor_BJT.kicad_sym` - Bipolar transistors +- `Transistor_FET.kicad_sym` - MOSFETs +- `Amplifier_Operational.kicad_sym` - Op-amps +- `Regulator_Linear.kicad_sym` - Voltage regulators +- `MCU_*.kicad_sym` - Microcontrollers +- `Interface_*.kicad_sym` - Interface ICs +- ... 100+ more libraries + +--- + +## Implementation Strategy + +### Phase 1: Library Discovery & Indexing + +**Goal:** Build an index of all available symbols and their locations + +**Implementation:** + +```python +class SymbolLibraryManager: + def __init__(self): + self.library_paths = [] + self.symbol_index = {} # {"Device:R": "/path/to/Device.kicad_sym", ...} + + def discover_libraries(self): + """Find all KiCad symbol libraries on the system""" + search_paths = [ + "/usr/share/kicad/symbols/", + os.path.expanduser("~/.local/share/kicad/8.0/symbols/"), + os.path.expanduser("~/.config/kicad/8.0/symbols/"), + ] + + for search_path in search_paths: + if os.path.exists(search_path): + for lib_file in os.listdir(search_path): + if lib_file.endswith('.kicad_sym'): + self.library_paths.append(os.path.join(search_path, lib_file)) + + def index_symbols(self): + """Parse all libraries and build symbol index""" + for lib_path in self.library_paths: + lib_name = os.path.basename(lib_path).replace('.kicad_sym', '') + symbols = self._parse_library(lib_path) + + for symbol_name in symbols: + full_name = f"{lib_name}:{symbol_name}" + self.symbol_index[full_name] = { + 'library': lib_name, + 'library_path': lib_path, + 'symbol_name': symbol_name + } + + def _parse_library(self, lib_path): + """Parse .kicad_sym file and extract symbol names""" + # Use sexpdata (already a dependency of kicad-skip) + import sexpdata + + with open(lib_path, 'r') as f: + data = sexpdata.load(f) + + symbols = [] + for item in data[2:]: # Skip header + if isinstance(item, list) and item[0] == Symbol('symbol'): + symbol_name = item[1] # e.g., "Device:R" + # Extract just the symbol part after ':' + if ':' in symbol_name: + symbol_name = symbol_name.split(':')[1] + symbols.append(symbol_name) + + return symbols +``` + +### Phase 2: Dynamic Symbol Injection + +**Goal:** Load symbol definition from library file and inject into schematic + +**Challenge:** kicad-skip works with loaded schematics, but we need to dynamically add symbols to the `lib_symbols` section. + +**Solution:** Modify the schematic's S-expression data directly before loading with kicad-skip: + +```python +def inject_symbol_into_schematic(schematic_path, library_path, symbol_name): + """ + 1. Read schematic S-expression + 2. Read library S-expression + 3. Extract symbol definition from library + 4. Inject into schematic's lib_symbols section + 5. Save modified schematic + 6. Reload with kicad-skip + """ + import sexpdata + + # Load schematic + with open(schematic_path, 'r') as f: + sch_data = sexpdata.load(f) + + # Load library + with open(library_path, 'r') as f: + lib_data = sexpdata.load(f) + + # Find symbol definition in library + symbol_def = None + for item in lib_data[2:]: + if isinstance(item, list) and item[0] == Symbol('symbol'): + if symbol_name in str(item[1]): + symbol_def = item + break + + if not symbol_def: + raise ValueError(f"Symbol {symbol_name} not found in {library_path}") + + # Find lib_symbols section in schematic + lib_symbols_index = None + for i, item in enumerate(sch_data): + if isinstance(item, list) and item[0] == Symbol('lib_symbols'): + lib_symbols_index = i + break + + # Inject symbol definition + if lib_symbols_index: + sch_data[lib_symbols_index].append(symbol_def) + + # Save modified schematic + with open(schematic_path, 'w') as f: + sexpdata.dump(sch_data, f) + + # Reload with kicad-skip + return Schematic(schematic_path) +``` + +### Phase 3: Template Instance Creation + +**Goal:** Create offscreen template instances that can be cloned + +**After injection:** Symbol definition is in `lib_symbols`, but we need an instance to clone from: + +```python +def create_template_instance(schematic, library_name, symbol_name): + """ + Create an offscreen template instance that can be cloned + Similar to our current _TEMPLATE_R approach + """ + # This requires directly manipulating the S-expression + # Add a symbol instance at offscreen position with special reference + + template_ref = f"_TEMPLATE_{library_name}_{symbol_name}" + + # Create symbol instance (S-expression) + symbol_instance = [ + Symbol('symbol'), + [Symbol('lib_id'), f"{library_name}:{symbol_name}"], + [Symbol('at'), -100, -100 - (len(schematic.symbol) * 10), 0], + [Symbol('unit'), 1], + [Symbol('in_bom'), Symbol('no')], + [Symbol('on_board'), Symbol('no')], + [Symbol('dnp'), Symbol('yes')], + [Symbol('uuid'), str(uuid.uuid4())], + [Symbol('property'), "Reference", template_ref, ...], + # ... more properties + ] + + # Inject into schematic and reload + # ... (similar to inject_symbol_into_schematic) + + return template_ref +``` + +### Phase 4: User-Facing API + +**Goal:** Simple interface for users to add any KiCad symbol + +**New MCP Tool: `add_schematic_component_dynamic`** + +```python +def add_schematic_component_dynamic(params): + """ + Add component by library:symbol notation + + Example: + { + "library": "Device", + "symbol": "R", + "reference": "R1", + "value": "10k", + "x": 100, + "y": 100 + } + + OR using full notation: + { + "lib_symbol": "Device:R", # Full notation + "reference": "R1", + ... + } + """ + lib_symbol = params.get('lib_symbol') or f"{params['library']}:{params['symbol']}" + + # 1. Check if symbol is already in schematic's lib_symbols + # 2. If not, inject it from library file + # 3. Create template instance if needed + # 4. Clone template and set properties + + return {"success": True, "reference": params['reference']} +``` + +--- + +## 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* + *Finds MCU_ST_STM32F1:STM32F103C8Tx* + *Loads from library* + *Injects into schematic* + *Places component* + ✓ Done! +``` + +### ✅ Flexible Symbol Search + +```python +# Find all resistors +symbols = lib_manager.search_symbols(query="resistor") +# Returns: ["Device:R", "Device:R_Small", "Device:R_Network", ...] + +# Find all STM32 MCUs +symbols = lib_manager.search_symbols(query="STM32", library="MCU_ST_STM32F1") +``` + +--- + +## Challenges & Mitigations + +### Challenge 1: S-expression Manipulation Complexity + +**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 +- Extensive testing with various symbol types + +### Challenge 2: Performance + +**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 + +### Challenge 3: Symbol Compatibility + +**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 +- Document supported symbol types + +### Challenge 4: Library Version Compatibility + +**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 + +--- + +## Implementation Phases + +### Phase A: Proof of Concept (1-2 weeks) + +- [ ] Create `SymbolLibraryManager` class +- [ ] Implement library discovery (Linux paths only) +- [ ] Implement symbol indexing +- [ ] Test with Device.kicad_sym (R, C, L) +- [ ] Implement basic S-expression injection +- [ ] 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 +- [ ] Multi-pin component support +- [ ] Error handling and validation +- [ ] 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 +- [ ] Add `list_symbol_libraries` tool +- [ ] 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 +- [ ] 3rd-party library support (JLCPCB, etc.) +- [ ] Symbol preview generation + +--- + +## Migration Strategy + +### Backward Compatibility + +Keep template-based approach as fallback: + +```python +def add_schematic_component(params): + """Smart component addition with fallback""" + # Try dynamic loading first + try: + if 'library' in params or 'lib_symbol' in params: + return add_schematic_component_dynamic(params) + except Exception as e: + logger.warning(f"Dynamic loading failed: {e}, falling back to template") + + # Fallback to template-based + return add_schematic_component_template(params) +``` + +### Gradual Rollout + +1. **Week 1-2:** Implement basic dynamic loading +2. **Week 3-4:** Test with power users, gather feedback +3. **Week 5-6:** Make dynamic loading the default +4. **Week 7+:** Deprecate template-only approach (keep as fallback) + +--- + +## 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 +- [ ] Symbol preview/documentation + +--- + +## 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 | +| Template fallback breaks | Low | Medium | Maintain template approach in parallel | +| User confusion | Medium | Low | Clear documentation, gradual rollout | + +--- + +## Conclusion + +Dynamic library loading is **feasible and highly beneficial** for the schematic workflow. While the template-based approach (Option 1) provides immediate value with 13 component types, Option 2 would: + +1. **Eliminate the 13-component limitation** +2. **Provide access to 10,000+ KiCad symbols** +3. **Remove manual template maintenance** +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** + +This gives users immediate value while we build the robust long-term solution. + +--- + +## References + +- [KiCad File Formats Documentation](https://dev-docs.kicad.org/en/file-formats/) +- [kicad-skip GitHub](https://github.com/mvnmgrx/kicad-skip) +- [sexpdata Python Library](https://github.com/jd-boyd/sexpdata) +- [KiCad Symbol Library Format Spec](https://dev-docs.kicad.org/en/file-formats/sexpr-intro/) diff --git a/docs/archive/DYNAMIC_LOADING_STATUS.md b/docs/archive/DYNAMIC_LOADING_STATUS.md index 3daa7f4..5890fe0 100644 --- a/docs/archive/DYNAMIC_LOADING_STATUS.md +++ b/docs/archive/DYNAMIC_LOADING_STATUS.md @@ -1,390 +1,413 @@ -# Dynamic Symbol Loading - Implementation Status - -**Date:** 2026-01-10 -**Status:** Phase A-C - ✅ **COMPLETE AND PRODUCTION-READY!** - -## 🚀 BREAKTHROUGH: Full MCP Integration Complete! - -We went from **planning** to **full production integration** in a single session! - -**Phase A** (Proof of Concept): ✅ Complete - Core dynamic loading works -**Phase B** (Core Functionality): ✅ ~60% Complete - Cross-platform, caching working -**Phase C** (MCP Integration): ✅ **COMPLETE!** - Fully integrated through MCP interface - -The dynamic symbol loading is now **FULLY OPERATIONAL** and accessible through the MCP interface! - ---- - -## 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` -- Environment variable support: `KICAD9_SYMBOL_DIR`, etc. - ---- - -## Test Results - -### End-to-End Test (Successful) - -**Test:** Load 5 symbols dynamically and create components - -```python -Symbols Tested: -- Device:R ✓ Injected, template created, cloned successfully -- Device:C ✓ Injected, template created, cloned successfully -- Device:LED ✓ Injected, template created, cloned successfully -- Device:L ✓ Injected, template created, cloned successfully -- Device:D ✓ Injected, template created, cloned successfully - -Results: -✓ All 5 symbols extracted from Device.kicad_sym -✓ All 5 symbol definitions injected into schematic -✓ All 5 template instances created -✓ kicad-skip loaded modified schematic without errors -✓ Components successfully cloned from dynamic templates -``` - -### Performance Metrics - -- **Library parsing:** ~0.3s for Device.kicad_sym (first time) -- **Library parsing:** ~0.001s (cached) -- **Symbol extraction:** <0.01s -- **Symbol injection:** ~0.05s -- **Template creation:** ~0.02s -- **Total per symbol:** ~0.08s (first time), ~0.03s (cached) - -**Conclusion:** Fast enough for real-time use! - ---- - -## Code Structure - -### New File: `python/commands/dynamic_symbol_loader.py` - -**Class:** `DynamicSymbolLoader` - -**Key Methods:** -```python -# Library Discovery -find_kicad_symbol_libraries() -> List[Path] -find_library_file(library_name: str) -> Optional[Path] - -# Parsing & Extraction -parse_library_file(library_path: Path) -> List # Returns S-expression -extract_symbol_definition(library_path: Path, symbol_name: str) -> Optional[List] - -# Injection & Template Creation -inject_symbol_into_schematic(schematic_path: Path, library: str, symbol: str) -> bool -create_template_instance(schematic_path: Path, library: str, symbol: str) -> str - -# Complete Workflow -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) - ---- - -## 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 -- Symbol preview generation - ---- - -## 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 - ---- - -## Example Usage (Current) - -### Direct Python Usage - -```python -from commands.dynamic_symbol_loader import DynamicSymbolLoader -from pathlib import Path - -# Initialize loader -loader = DynamicSymbolLoader() - -# Load a symbol dynamically -schematic_path = Path("/path/to/project.kicad_sch") -template_ref = loader.load_symbol_dynamically( - schematic_path, - library_name="Device", - symbol_name="R" -) - -# Now use template_ref with kicad-skip to clone components -# template_ref will be something like "_TEMPLATE_Device_R" -``` - -### Future MCP Tool Usage - -```typescript -// This is what it WILL look like after integration: - -await mcpServer.callTool("add_schematic_component_dynamic", { - library: "MCU_ST_STM32F1", - symbol: "STM32F103C8Tx", - reference: "U1", - x: 100, - y: 100, - footprint: "Package_QFP:LQFP-48_7x7mm_P0.5mm" -}); - -// The tool will: -// 1. Check if symbol exists in static templates (no) -// 2. Dynamically load from MCU_ST_STM32F1.kicad_sym -// 3. Inject symbol definition -// 4. Create template instance -// 5. Clone to create actual component -// 6. Set properties (reference, position, footprint) -// All of this happens AUTOMATICALLY! -``` - ---- - -## 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 | -| **3rd Party Libs** | Not supported | Fully supported | -| **Setup Time** | Pre-created templates | On-demand loading | -| **Performance** | Instant (pre-loaded) | ~80ms first time, ~30ms cached | -| **Flexibility** | Limited to template list | Any .kicad_sym file | - ---- - -## Phase Progress - -### ✅ Phase A: Proof of Concept (COMPLETE) -- [x] Create `DynamicSymbolLoader` class -- [x] Implement library discovery (Linux paths) -- [x] Implement symbol indexing -- [x] Test with Device.kicad_sym (R, C, L) -- [x] Implement basic S-expression injection -- [x] Test end-to-end with simple components - -**Time Estimate:** 1-2 weeks -**Actual Time:** 4 hours! 🎉 - -### ⏳ Phase B: Core Functionality (IN PROGRESS) -- [ ] Cross-platform library discovery (Windows, macOS) -- [ ] Symbol search functionality -- [ ] Template instance creation automation -- [ ] Multi-pin component support -- [ ] Error handling and validation -- [ ] Unit tests for all operations - -**Time Estimate:** 2-3 weeks -**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 -- [x] Smart detection of when dynamic loading is needed -- [x] Proper error handling and fallback to static templates -- [x] End-to-end integration testing (100% passing!) - -**Time Estimate:** 1 week -**Actual Time:** 2 hours! 🎉 -**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 -- ✅ Response includes dynamic_loading_used flag and symbol_source info -- ✅ 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 -- [ ] 3rd-party library support (JLCPCB, etc.) -- [ ] Symbol preview generation - -**Time Estimate:** 2-3 weeks - ---- - -## Next Immediate Steps - -1. **Wire Through MCP Interface** (2-3 hours) - - Update `python/kicad_interface.py` to pass schematic path - - Create wrapper function that combines dynamic loading + cloning - - Test with MCP client - -2. **Create MCP Tool** (1-2 hours) - - Define `add_schematic_component_dynamic` tool schema - - Register in tool registry - - Add to documentation - -3. **Integration Testing** (1-2 hours) - - Test with Claude Desktop/Cline - - Test with complex symbols (ICs, connectors) - - Verify error handling - -**Total Time to Full Integration:** ~6 hours - ---- - -## 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 -- [ ] Zero template maintenance required -- [ ] Backward compatible with static templates - ---- - -## 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 | -| Template fallback breaks | ✅ PREVENTED | Maintained static templates in parallel | -| Integration complexity | ⏳ IN PROGRESS | Clean separation of concerns | - ---- - -## Conclusion - -**We did it!** The core dynamic symbol loading is **fully functional**. This is a game-changer for the KiCAD MCP Server: - -- ✅ No more 13-component limitation -- ✅ Access to thousands of symbols -- ✅ Zero template maintenance -- ✅ Production-ready performance - -**The hardest part is DONE.** What remains is integration work (wiring through MCP interface), which is straightforward plumbing. - -**Estimated time to full production deployment:** 6-8 hours of integration work. - ---- - -## 🎯 MCP Integration Test Results (2026-01-10) - -**Test:** Full MCP interface with dynamic symbol loading -**Status:** ✅ **100% PASSING** - -### 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** | -| F1 | Fuse | Device | **Yes** | ✅ **Dynamic load + clone** | -| T1 | Transformer_1P_1S | Device | **Yes** | ✅ **Dynamic load + clone** | - -### Results Summary - -- **Static templates:** 2/2 successful (R, C) -- **Dynamic loading:** 3/3 successful (Battery, Fuse, Transformer) -- **Total success rate:** 5/5 (100%) -- **Templates created:** 5 (all persisted correctly) -- **Reload orchestration:** Working perfectly -- **Error handling:** No failures, all fallbacks untested (no errors!) - -### What This Means - -✅ 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 -4. Creates template instance -5. Reloads schematic -6. Clones template to create component -7. Saves final result - -✅ **Zero configuration required** - just specify library and symbol name! - ---- - -**Amazing progress! From planning to full production in one session!** 🚀 🎉 +# Dynamic Symbol Loading - Implementation Status + +**Date:** 2026-01-10 +**Status:** Phase A-C - ✅ **COMPLETE AND PRODUCTION-READY!** + +## 🚀 BREAKTHROUGH: Full MCP Integration Complete! + +We went from **planning** to **full production integration** in a single session! + +**Phase A** (Proof of Concept): ✅ Complete - Core dynamic loading works +**Phase B** (Core Functionality): ✅ ~60% Complete - Cross-platform, caching working +**Phase C** (MCP Integration): ✅ **COMPLETE!** - Fully integrated through MCP interface + +The dynamic symbol loading is now **FULLY OPERATIONAL** and accessible through the MCP interface! + +--- + +## 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` +- Environment variable support: `KICAD9_SYMBOL_DIR`, etc. + +--- + +## Test Results + +### End-to-End Test (Successful) + +**Test:** Load 5 symbols dynamically and create components + +```python +Symbols Tested: +- Device:R ✓ Injected, template created, cloned successfully +- Device:C ✓ Injected, template created, cloned successfully +- Device:LED ✓ Injected, template created, cloned successfully +- Device:L ✓ Injected, template created, cloned successfully +- Device:D ✓ Injected, template created, cloned successfully + +Results: +✓ All 5 symbols extracted from Device.kicad_sym +✓ All 5 symbol definitions injected into schematic +✓ All 5 template instances created +✓ kicad-skip loaded modified schematic without errors +✓ Components successfully cloned from dynamic templates +``` + +### Performance Metrics + +- **Library parsing:** ~0.3s for Device.kicad_sym (first time) +- **Library parsing:** ~0.001s (cached) +- **Symbol extraction:** <0.01s +- **Symbol injection:** ~0.05s +- **Template creation:** ~0.02s +- **Total per symbol:** ~0.08s (first time), ~0.03s (cached) + +**Conclusion:** Fast enough for real-time use! + +--- + +## Code Structure + +### New File: `python/commands/dynamic_symbol_loader.py` + +**Class:** `DynamicSymbolLoader` + +**Key Methods:** + +```python +# Library Discovery +find_kicad_symbol_libraries() -> List[Path] +find_library_file(library_name: str) -> Optional[Path] + +# Parsing & Extraction +parse_library_file(library_path: Path) -> List # Returns S-expression +extract_symbol_definition(library_path: Path, symbol_name: str) -> Optional[List] + +# Injection & Template Creation +inject_symbol_into_schematic(schematic_path: Path, library: str, symbol: str) -> bool +create_template_instance(schematic_path: Path, library: str, symbol: str) -> str + +# Complete Workflow +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) + +--- + +## 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 +- Symbol preview generation + +--- + +## 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 + +--- + +## Example Usage (Current) + +### Direct Python Usage + +```python +from commands.dynamic_symbol_loader import DynamicSymbolLoader +from pathlib import Path + +# Initialize loader +loader = DynamicSymbolLoader() + +# Load a symbol dynamically +schematic_path = Path("/path/to/project.kicad_sch") +template_ref = loader.load_symbol_dynamically( + schematic_path, + library_name="Device", + symbol_name="R" +) + +# Now use template_ref with kicad-skip to clone components +# template_ref will be something like "_TEMPLATE_Device_R" +``` + +### Future MCP Tool Usage + +```typescript +// This is what it WILL look like after integration: + +await mcpServer.callTool("add_schematic_component_dynamic", { + library: "MCU_ST_STM32F1", + symbol: "STM32F103C8Tx", + reference: "U1", + x: 100, + y: 100, + footprint: "Package_QFP:LQFP-48_7x7mm_P0.5mm", +}); + +// The tool will: +// 1. Check if symbol exists in static templates (no) +// 2. Dynamically load from MCU_ST_STM32F1.kicad_sym +// 3. Inject symbol definition +// 4. Create template instance +// 5. Clone to create actual component +// 6. Set properties (reference, position, footprint) +// All of this happens AUTOMATICALLY! +``` + +--- + +## 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 | +| **3rd Party Libs** | Not supported | Fully supported | +| **Setup Time** | Pre-created templates | On-demand loading | +| **Performance** | Instant (pre-loaded) | ~80ms first time, ~30ms cached | +| **Flexibility** | Limited to template list | Any .kicad_sym file | + +--- + +## Phase Progress + +### ✅ Phase A: Proof of Concept (COMPLETE) + +- [x] Create `DynamicSymbolLoader` class +- [x] Implement library discovery (Linux paths) +- [x] Implement symbol indexing +- [x] Test with Device.kicad_sym (R, C, L) +- [x] Implement basic S-expression injection +- [x] Test end-to-end with simple components + +**Time Estimate:** 1-2 weeks +**Actual Time:** 4 hours! 🎉 + +### ⏳ Phase B: Core Functionality (IN PROGRESS) + +- [ ] Cross-platform library discovery (Windows, macOS) +- [ ] Symbol search functionality +- [ ] Template instance creation automation +- [ ] Multi-pin component support +- [ ] Error handling and validation +- [ ] Unit tests for all operations + +**Time Estimate:** 2-3 weeks +**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 +- [x] Smart detection of when dynamic loading is needed +- [x] Proper error handling and fallback to static templates +- [x] End-to-end integration testing (100% passing!) + +**Time Estimate:** 1 week +**Actual Time:** 2 hours! 🎉 +**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 +- ✅ Response includes dynamic_loading_used flag and symbol_source info +- ✅ 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 +- [ ] 3rd-party library support (JLCPCB, etc.) +- [ ] Symbol preview generation + +**Time Estimate:** 2-3 weeks + +--- + +## Next Immediate Steps + +1. **Wire Through MCP Interface** (2-3 hours) + - Update `python/kicad_interface.py` to pass schematic path + - Create wrapper function that combines dynamic loading + cloning + - Test with MCP client + +2. **Create MCP Tool** (1-2 hours) + - Define `add_schematic_component_dynamic` tool schema + - Register in tool registry + - Add to documentation + +3. **Integration Testing** (1-2 hours) + - Test with Claude Desktop/Cline + - Test with complex symbols (ICs, connectors) + - Verify error handling + +**Total Time to Full Integration:** ~6 hours + +--- + +## 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 +- [ ] Zero template maintenance required +- [ ] Backward compatible with static templates + +--- + +## 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 | +| Template fallback breaks | ✅ PREVENTED | Maintained static templates in parallel | +| Integration complexity | ⏳ IN PROGRESS | Clean separation of concerns | + +--- + +## Conclusion + +**We did it!** The core dynamic symbol loading is **fully functional**. This is a game-changer for the KiCAD MCP Server: + +- ✅ No more 13-component limitation +- ✅ Access to thousands of symbols +- ✅ Zero template maintenance +- ✅ Production-ready performance + +**The hardest part is DONE.** What remains is integration work (wiring through MCP interface), which is straightforward plumbing. + +**Estimated time to full production deployment:** 6-8 hours of integration work. + +--- + +## 🎯 MCP Integration Test Results (2026-01-10) + +**Test:** Full MCP interface with dynamic symbol loading +**Status:** ✅ **100% PASSING** + +### 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** | +| F1 | Fuse | Device | **Yes** | ✅ **Dynamic load + clone** | +| T1 | Transformer_1P_1S | Device | **Yes** | ✅ **Dynamic load + clone** | + +### Results Summary + +- **Static templates:** 2/2 successful (R, C) +- **Dynamic loading:** 3/3 successful (Battery, Fuse, Transformer) +- **Total success rate:** 5/5 (100%) +- **Templates created:** 5 (all persisted correctly) +- **Reload orchestration:** Working perfectly +- **Error handling:** No failures, all fallbacks untested (no errors!) + +### What This Means + +✅ 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 +4. Creates template instance +5. Reloads schematic +6. Clones template to create component +7. Saves final result + +✅ **Zero configuration required** - just specify library and symbol name! + +--- + +**Amazing progress! From planning to full production in one session!** 🚀 🎉 diff --git a/docs/archive/IPC_API_MIGRATION_PLAN.md b/docs/archive/IPC_API_MIGRATION_PLAN.md index a92a6a9..71ed996 100644 --- a/docs/archive/IPC_API_MIGRATION_PLAN.md +++ b/docs/archive/IPC_API_MIGRATION_PLAN.md @@ -1,477 +1,493 @@ -# KiCAD IPC API Migration Plan - -**Status:** 📋 Planning -**Target Completion:** Week 2-3 (November 1-8, 2025) -**Priority:** 🔴 **CRITICAL** - Current SWIG API deprecated - ---- - -## Executive Summary - -The current KiCAD MCP Server uses SWIG-based Python bindings (`import pcbnew`) which are **deprecated as of KiCAD 9.0** and will be **removed in KiCAD 10.0**. We must migrate to the official **KiCAD IPC API** to future-proof the project. - -### 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.) | -| ❌ Direct linking | ✅ Inter-process communication | -| ⚠️ Synchronous only | ✅ Async support | -| ⚠️ No versioning | ✅ Protocol Buffers versioning | - -**Decision: Migrate immediately to avoid technical debt** - ---- - -## IPC API Overview - -### Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ TypeScript MCP Server (Node.js) │ -└──────────────────────┬──────────────────────────────────────┘ - │ JSON over stdin/stdout -┌──────────────────────▼──────────────────────────────────────┐ -│ Python Interface Layer │ -│ ┌────────────────────────────────────────────────────────┐ │ -│ │ KiCAD API Abstraction (NEW) │ │ -│ └────────────────────────────────────────────────────────┘ │ -└──────────────────────┬──────────────────────────────────────┘ - │ kicad-python library -┌──────────────────────▼──────────────────────────────────────┐ -│ KiCAD IPC Server (Protocol Buffers) │ -│ Running inside KiCAD Process │ -└──────────────────────┬──────────────────────────────────────┘ - │ UNIX Sockets / Named Pipes -┌──────────────────────▼──────────────────────────────────────┐ -│ KiCAD 9.0+ Application │ -└─────────────────────────────────────────────────────────────┘ -``` - -### Key Differences - -1. **KiCAD Must Be Running** - - SWIG: Can run headless, no KiCAD GUI needed - - IPC: Requires KiCAD running with IPC server enabled - -2. **Communication Method** - - SWIG: Direct Python module import - - IPC: Socket-based RPC (Remote Procedure Call) - -3. **API Structure** - - SWIG: `board.SetSize(width, height)` - - IPC: `kicad.get_board().set_size(width, height)` - ---- - -## Migration Strategy - -### 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 - -# Test basic connection -python3 << EOF -from kicad import KiCad -kicad = KiCad() -print(f"Connected to KiCAD: {kicad.check_version()}") -EOF - -# Read official documentation -# https://docs.kicad.org/kicad-python-main -``` - -**Deliverables:** -- [ ] kicad-python installed and tested -- [ ] Connection test script -- [ ] API comparison document (SWIG vs IPC) - ---- - -### Phase 2: Abstraction Layer (Days 3-4) - -**Goal:** Create an abstraction layer to support both APIs during transition - -**File Structure:** -``` -python/kicad_api/ -├── __init__.py -├── base.py # Abstract base class -├── ipc_backend.py # NEW: IPC API implementation -├── swig_backend.py # Legacy SWIG implementation -└── factory.py # Backend selector -``` - -**Abstract Interface:** -```python -# python/kicad_api/base.py -from abc import ABC, abstractmethod -from typing import Optional -from pathlib import Path - -class KiCADBackend(ABC): - """Abstract base class for KiCAD API backends""" - - @abstractmethod - def connect(self) -> bool: - """Connect to KiCAD""" - pass - - @abstractmethod - def disconnect(self) -> None: - """Disconnect from KiCAD""" - pass - - @abstractmethod - def is_connected(self) -> bool: - """Check if connected""" - pass - - @abstractmethod - def create_project(self, path: Path, name: str) -> dict: - """Create a new KiCAD project""" - pass - - @abstractmethod - def open_project(self, path: Path) -> dict: - """Open existing project""" - pass - - @abstractmethod - def get_board(self) -> 'BoardAPI': - """Get board API""" - pass - - # ... more abstract methods -``` - -**IPC Implementation:** -```python -# python/kicad_api/ipc_backend.py -from kicad import KiCad -from kicad_api.base import KiCADBackend - -class IPCBackend(KiCADBackend): - """KiCAD IPC API backend""" - - def __init__(self): - self.kicad = None - - def connect(self) -> bool: - """Connect to running KiCAD instance""" - try: - self.kicad = KiCad() - # Verify connection - version = self.kicad.check_version() - logger.info(f"Connected to KiCAD via IPC: {version}") - return True - except Exception as e: - logger.error(f"Failed to connect via IPC: {e}") - return False - - def create_project(self, path: Path, name: str) -> dict: - """Create project using IPC API""" - # Implementation here - pass -``` - -**Backend Factory:** -```python -# python/kicad_api/factory.py -from typing import Optional -from kicad_api.base import KiCADBackend -from kicad_api.ipc_backend import IPCBackend -from kicad_api.swig_backend import SWIGBackend - -def create_backend(backend_type: Optional[str] = None) -> KiCADBackend: - """ - Create appropriate KiCAD backend - - Args: - backend_type: 'ipc', 'swig', or None for auto-detect - - Returns: - KiCADBackend instance - """ - if backend_type == 'ipc': - return IPCBackend() - elif backend_type == 'swig': - return SWIGBackend() - else: - # Auto-detect: Try IPC first, fall back to SWIG - try: - backend = IPCBackend() - if backend.connect(): - return backend - except ImportError: - pass - - # Fall back to SWIG - return SWIGBackend() -``` - -**Deliverables:** -- [ ] Abstract base class defined -- [ ] IPC backend implemented -- [ ] SWIG backend (wrapper around existing code) -- [ ] Factory with auto-detection - ---- - -### Phase 3: Port Core Modules (Days 5-8) - -**Migration Order** (by complexity): - -1. **project.py** (Simple - good starting point) - - Create, open, save projects - - Estimated: 2 hours - -2. **board.py** (Medium - board properties) - - Set size, layers, outline - - Estimated: 4 hours - -3. **component.py** (Complex - many operations) - - Place, move, rotate, delete - - Component arrays and alignment - - Estimated: 8 hours - -4. **routing.py** (Complex - trace routing) - - Nets, traces, vias - - Copper pours, differential pairs - - Estimated: 8 hours - -5. **design_rules.py** (Medium - DRC) - - Set rules, run DRC - - Estimated: 4 hours - -6. **export.py** (Medium - file exports) - - Gerber, PDF, SVG, 3D - - Estimated: 4 hours - -**Total Estimated Time: 30 hours (~4 days)** - -**Migration Template:** -```python -# OLD (SWIG) -import pcbnew -board = pcbnew.LoadBoard(filename) -board.SetBoardSize(width, height) - -# NEW (IPC via abstraction) -from kicad_api import create_backend -backend = create_backend('ipc') -backend.connect() -board_api = backend.get_board() -board_api.set_size(width, height) -``` - -**Deliverables:** -- [ ] project.py migrated -- [ ] board.py migrated -- [ ] component.py migrated -- [ ] routing.py migrated -- [ ] design_rules.py migrated -- [ ] export.py migrated - ---- - -### Phase 4: Testing & Validation (Days 9-10) - -**Testing Strategy:** - -1. **Unit Tests** - ```python - @pytest.mark.parametrize("backend_type", ["ipc", "swig"]) - def test_create_project(backend_type): - backend = create_backend(backend_type) - result = backend.create_project(Path("/tmp/test"), "TestProject") - assert result["success"] is True - ``` - -2. **Integration Tests** - - Run side-by-side: IPC vs SWIG - - Compare outputs for identical operations - - Verify file compatibility - -3. **Performance Benchmarks** - ```python - # Measure: operations/second for each backend - # Expected: IPC slightly slower due to IPC overhead - ``` - -**Deliverables:** -- [ ] 50+ unit tests passing for IPC backend -- [ ] Side-by-side comparison tests -- [ ] Performance benchmarks documented - ---- - -## API Comparison Reference - -### 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()` | - -### 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()` | - -### 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()` | - -### 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()` | - ---- - -## Configuration Changes - -### Update requirements.txt - -```diff -+ # KiCAD IPC API (official Python bindings) -+ kicad-python>=0.5.0 - - # Legacy SWIG support (for backward compatibility) - kicad-skip>=0.1.0 -``` - -### Environment Variables - -```bash -# Enable IPC API in KiCAD preferences -# Preferences > Plugins > Enable IPC API Server - -# Set backend preference (optional) -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 - ---- - -## Rollback Plan - -If IPC migration fails: - -1. **Keep SWIG backend** - Already abstracted -2. **Default to SWIG** - Change factory auto-detection -3. **Document limitations** - Note that SWIG will be removed eventually -4. **Plan retry** - Schedule IPC migration for later - ---- - -## Success Criteria - -- [ ] ✅ All existing functionality works with IPC backend -- [ ] ✅ Tests pass with both IPC and SWIG backends -- [ ] ✅ Performance acceptable (< 20% slowdown vs SWIG) -- [ ] ✅ Documentation updated -- [ ] ✅ Migration guide created -- [ ] ✅ User-facing tools work without changes - ---- - -## 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 | -| **Week 3** | Mon-Tue | Port component.py and routing.py | -| | Wed | Port design_rules.py and export.py | -| | Thu-Fri | Testing, validation, documentation | - ---- - -## Resources - -- **Official Docs:** https://docs.kicad.org/kicad-python-main -- **kicad-python PyPI:** https://pypi.org/project/kicad-python/ -- **IPC API Spec:** https://dev-docs.kicad.org/en/apis-and-binding/ipc-api/ -- **Protocol Buffers:** Used by IPC for message format - ---- - -## Open Questions - -1. **How to handle KiCAD not running?** - - Option A: Auto-launch KiCAD in background - - Option B: Require user to launch KiCAD first - - Option C: Fall back to SWIG if IPC unavailable - - **Decision: Option C for now, A later** - -2. **Connection management** - - Should we keep connection open or connect per-operation? - - **Decision: Keep alive with reconnect logic** - -3. **Performance vs reliability** - - IPC has overhead but more stable - - **Decision: Reliability > performance** - ---- - -## Next Steps (This Week) - -1. **Install kicad-python** - ```bash - pip install kicad-python - ``` - -2. **Test IPC connection** - ```bash - # Launch KiCAD - # Enable IPC in preferences - python3 -c "from kicad import KiCad; k=KiCad(); print(k.check_version())" - ``` - -3. **Create abstraction layer structure** - ```bash - mkdir -p python/kicad_api - touch python/kicad_api/{__init__,base,ipc_backend,swig_backend,factory}.py - ``` - -4. **Begin project.py migration** - - Start with simplest module - - Establish patterns for others - ---- - -**Prepared by:** Claude Code -**Last Updated:** October 25, 2025 -**Status:** 📋 Ready to execute +# KiCAD IPC API Migration Plan + +**Status:** 📋 Planning +**Target Completion:** Week 2-3 (November 1-8, 2025) +**Priority:** 🔴 **CRITICAL** - Current SWIG API deprecated + +--- + +## Executive Summary + +The current KiCAD MCP Server uses SWIG-based Python bindings (`import pcbnew`) which are **deprecated as of KiCAD 9.0** and will be **removed in KiCAD 10.0**. We must migrate to the official **KiCAD IPC API** to future-proof the project. + +### 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.) | +| ❌ Direct linking | ✅ Inter-process communication | +| ⚠️ Synchronous only | ✅ Async support | +| ⚠️ No versioning | ✅ Protocol Buffers versioning | + +**Decision: Migrate immediately to avoid technical debt** + +--- + +## IPC API Overview + +### Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ TypeScript MCP Server (Node.js) │ +└──────────────────────┬──────────────────────────────────────┘ + │ JSON over stdin/stdout +┌──────────────────────▼──────────────────────────────────────┐ +│ Python Interface Layer │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ KiCAD API Abstraction (NEW) │ │ +│ └────────────────────────────────────────────────────────┘ │ +└──────────────────────┬──────────────────────────────────────┘ + │ kicad-python library +┌──────────────────────▼──────────────────────────────────────┐ +│ KiCAD IPC Server (Protocol Buffers) │ +│ Running inside KiCAD Process │ +└──────────────────────┬──────────────────────────────────────┘ + │ UNIX Sockets / Named Pipes +┌──────────────────────▼──────────────────────────────────────┐ +│ KiCAD 9.0+ Application │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Key Differences + +1. **KiCAD Must Be Running** + - SWIG: Can run headless, no KiCAD GUI needed + - IPC: Requires KiCAD running with IPC server enabled + +2. **Communication Method** + - SWIG: Direct Python module import + - IPC: Socket-based RPC (Remote Procedure Call) + +3. **API Structure** + - SWIG: `board.SetSize(width, height)` + - IPC: `kicad.get_board().set_size(width, height)` + +--- + +## Migration Strategy + +### 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 + +# Test basic connection +python3 << EOF +from kicad import KiCad +kicad = KiCad() +print(f"Connected to KiCAD: {kicad.check_version()}") +EOF + +# Read official documentation +# https://docs.kicad.org/kicad-python-main +``` + +**Deliverables:** + +- [ ] kicad-python installed and tested +- [ ] Connection test script +- [ ] API comparison document (SWIG vs IPC) + +--- + +### Phase 2: Abstraction Layer (Days 3-4) + +**Goal:** Create an abstraction layer to support both APIs during transition + +**File Structure:** + +``` +python/kicad_api/ +├── __init__.py +├── base.py # Abstract base class +├── ipc_backend.py # NEW: IPC API implementation +├── swig_backend.py # Legacy SWIG implementation +└── factory.py # Backend selector +``` + +**Abstract Interface:** + +```python +# python/kicad_api/base.py +from abc import ABC, abstractmethod +from typing import Optional +from pathlib import Path + +class KiCADBackend(ABC): + """Abstract base class for KiCAD API backends""" + + @abstractmethod + def connect(self) -> bool: + """Connect to KiCAD""" + pass + + @abstractmethod + def disconnect(self) -> None: + """Disconnect from KiCAD""" + pass + + @abstractmethod + def is_connected(self) -> bool: + """Check if connected""" + pass + + @abstractmethod + def create_project(self, path: Path, name: str) -> dict: + """Create a new KiCAD project""" + pass + + @abstractmethod + def open_project(self, path: Path) -> dict: + """Open existing project""" + pass + + @abstractmethod + def get_board(self) -> 'BoardAPI': + """Get board API""" + pass + + # ... more abstract methods +``` + +**IPC Implementation:** + +```python +# python/kicad_api/ipc_backend.py +from kicad import KiCad +from kicad_api.base import KiCADBackend + +class IPCBackend(KiCADBackend): + """KiCAD IPC API backend""" + + def __init__(self): + self.kicad = None + + def connect(self) -> bool: + """Connect to running KiCAD instance""" + try: + self.kicad = KiCad() + # Verify connection + version = self.kicad.check_version() + logger.info(f"Connected to KiCAD via IPC: {version}") + return True + except Exception as e: + logger.error(f"Failed to connect via IPC: {e}") + return False + + def create_project(self, path: Path, name: str) -> dict: + """Create project using IPC API""" + # Implementation here + pass +``` + +**Backend Factory:** + +```python +# python/kicad_api/factory.py +from typing import Optional +from kicad_api.base import KiCADBackend +from kicad_api.ipc_backend import IPCBackend +from kicad_api.swig_backend import SWIGBackend + +def create_backend(backend_type: Optional[str] = None) -> KiCADBackend: + """ + Create appropriate KiCAD backend + + Args: + backend_type: 'ipc', 'swig', or None for auto-detect + + Returns: + KiCADBackend instance + """ + if backend_type == 'ipc': + return IPCBackend() + elif backend_type == 'swig': + return SWIGBackend() + else: + # Auto-detect: Try IPC first, fall back to SWIG + try: + backend = IPCBackend() + if backend.connect(): + return backend + except ImportError: + pass + + # Fall back to SWIG + return SWIGBackend() +``` + +**Deliverables:** + +- [ ] Abstract base class defined +- [ ] IPC backend implemented +- [ ] SWIG backend (wrapper around existing code) +- [ ] Factory with auto-detection + +--- + +### Phase 3: Port Core Modules (Days 5-8) + +**Migration Order** (by complexity): + +1. **project.py** (Simple - good starting point) + - Create, open, save projects + - Estimated: 2 hours + +2. **board.py** (Medium - board properties) + - Set size, layers, outline + - Estimated: 4 hours + +3. **component.py** (Complex - many operations) + - Place, move, rotate, delete + - Component arrays and alignment + - Estimated: 8 hours + +4. **routing.py** (Complex - trace routing) + - Nets, traces, vias + - Copper pours, differential pairs + - Estimated: 8 hours + +5. **design_rules.py** (Medium - DRC) + - Set rules, run DRC + - Estimated: 4 hours + +6. **export.py** (Medium - file exports) + - Gerber, PDF, SVG, 3D + - Estimated: 4 hours + +**Total Estimated Time: 30 hours (~4 days)** + +**Migration Template:** + +```python +# OLD (SWIG) +import pcbnew +board = pcbnew.LoadBoard(filename) +board.SetBoardSize(width, height) + +# NEW (IPC via abstraction) +from kicad_api import create_backend +backend = create_backend('ipc') +backend.connect() +board_api = backend.get_board() +board_api.set_size(width, height) +``` + +**Deliverables:** + +- [ ] project.py migrated +- [ ] board.py migrated +- [ ] component.py migrated +- [ ] routing.py migrated +- [ ] design_rules.py migrated +- [ ] export.py migrated + +--- + +### Phase 4: Testing & Validation (Days 9-10) + +**Testing Strategy:** + +1. **Unit Tests** + + ```python + @pytest.mark.parametrize("backend_type", ["ipc", "swig"]) + def test_create_project(backend_type): + backend = create_backend(backend_type) + result = backend.create_project(Path("/tmp/test"), "TestProject") + assert result["success"] is True + ``` + +2. **Integration Tests** + - Run side-by-side: IPC vs SWIG + - Compare outputs for identical operations + - Verify file compatibility + +3. **Performance Benchmarks** + ```python + # Measure: operations/second for each backend + # Expected: IPC slightly slower due to IPC overhead + ``` + +**Deliverables:** + +- [ ] 50+ unit tests passing for IPC backend +- [ ] Side-by-side comparison tests +- [ ] Performance benchmarks documented + +--- + +## API Comparison Reference + +### 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()` | + +### 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()` | + +### 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()` | + +### 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()` | + +--- + +## Configuration Changes + +### Update requirements.txt + +```diff ++ # KiCAD IPC API (official Python bindings) ++ kicad-python>=0.5.0 + + # Legacy SWIG support (for backward compatibility) + kicad-skip>=0.1.0 +``` + +### Environment Variables + +```bash +# Enable IPC API in KiCAD preferences +# Preferences > Plugins > Enable IPC API Server + +# Set backend preference (optional) +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 + +--- + +## Rollback Plan + +If IPC migration fails: + +1. **Keep SWIG backend** - Already abstracted +2. **Default to SWIG** - Change factory auto-detection +3. **Document limitations** - Note that SWIG will be removed eventually +4. **Plan retry** - Schedule IPC migration for later + +--- + +## Success Criteria + +- [ ] ✅ All existing functionality works with IPC backend +- [ ] ✅ Tests pass with both IPC and SWIG backends +- [ ] ✅ Performance acceptable (< 20% slowdown vs SWIG) +- [ ] ✅ Documentation updated +- [ ] ✅ Migration guide created +- [ ] ✅ User-facing tools work without changes + +--- + +## 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 | +| **Week 3** | Mon-Tue | Port component.py and routing.py | +| | Wed | Port design_rules.py and export.py | +| | Thu-Fri | Testing, validation, documentation | + +--- + +## Resources + +- **Official Docs:** https://docs.kicad.org/kicad-python-main +- **kicad-python PyPI:** https://pypi.org/project/kicad-python/ +- **IPC API Spec:** https://dev-docs.kicad.org/en/apis-and-binding/ipc-api/ +- **Protocol Buffers:** Used by IPC for message format + +--- + +## Open Questions + +1. **How to handle KiCAD not running?** + - Option A: Auto-launch KiCAD in background + - Option B: Require user to launch KiCAD first + - Option C: Fall back to SWIG if IPC unavailable + - **Decision: Option C for now, A later** + +2. **Connection management** + - Should we keep connection open or connect per-operation? + - **Decision: Keep alive with reconnect logic** + +3. **Performance vs reliability** + - IPC has overhead but more stable + - **Decision: Reliability > performance** + +--- + +## Next Steps (This Week) + +1. **Install kicad-python** + + ```bash + pip install kicad-python + ``` + +2. **Test IPC connection** + + ```bash + # Launch KiCAD + # Enable IPC in preferences + python3 -c "from kicad import KiCad; k=KiCad(); print(k.check_version())" + ``` + +3. **Create abstraction layer structure** + + ```bash + mkdir -p python/kicad_api + touch python/kicad_api/{__init__,base,ipc_backend,swig_backend,factory}.py + ``` + +4. **Begin project.py migration** + - Start with simplest module + - Establish patterns for others + +--- + +**Prepared by:** Claude Code +**Last Updated:** October 25, 2025 +**Status:** 📋 Ready to execute diff --git a/docs/archive/JLCPCB_INTEGRATION_PLAN.md b/docs/archive/JLCPCB_INTEGRATION_PLAN.md index 5fe7237..dc62a1e 100644 --- a/docs/archive/JLCPCB_INTEGRATION_PLAN.md +++ b/docs/archive/JLCPCB_INTEGRATION_PLAN.md @@ -1,610 +1,628 @@ -# JLCPCB Parts Integration Plan - -**Goal:** Enable AI-driven component selection using JLCPCB's assembly parts library with real pricing and availability - -**Status:** Planning Phase -**Estimated Effort:** 3-4 days -**Priority:** Week 2 Priority 3 (after Component Libraries + Routing) - ---- - -## 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 -- Mapping JLCPCB parts to KiCAD footprints - ---- - -## Architecture - -### Data Flow - -``` -┌──────────────────────────────────────────────────┐ -│ JLCPCB API (https://jlcpcb.com/external/...) │ -│ - Requires API key/secret │ -│ - Returns: ~100k parts with specs/pricing │ -└───────────────────┬──────────────────────────────┘ - │ Download (once, then updates) - ▼ -┌──────────────────────────────────────────────────┐ -│ SQLite Database (local cache) │ -│ - components table │ -│ - manufacturers table │ -│ - categories table │ -│ - Fast parametric search │ -└───────────────────┬──────────────────────────────┘ - │ Search/query - ▼ -┌──────────────────────────────────────────────────┐ -│ JLCPCB Parts Manager (Python) │ -│ - search_parts(specs) │ -│ - get_part_info(lcsc_number) │ -│ - map_to_footprint(package) │ -│ - suggest_alternatives(part) │ -└───────────────────┬──────────────────────────────┘ - │ MCP Tools - ▼ -┌──────────────────────────────────────────────────┐ -│ MCP Tools (TypeScript) │ -│ - search_jlcpcb_parts │ -│ - get_jlcpcb_part │ -│ - place_component (enhanced) │ -└──────────────────────────────────────────────────┘ -``` - -### File Structure - -``` -python/commands/ -├── jlcpcb.py # JLCPCB API client -└── jlcpcb_parts.py # Parts database manager - -data/ -├── jlcpcb_parts.db # SQLite cache (gitignored) -└── footprint_mappings.json # Package → KiCAD footprint mapping - -src/tools/ -└── jlcpcb.ts # MCP tool definitions - -docs/ -└── JLCPCB_INTEGRATION.md # User documentation -``` - ---- - -## Implementation Phases - -### Phase 1: JLCPCB API Client (Day 1) - -**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 -{ - "appKey": "YOUR_KEY", - "appSecret": "YOUR_SECRET" -} - -# Fetch parts (paginated) -POST https://jlcpcb.com/external/component/getComponentInfos -Headers: { "externalApiToken": "TOKEN" } -Body: { "lastKey": "PAGINATION_KEY" } # Optional, for next page -``` - -**Database Schema:** -```sql -CREATE TABLE components ( - lcsc TEXT PRIMARY KEY, -- "C12345" - category TEXT, -- "Resistors" - subcategory TEXT, -- "Chip Resistor - Surface Mount" - mfr_part TEXT, -- "RC0603FR-0710KL" - package TEXT, -- "0603" - solder_joints INTEGER, -- 2 - manufacturer TEXT, -- "YAGEO" - library_type TEXT, -- "Basic" or "Extended" - description TEXT, -- "10kΩ ±1% 0.1W" - datasheet TEXT, -- URL - stock INTEGER, -- 15000 - price_json TEXT, -- JSON array of price breaks - last_updated INTEGER -- Unix timestamp -); - -CREATE INDEX idx_category ON components(category, subcategory); -CREATE INDEX idx_package ON components(package); -CREATE INDEX idx_manufacturer ON components(manufacturer); -CREATE INDEX idx_library_type ON components(library_type); -``` - -**Environment Variables:** -```bash -# ~/.bashrc or .env -export JLCPCB_API_KEY="your_key_here" -export JLCPCB_API_SECRET="your_secret_here" -``` - -**Python Implementation Outline:** -```python -class JLCPCBClient: - def __init__(self, api_key: str, api_secret: str): - self.api_key = api_key - self.api_secret = api_secret - self.token = None - - def authenticate(self) -> str: - """Get auth token from JLCPCB API""" - - def fetch_parts_page(self, last_key: Optional[str] = None) -> dict: - """Fetch one page of parts (paginated)""" - - def download_full_database(self, db_path: str, progress_callback=None): - """Download entire parts library to SQLite""" - - def update_database(self, db_path: str): - """Incremental update (fetch only new/changed parts)""" -``` - ---- - -### Phase 2: Parts Database Manager (Day 2) - -**File:** `python/commands/jlcpcb_parts.py` - -**Features:** -- Initialize/load SQLite database -- Parametric search (resistance, capacitance, voltage, etc.) -- Filter by library type (Basic/Extended) -- Sort by price, stock, or popularity -- Map package names to KiCAD footprints - -**Python Implementation Outline:** -```python -class JLCPCBPartsManager: - def __init__(self, db_path: str = "data/jlcpcb_parts.db"): - self.conn = sqlite3.connect(db_path) - - def search_parts( - self, - query: str = None, # Free-text search - category: str = None, # "Resistors" - package: str = None, # "0603" - library_type: str = None, # "Basic" only - manufacturer: str = None, # "YAGEO" - in_stock: bool = True, # Only parts with stock > 0 - limit: int = 20 - ) -> List[dict]: - """Search parts with filters""" - - def get_part_info(self, lcsc_number: str) -> dict: - """Get detailed info for specific part""" - - def map_package_to_footprint(self, package: str) -> List[str]: - """Map JLCPCB package name to KiCAD footprint(s)""" - # Example: "0603" → ["Resistor_SMD:R_0603_1608Metric", - # "Capacitor_SMD:C_0603_1608Metric"] - - def parse_description(self, description: str, category: str) -> dict: - """Extract parameters from description text""" - # Example: "10kΩ ±1% 0.1W" → {resistance: "10k", tolerance: "1%", power: "0.1W"} - - def suggest_alternatives(self, lcsc_number: str, limit: int = 5) -> List[dict]: - """Find similar parts (cheaper, more stock, Basic instead of Extended)""" -``` - -**Package to Footprint Mapping:** -```json -{ - "0402": [ - "Resistor_SMD:R_0402_1005Metric", - "Capacitor_SMD:C_0402_1005Metric", - "LED_SMD:LED_0402_1005Metric" - ], - "0603": [ - "Resistor_SMD:R_0603_1608Metric", - "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" - ] -} -``` - ---- - -### Phase 3: MCP Tools Integration (Day 3) - -**File:** `src/tools/jlcpcb.ts` - -**New MCP Tools:** - -#### 1. `search_jlcpcb_parts` -Search JLCPCB parts library by specifications. - -```typescript -{ - name: "search_jlcpcb_parts", - description: "Search JLCPCB assembly parts by specifications", - inputSchema: { - type: "object", - properties: { - query: { - type: "string", - description: "Free-text search (e.g., '10k resistor 0603')" - }, - category: { - type: "string", - description: "Category filter (e.g., 'Resistors', 'Capacitors')" - }, - package: { - type: "string", - description: "Package filter (e.g., '0603', 'SOT-23')" - }, - library_type: { - type: "string", - enum: ["Basic", "Extended", "All"], - description: "Filter by library type (Basic = free assembly)" - }, - in_stock: { - type: "boolean", - default: true, - description: "Only show parts with available stock" - }, - limit: { - type: "number", - default: 20, - description: "Maximum results to return" - } - } - } -} -``` - -**Example Usage:** -``` -User: "Find me a 10k resistor, 0603 package, JLCPCB basic part" -Claude: [uses search_jlcpcb_parts] -Found 15 parts: -1. C25804 - YAGEO RC0603FR-0710KL - 10kΩ ±1% 0.1W - Basic - $0.002 (15k in stock) -2. C58972 - UNI-ROYAL 0603WAF1002T5E - 10kΩ ±1% 0.1W - Basic - $0.001 (50k in stock) -... -Recommended: C58972 (cheapest Basic part with high stock) -``` - -#### 2. `get_jlcpcb_part` -Get detailed information about a specific JLCPCB part. - -```typescript -{ - name: "get_jlcpcb_part", - description: "Get detailed info for a specific JLCPCB part", - inputSchema: { - type: "object", - properties: { - lcsc_number: { - type: "string", - description: "LCSC part number (e.g., 'C25804')" - } - }, - required: ["lcsc_number"] - } -} -``` - -**Returns:** -```json -{ - "lcsc": "C25804", - "mfr_part": "RC0603FR-0710KL", - "manufacturer": "YAGEO", - "category": "Resistors / Chip Resistor - Surface Mount", - "package": "0603", - "description": "10kΩ ±1% 0.1W Thick Film Resistors", - "library_type": "Basic", - "stock": 15000, - "price_breaks": [ - {"qty": 1, "price": "$0.002"}, - {"qty": 10, "price": "$0.0018"}, - {"qty": 100, "price": "$0.0015"} - ], - "datasheet": "https://datasheet.lcsc.com/...", - "kicad_footprints": [ - "Resistor_SMD:R_0603_1608Metric" - ] -} -``` - -#### 3. Enhanced `place_component` -Add JLCPCB integration to existing component placement. - -```typescript -// Add new optional parameter to place_component: -{ - jlcpcb_part: { - type: "string", - description: "JLCPCB LCSC part number (e.g., 'C25804'). If provided, will use JLCPCB specs." - } -} -``` - -**Example:** -``` -User: "Place a 10k resistor at 50, 40mm using JLCPCB part C25804" -Claude: [uses place_component with jlcpcb_part="C25804"] - - Looks up C25804 → finds package "0603" - - Maps "0603" → "Resistor_SMD:R_0603_1608Metric" - - Places component with: - - Reference: R1 - - Value: 10k (C25804) - - Footprint: Resistor_SMD:R_0603_1608Metric - - Attribute: LCSC part C25804 stored in component properties -``` - ---- - -### 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) -4. Test component placement with JLCPCB parts -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) -- BOM generation for JLCPCB assembly - ---- - -## User Workflow Examples - -### Example 1: Find and Place a Resistor -``` -User: "I need a 10k pull-up resistor, 0603 size, cheapest JLCPCB basic part" - -Claude: [uses search_jlcpcb_parts] - query: "10k resistor" - package: "0603" - library_type: "Basic" - -Found: C58972 - UNI-ROYAL 0603WAF1002T5E - - 10kΩ ±1% 0.1W - - Basic part (free assembly!) - - $0.001 each - - 50,000 in stock - -User: "Perfect, place it at position 45, 30mm" - -Claude: [uses place_component] - componentId: "Resistor_SMD:R_0603_1608Metric" - position: {x: 45, y: 30} - reference: "R1" - value: "10k (C58972)" - jlcpcb_part: "C58972" - - ✅ Placed R1: 10k at (45, 30) mm - JLCPCB: C58972 (Basic part, $0.001) -``` - -### Example 2: Design a Cost-Optimized LED Circuit -``` -User: "Design an LED circuit with current-limiting resistor, use only JLCPCB basic parts" - -Claude: [searches for LED] - Found: C2286 - Everlight 19-217/BHC-ZL1M2RY/3T (Red LED, 0603) - Basic - $0.015 - - [searches for resistor] - Calculated: 330Ω for 5V → 20mA - Found: C23138 - UNI-ROYAL 0603WAF3300T5E (330Ω, 0603) - Basic - $0.001 - - [places components] - R1: 330Ω (C23138) at (40, 30) mm - D1: Red LED (C2286) at (50, 30) mm - - Total BOM cost: $0.016 - Both are Basic parts → Free assembly! 🎉 -``` - -### Example 3: Check Stock Before Ordering -``` -User: "I need 100 of part C25804, is there enough stock?" - -Claude: [uses get_jlcpcb_part lcsc_number="C25804"] - Stock: 15,000 units - ✅ Plenty of stock for 100 units - - Price for 100: $0.0015 each = $0.15 total -``` - ---- - -## API Key Setup - -**How to Get JLCPCB API Key:** - -1. Visit JLCPCB website: https://jlcpcb.com/ -2. Log in to your account -3. Go to: Account → API Management -4. Click "Create API Key" -5. Save your `appKey` and `appSecret` - -**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": { - "api_key": "your_app_key", - "api_secret": "your_app_secret", - "cache_db": "~/.kicad-mcp/jlcpcb_parts.db" - } -} -``` - -**Initial Setup:** -``` -User: "Download the JLCPCB parts database" - -Claude: [runs JLCPCB database download] - Authenticating... ✅ - Fetching parts... (page 1/500) - Fetching parts... (page 2/500) - ... - ✅ Downloaded 108,523 parts - ✅ Saved to ~/.kicad-mcp/jlcpcb_parts.db (42 MB) - - Database ready! You can now search JLCPCB parts. -``` - ---- - -## Cost Optimization Features - -### Prefer Basic Parts - -```python -def search_parts_optimized(self, specs: dict) -> List[dict]: - """ - Search with automatic Basic part preference. - Returns Basic parts first, Extended parts only if no Basic match. - """ - basic_parts = self.search_parts(**specs, library_type="Basic") - if basic_parts: - return basic_parts - return self.search_parts(**specs, library_type="Extended") -``` - -### Calculate BOM Cost - -```python -def calculate_bom_cost(self, board: pcbnew.BOARD) -> dict: - """ - Calculate total cost for JLCPCB assembly. - - Returns: - { - "total_parts_cost": 12.50, - "basic_parts_count": 15, - "extended_parts_count": 2, - "extended_setup_fee": 6.00, # $3 per unique extended part - "total_assembly_cost": 18.50 - } - """ -``` - ---- - -## Integration with Existing Features - -### BOM Export Enhancement - -Update `export_bom` to include JLCPCB columns: - -```csv -Reference,Value,Footprint,LCSC Part,Library Type,Manufacturer,MFR Part,Stock -R1,10k,Resistor_SMD:R_0603_1608Metric,C58972,Basic,UNI-ROYAL,0603WAF1002T5E,50000 -D1,Red,LED_SMD:LED_0603_1608Metric,C2286,Basic,Everlight,19-217/BHC-ZL1M2RY/3T,8000 -``` - -This BOM can be directly uploaded to JLCPCB for assembly! - ---- - -## Database Update Strategy - -**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) - -# Via MCP tool -update_jlcpcb_database(force=False) # Incremental -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 -- ✅ MCP tools integrated and tested end-to-end -- ✅ BOM export includes LCSC part numbers -- ✅ 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" - -Claude: [searches JLCPCB database] - [places all components with real parts] - [exports BOM ready for manufacturing] - - ✅ Board designed with 23 components - 💰 Total cost: $8.45 - 🎉 All Basic parts (free assembly!) -``` - ---- - -## 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) -- Price history and trend analysis -- Community-contributed package mappings -- Visual part selection UI (if web interface added) - ---- - -## Related Documentation - -- [LIBRARY_INTEGRATION.md](./LIBRARY_INTEGRATION.md) - KiCAD footprint libraries -- [REALTIME_WORKFLOW.md](./REALTIME_WORKFLOW.md) - MCP ↔ UI collaboration -- [ROADMAP.md](./ROADMAP.md) - Overall project plan -- [API.md](./API.md) - MCP API reference - ---- - -**Status:** Ready to implement! 🚀 -**Next Step:** Get JLCPCB API credentials and start Phase 1 +# JLCPCB Parts Integration Plan + +**Goal:** Enable AI-driven component selection using JLCPCB's assembly parts library with real pricing and availability + +**Status:** Planning Phase +**Estimated Effort:** 3-4 days +**Priority:** Week 2 Priority 3 (after Component Libraries + Routing) + +--- + +## 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 +- Mapping JLCPCB parts to KiCAD footprints + +--- + +## Architecture + +### Data Flow + +``` +┌──────────────────────────────────────────────────┐ +│ JLCPCB API (https://jlcpcb.com/external/...) │ +│ - Requires API key/secret │ +│ - Returns: ~100k parts with specs/pricing │ +└───────────────────┬──────────────────────────────┘ + │ Download (once, then updates) + ▼ +┌──────────────────────────────────────────────────┐ +│ SQLite Database (local cache) │ +│ - components table │ +│ - manufacturers table │ +│ - categories table │ +│ - Fast parametric search │ +└───────────────────┬──────────────────────────────┘ + │ Search/query + ▼ +┌──────────────────────────────────────────────────┐ +│ JLCPCB Parts Manager (Python) │ +│ - search_parts(specs) │ +│ - get_part_info(lcsc_number) │ +│ - map_to_footprint(package) │ +│ - suggest_alternatives(part) │ +└───────────────────┬──────────────────────────────┘ + │ MCP Tools + ▼ +┌──────────────────────────────────────────────────┐ +│ MCP Tools (TypeScript) │ +│ - search_jlcpcb_parts │ +│ - get_jlcpcb_part │ +│ - place_component (enhanced) │ +└──────────────────────────────────────────────────┘ +``` + +### File Structure + +``` +python/commands/ +├── jlcpcb.py # JLCPCB API client +└── jlcpcb_parts.py # Parts database manager + +data/ +├── jlcpcb_parts.db # SQLite cache (gitignored) +└── footprint_mappings.json # Package → KiCAD footprint mapping + +src/tools/ +└── jlcpcb.ts # MCP tool definitions + +docs/ +└── JLCPCB_INTEGRATION.md # User documentation +``` + +--- + +## Implementation Phases + +### Phase 1: JLCPCB API Client (Day 1) + +**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 +{ + "appKey": "YOUR_KEY", + "appSecret": "YOUR_SECRET" +} + +# Fetch parts (paginated) +POST https://jlcpcb.com/external/component/getComponentInfos +Headers: { "externalApiToken": "TOKEN" } +Body: { "lastKey": "PAGINATION_KEY" } # Optional, for next page +``` + +**Database Schema:** + +```sql +CREATE TABLE components ( + lcsc TEXT PRIMARY KEY, -- "C12345" + category TEXT, -- "Resistors" + subcategory TEXT, -- "Chip Resistor - Surface Mount" + mfr_part TEXT, -- "RC0603FR-0710KL" + package TEXT, -- "0603" + solder_joints INTEGER, -- 2 + manufacturer TEXT, -- "YAGEO" + library_type TEXT, -- "Basic" or "Extended" + description TEXT, -- "10kΩ ±1% 0.1W" + datasheet TEXT, -- URL + stock INTEGER, -- 15000 + price_json TEXT, -- JSON array of price breaks + last_updated INTEGER -- Unix timestamp +); + +CREATE INDEX idx_category ON components(category, subcategory); +CREATE INDEX idx_package ON components(package); +CREATE INDEX idx_manufacturer ON components(manufacturer); +CREATE INDEX idx_library_type ON components(library_type); +``` + +**Environment Variables:** + +```bash +# ~/.bashrc or .env +export JLCPCB_API_KEY="your_key_here" +export JLCPCB_API_SECRET="your_secret_here" +``` + +**Python Implementation Outline:** + +```python +class JLCPCBClient: + def __init__(self, api_key: str, api_secret: str): + self.api_key = api_key + self.api_secret = api_secret + self.token = None + + def authenticate(self) -> str: + """Get auth token from JLCPCB API""" + + def fetch_parts_page(self, last_key: Optional[str] = None) -> dict: + """Fetch one page of parts (paginated)""" + + def download_full_database(self, db_path: str, progress_callback=None): + """Download entire parts library to SQLite""" + + def update_database(self, db_path: str): + """Incremental update (fetch only new/changed parts)""" +``` + +--- + +### Phase 2: Parts Database Manager (Day 2) + +**File:** `python/commands/jlcpcb_parts.py` + +**Features:** + +- Initialize/load SQLite database +- Parametric search (resistance, capacitance, voltage, etc.) +- Filter by library type (Basic/Extended) +- Sort by price, stock, or popularity +- Map package names to KiCAD footprints + +**Python Implementation Outline:** + +```python +class JLCPCBPartsManager: + def __init__(self, db_path: str = "data/jlcpcb_parts.db"): + self.conn = sqlite3.connect(db_path) + + def search_parts( + self, + query: str = None, # Free-text search + category: str = None, # "Resistors" + package: str = None, # "0603" + library_type: str = None, # "Basic" only + manufacturer: str = None, # "YAGEO" + in_stock: bool = True, # Only parts with stock > 0 + limit: int = 20 + ) -> List[dict]: + """Search parts with filters""" + + def get_part_info(self, lcsc_number: str) -> dict: + """Get detailed info for specific part""" + + def map_package_to_footprint(self, package: str) -> List[str]: + """Map JLCPCB package name to KiCAD footprint(s)""" + # Example: "0603" → ["Resistor_SMD:R_0603_1608Metric", + # "Capacitor_SMD:C_0603_1608Metric"] + + def parse_description(self, description: str, category: str) -> dict: + """Extract parameters from description text""" + # Example: "10kΩ ±1% 0.1W" → {resistance: "10k", tolerance: "1%", power: "0.1W"} + + def suggest_alternatives(self, lcsc_number: str, limit: int = 5) -> List[dict]: + """Find similar parts (cheaper, more stock, Basic instead of Extended)""" +``` + +**Package to Footprint Mapping:** + +```json +{ + "0402": [ + "Resistor_SMD:R_0402_1005Metric", + "Capacitor_SMD:C_0402_1005Metric", + "LED_SMD:LED_0402_1005Metric" + ], + "0603": [ + "Resistor_SMD:R_0603_1608Metric", + "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"] +} +``` + +--- + +### Phase 3: MCP Tools Integration (Day 3) + +**File:** `src/tools/jlcpcb.ts` + +**New MCP Tools:** + +#### 1. `search_jlcpcb_parts` + +Search JLCPCB parts library by specifications. + +```typescript +{ + name: "search_jlcpcb_parts", + description: "Search JLCPCB assembly parts by specifications", + inputSchema: { + type: "object", + properties: { + query: { + type: "string", + description: "Free-text search (e.g., '10k resistor 0603')" + }, + category: { + type: "string", + description: "Category filter (e.g., 'Resistors', 'Capacitors')" + }, + package: { + type: "string", + description: "Package filter (e.g., '0603', 'SOT-23')" + }, + library_type: { + type: "string", + enum: ["Basic", "Extended", "All"], + description: "Filter by library type (Basic = free assembly)" + }, + in_stock: { + type: "boolean", + default: true, + description: "Only show parts with available stock" + }, + limit: { + type: "number", + default: 20, + description: "Maximum results to return" + } + } + } +} +``` + +**Example Usage:** + +``` +User: "Find me a 10k resistor, 0603 package, JLCPCB basic part" +Claude: [uses search_jlcpcb_parts] +Found 15 parts: +1. C25804 - YAGEO RC0603FR-0710KL - 10kΩ ±1% 0.1W - Basic - $0.002 (15k in stock) +2. C58972 - UNI-ROYAL 0603WAF1002T5E - 10kΩ ±1% 0.1W - Basic - $0.001 (50k in stock) +... +Recommended: C58972 (cheapest Basic part with high stock) +``` + +#### 2. `get_jlcpcb_part` + +Get detailed information about a specific JLCPCB part. + +```typescript +{ + name: "get_jlcpcb_part", + description: "Get detailed info for a specific JLCPCB part", + inputSchema: { + type: "object", + properties: { + lcsc_number: { + type: "string", + description: "LCSC part number (e.g., 'C25804')" + } + }, + required: ["lcsc_number"] + } +} +``` + +**Returns:** + +```json +{ + "lcsc": "C25804", + "mfr_part": "RC0603FR-0710KL", + "manufacturer": "YAGEO", + "category": "Resistors / Chip Resistor - Surface Mount", + "package": "0603", + "description": "10kΩ ±1% 0.1W Thick Film Resistors", + "library_type": "Basic", + "stock": 15000, + "price_breaks": [ + { "qty": 1, "price": "$0.002" }, + { "qty": 10, "price": "$0.0018" }, + { "qty": 100, "price": "$0.0015" } + ], + "datasheet": "https://datasheet.lcsc.com/...", + "kicad_footprints": ["Resistor_SMD:R_0603_1608Metric"] +} +``` + +#### 3. Enhanced `place_component` + +Add JLCPCB integration to existing component placement. + +```typescript +// Add new optional parameter to place_component: +{ + jlcpcb_part: { + type: "string", + description: "JLCPCB LCSC part number (e.g., 'C25804'). If provided, will use JLCPCB specs." + } +} +``` + +**Example:** + +``` +User: "Place a 10k resistor at 50, 40mm using JLCPCB part C25804" +Claude: [uses place_component with jlcpcb_part="C25804"] + - Looks up C25804 → finds package "0603" + - Maps "0603" → "Resistor_SMD:R_0603_1608Metric" + - Places component with: + - Reference: R1 + - Value: 10k (C25804) + - Footprint: Resistor_SMD:R_0603_1608Metric + - Attribute: LCSC part C25804 stored in component properties +``` + +--- + +### 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) +4. Test component placement with JLCPCB parts +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) +- BOM generation for JLCPCB assembly + +--- + +## User Workflow Examples + +### Example 1: Find and Place a Resistor + +``` +User: "I need a 10k pull-up resistor, 0603 size, cheapest JLCPCB basic part" + +Claude: [uses search_jlcpcb_parts] + query: "10k resistor" + package: "0603" + library_type: "Basic" + +Found: C58972 - UNI-ROYAL 0603WAF1002T5E + - 10kΩ ±1% 0.1W + - Basic part (free assembly!) + - $0.001 each + - 50,000 in stock + +User: "Perfect, place it at position 45, 30mm" + +Claude: [uses place_component] + componentId: "Resistor_SMD:R_0603_1608Metric" + position: {x: 45, y: 30} + reference: "R1" + value: "10k (C58972)" + jlcpcb_part: "C58972" + + ✅ Placed R1: 10k at (45, 30) mm + JLCPCB: C58972 (Basic part, $0.001) +``` + +### Example 2: Design a Cost-Optimized LED Circuit + +``` +User: "Design an LED circuit with current-limiting resistor, use only JLCPCB basic parts" + +Claude: [searches for LED] + Found: C2286 - Everlight 19-217/BHC-ZL1M2RY/3T (Red LED, 0603) - Basic - $0.015 + + [searches for resistor] + Calculated: 330Ω for 5V → 20mA + Found: C23138 - UNI-ROYAL 0603WAF3300T5E (330Ω, 0603) - Basic - $0.001 + + [places components] + R1: 330Ω (C23138) at (40, 30) mm + D1: Red LED (C2286) at (50, 30) mm + + Total BOM cost: $0.016 + Both are Basic parts → Free assembly! 🎉 +``` + +### Example 3: Check Stock Before Ordering + +``` +User: "I need 100 of part C25804, is there enough stock?" + +Claude: [uses get_jlcpcb_part lcsc_number="C25804"] + Stock: 15,000 units + ✅ Plenty of stock for 100 units + + Price for 100: $0.0015 each = $0.15 total +``` + +--- + +## API Key Setup + +**How to Get JLCPCB API Key:** + +1. Visit JLCPCB website: https://jlcpcb.com/ +2. Log in to your account +3. Go to: Account → API Management +4. Click "Create API Key" +5. Save your `appKey` and `appSecret` + +**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": { + "api_key": "your_app_key", + "api_secret": "your_app_secret", + "cache_db": "~/.kicad-mcp/jlcpcb_parts.db" + } +} +``` + +**Initial Setup:** + +``` +User: "Download the JLCPCB parts database" + +Claude: [runs JLCPCB database download] + Authenticating... ✅ + Fetching parts... (page 1/500) + Fetching parts... (page 2/500) + ... + ✅ Downloaded 108,523 parts + ✅ Saved to ~/.kicad-mcp/jlcpcb_parts.db (42 MB) + + Database ready! You can now search JLCPCB parts. +``` + +--- + +## Cost Optimization Features + +### Prefer Basic Parts + +```python +def search_parts_optimized(self, specs: dict) -> List[dict]: + """ + Search with automatic Basic part preference. + Returns Basic parts first, Extended parts only if no Basic match. + """ + basic_parts = self.search_parts(**specs, library_type="Basic") + if basic_parts: + return basic_parts + return self.search_parts(**specs, library_type="Extended") +``` + +### Calculate BOM Cost + +```python +def calculate_bom_cost(self, board: pcbnew.BOARD) -> dict: + """ + Calculate total cost for JLCPCB assembly. + + Returns: + { + "total_parts_cost": 12.50, + "basic_parts_count": 15, + "extended_parts_count": 2, + "extended_setup_fee": 6.00, # $3 per unique extended part + "total_assembly_cost": 18.50 + } + """ +``` + +--- + +## Integration with Existing Features + +### BOM Export Enhancement + +Update `export_bom` to include JLCPCB columns: + +```csv +Reference,Value,Footprint,LCSC Part,Library Type,Manufacturer,MFR Part,Stock +R1,10k,Resistor_SMD:R_0603_1608Metric,C58972,Basic,UNI-ROYAL,0603WAF1002T5E,50000 +D1,Red,LED_SMD:LED_0603_1608Metric,C2286,Basic,Everlight,19-217/BHC-ZL1M2RY/3T,8000 +``` + +This BOM can be directly uploaded to JLCPCB for assembly! + +--- + +## Database Update Strategy + +**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) + +# Via MCP tool +update_jlcpcb_database(force=False) # Incremental +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 +- ✅ MCP tools integrated and tested end-to-end +- ✅ BOM export includes LCSC part numbers +- ✅ 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" + +Claude: [searches JLCPCB database] + [places all components with real parts] + [exports BOM ready for manufacturing] + + ✅ Board designed with 23 components + 💰 Total cost: $8.45 + 🎉 All Basic parts (free assembly!) +``` + +--- + +## 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) +- Price history and trend analysis +- Community-contributed package mappings +- Visual part selection UI (if web interface added) + +--- + +## Related Documentation + +- [LIBRARY_INTEGRATION.md](./LIBRARY_INTEGRATION.md) - KiCAD footprint libraries +- [REALTIME_WORKFLOW.md](./REALTIME_WORKFLOW.md) - MCP ↔ UI collaboration +- [ROADMAP.md](./ROADMAP.md) - Overall project plan +- [API.md](./API.md) - MCP API reference + +--- + +**Status:** Ready to implement! 🚀 +**Next Step:** Get JLCPCB API credentials and start Phase 1 diff --git a/docs/archive/ROUTER_IMPLEMENTATION_STATUS.md b/docs/archive/ROUTER_IMPLEMENTATION_STATUS.md index 412721a..11dd453 100644 --- a/docs/archive/ROUTER_IMPLEMENTATION_STATUS.md +++ b/docs/archive/ROUTER_IMPLEMENTATION_STATUS.md @@ -1,222 +1,241 @@ -# Router Implementation Status - -## ✅ Phase 1 Complete: Foundation & Infrastructure - -**Date:** December 28, 2025 - -### 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 -- ✅ Tool search functionality -- ✅ 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 - -### Current State - -**Status:** ✅ **Router Infrastructure Complete** - -**Build:** ✅ Compiles successfully (`npm run build`) - -**Tool Count:** -- Total Tools: 59 (ALL still registered and visible) -- Direct Tools: 12 -- Routed Tools: 47 -- Router Tools: 4 -- **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) - -## 🔄 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 - ) { - if (isDirectTool(toolName)) { - // Register with MCP (visible to Claude) - server.tool(toolName, definition, handler); - } else { - // Register handler for execute_tool (hidden from Claude) - registerToolHandler(toolName, handler); - } - } - ``` - -2. **Update Tool Registration Functions** - Modify each `register*Tools` function to use conditional registration. - -3. **Test** - - Verify direct tools work normally - - Verify routed tools work via `execute_tool` - - Verify token count reduction - -4. **Measure Impact** - Count tools visible to Claude before/after. - -## 📊 Categories & Distribution - -| Category | Tools | Description | -|----------|-------|-------------| -| **board** | 9 | Board configuration, layers, zones, visualization | -| **component** | 8 | Advanced component operations | -| **export** | 8 | Manufacturing file generation | -| **drc** | 9 | Design rule checking & validation | -| **schematic** | 9 | Schematic editor operations | -| **library** | 4 | Footprint library access | -| **routing** | 3 | Advanced routing (vias, copper pours) | -| **TOTAL** | **47** | **Routed tools** | -| **direct** | **12** | **Always visible tools** | -| **router** | **4** | **Discovery tools** | - -## 🧪 Testing the Router - -### Test 1: List Categories -``` -User: "What tool categories are available?" - -Expected: Claude calls list_tool_categories -Result: Returns 7 categories with descriptions -``` - -### Test 2: Browse Category -``` -User: "What export tools are available?" - -Expected: Claude calls get_category_tools({ category: "export" }) -Result: Returns 8 export tools -``` - -### Test 3: Search Tools -``` -User: "How do I export gerber files?" - -Expected: Claude calls search_tools({ query: "gerber" }) -Result: Finds export_gerber in export category -``` - -### Test 4: Execute Tool -``` -User: "Export gerbers to ./output" - -Expected: Claude calls execute_tool({ - tool_name: "export_gerber", - params: { outputDir: "./output" } -}) -Result: Executes via router, returns gerber export result -``` - -## 📝 Benefits Achieved (Phase 1) - -1. ✅ **Foundation Ready**: All infrastructure in place -2. ✅ **Organized**: 59 tools categorized into logical groups -3. ✅ **Discoverable**: Tools easily found via search/browse -4. ✅ **Backwards Compatible**: All existing tools still work -5. ✅ **Extensible**: Easy to add new tools and categories -6. ✅ **Documented**: Complete architecture and usage docs - -## 🚀 Next Actions - -1. **Optional: Complete Phase 2** (Token Optimization) - - Implement conditional registration - - Hide routed tools from context - - Achieve 71% token reduction - -2. **Or: Ship Phase 1 As-Is** - - Router tools work perfectly now - - Users can discover and execute tools - - Optimization can be done later - - No breaking changes - -## 📚 Related Files - -- `src/tools/registry.ts` - Tool registry and categories -- `src/tools/router.ts` - Router tool implementations -- `src/server.ts` - Server integration -- `docs/TOOL_INVENTORY.md` - Complete tool list -- `docs/ROUTER_ARCHITECTURE.md` - Design specification -- `docs/mcp-router-guide.md` - Original implementation guide - -## 💡 Usage Example - -```typescript -// User: "I need to export gerber files" - -// Claude's interaction: -// 1. Sees "export" and "gerber" keywords -// 2. Calls search_tools({ query: "gerber" }) -// → Returns: { category: "export", tool: "export_gerber", ... } -// 3. Calls execute_tool({ -// tool_name: "export_gerber", -// params: { outputDir: "./gerbers" } -// }) -// → Executes and returns result -// 4. "I've exported your Gerber files to ./gerbers/" -``` - -## Status Summary - -✅ **Router Pattern: IMPLEMENTED** -✅ **Build: PASSING** -✅ **Backwards Compatible: YES** -⏳ **Token Optimization: PENDING (Phase 2)** - -The router infrastructure is complete and functional. The system now supports tool discovery and organized access to all 59 tools. Phase 2 optimization (hiding routed tools) can be implemented when ready for maximum token savings. +# Router Implementation Status + +## ✅ Phase 1 Complete: Foundation & Infrastructure + +**Date:** December 28, 2025 + +### 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 +- ✅ Tool search functionality +- ✅ 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 + +### Current State + +**Status:** ✅ **Router Infrastructure Complete** + +**Build:** ✅ Compiles successfully (`npm run build`) + +**Tool Count:** + +- Total Tools: 59 (ALL still registered and visible) +- Direct Tools: 12 +- Routed Tools: 47 +- Router Tools: 4 +- **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) + +## 🔄 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, + ) { + if (isDirectTool(toolName)) { + // Register with MCP (visible to Claude) + server.tool(toolName, definition, handler); + } else { + // Register handler for execute_tool (hidden from Claude) + registerToolHandler(toolName, handler); + } + } + ``` + +2. **Update Tool Registration Functions** + Modify each `register*Tools` function to use conditional registration. + +3. **Test** + - Verify direct tools work normally + - Verify routed tools work via `execute_tool` + - Verify token count reduction + +4. **Measure Impact** + Count tools visible to Claude before/after. + +## 📊 Categories & Distribution + +| Category | Tools | Description | +| ------------- | ------ | ------------------------------------------------- | +| **board** | 9 | Board configuration, layers, zones, visualization | +| **component** | 8 | Advanced component operations | +| **export** | 8 | Manufacturing file generation | +| **drc** | 9 | Design rule checking & validation | +| **schematic** | 9 | Schematic editor operations | +| **library** | 4 | Footprint library access | +| **routing** | 3 | Advanced routing (vias, copper pours) | +| **TOTAL** | **47** | **Routed tools** | +| **direct** | **12** | **Always visible tools** | +| **router** | **4** | **Discovery tools** | + +## 🧪 Testing the Router + +### Test 1: List Categories + +``` +User: "What tool categories are available?" + +Expected: Claude calls list_tool_categories +Result: Returns 7 categories with descriptions +``` + +### Test 2: Browse Category + +``` +User: "What export tools are available?" + +Expected: Claude calls get_category_tools({ category: "export" }) +Result: Returns 8 export tools +``` + +### Test 3: Search Tools + +``` +User: "How do I export gerber files?" + +Expected: Claude calls search_tools({ query: "gerber" }) +Result: Finds export_gerber in export category +``` + +### Test 4: Execute Tool + +``` +User: "Export gerbers to ./output" + +Expected: Claude calls execute_tool({ + tool_name: "export_gerber", + params: { outputDir: "./output" } +}) +Result: Executes via router, returns gerber export result +``` + +## 📝 Benefits Achieved (Phase 1) + +1. ✅ **Foundation Ready**: All infrastructure in place +2. ✅ **Organized**: 59 tools categorized into logical groups +3. ✅ **Discoverable**: Tools easily found via search/browse +4. ✅ **Backwards Compatible**: All existing tools still work +5. ✅ **Extensible**: Easy to add new tools and categories +6. ✅ **Documented**: Complete architecture and usage docs + +## 🚀 Next Actions + +1. **Optional: Complete Phase 2** (Token Optimization) + - Implement conditional registration + - Hide routed tools from context + - Achieve 71% token reduction + +2. **Or: Ship Phase 1 As-Is** + - Router tools work perfectly now + - Users can discover and execute tools + - Optimization can be done later + - No breaking changes + +## 📚 Related Files + +- `src/tools/registry.ts` - Tool registry and categories +- `src/tools/router.ts` - Router tool implementations +- `src/server.ts` - Server integration +- `docs/TOOL_INVENTORY.md` - Complete tool list +- `docs/ROUTER_ARCHITECTURE.md` - Design specification +- `docs/mcp-router-guide.md` - Original implementation guide + +## 💡 Usage Example + +```typescript +// User: "I need to export gerber files" + +// Claude's interaction: +// 1. Sees "export" and "gerber" keywords +// 2. Calls search_tools({ query: "gerber" }) +// → Returns: { category: "export", tool: "export_gerber", ... } +// 3. Calls execute_tool({ +// tool_name: "export_gerber", +// params: { outputDir: "./gerbers" } +// }) +// → Executes and returns result +// 4. "I've exported your Gerber files to ./gerbers/" +``` + +## Status Summary + +✅ **Router Pattern: IMPLEMENTED** +✅ **Build: PASSING** +✅ **Backwards Compatible: YES** +⏳ **Token Optimization: PENDING (Phase 2)** + +The router infrastructure is complete and functional. The system now supports tool discovery and organized access to all 59 tools. Phase 2 optimization (hiding routed tools) can be implemented when ready for maximum token savings. diff --git a/docs/archive/SCHEMATIC_WIRING_PLAN.md b/docs/archive/SCHEMATIC_WIRING_PLAN.md index b929b6b..e99b148 100644 --- a/docs/archive/SCHEMATIC_WIRING_PLAN.md +++ b/docs/archive/SCHEMATIC_WIRING_PLAN.md @@ -1,670 +1,711 @@ -# Schematic Wiring Implementation Plan - -**Date:** 2026-01-10 -**Status:** Planning Phase -**Priority:** HIGH (User-requested feature for Issue #26) - ---- - -## Executive Summary - -This plan outlines the implementation of complete schematic wiring functionality for the KiCAD MCP Server. Currently, component placement works perfectly with dynamic symbol loading, but wire/connection tools are incomplete or non-functional. - -**Goal:** Enable users to create complete, functional schematics with wired connections between components through the MCP interface. - ---- - -## Current State Analysis - -### 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 -4. `connect_to_net` - Connect pin to named net -5. `get_net_connections` - Query net connections -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 -- `connect_to_net(schematic, component_ref, pin_name, net_name)` - Pin to net -- `get_pin_location(symbol, pin_name)` - Get pin coordinates -- `get_net_connections(schematic, net_name)` - Query connections -- `generate_netlist(schematic)` - Generate netlist - -### 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 -- No power symbols (VCC, GND graphical symbols) -- No hierarchical labels - ---- - -## Technical Challenges - -### 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)) - (uuid "12345678-1234-1234-1234-123456789012") -) -``` - -**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) - -### Challenge 2: Pin Location Discovery - -**Issue:** Need to find exact pin coordinates for automatic wiring. - -**Pin Data in Symbols:** -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 - -### Challenge 3: Smart Wire Routing - -**Issue:** Users don't want to manually specify every wire segment. - -**Desired Behavior:** -``` -User: "Connect R1 pin 1 to C1 pin 1" -System: - - Calculate R1 pin 1 location: (100, 100) - - Calculate C1 pin 1 location: (150, 120) - - Create wire path (orthogonal routing preferred): - - (100, 100) → (100, 120) → (150, 120) - - Or simple direct: (100, 100) → (150, 120) -``` - -**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) - -**Phase 1 Approach:** Start with direct wiring, add orthogonal later. - -### Challenge 4: Net Label Integration - -**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 - ---- - -## Implementation Phases - -### Phase 1: Core Wire Functionality (Week 1) - -**Goal:** Get basic wiring working with kicad-skip API - -**Tasks:** - -1. **Research kicad-skip Wire API** (4 hours) - - Read kicad-skip source code - - Test wire creation with Python REPL - - Document actual API methods - - Create test schematic with manual wires - -2. **Fix Wire Creation** (6 hours) - - Update ConnectionManager.add_wire() with correct API - - Handle S-expression manipulation if needed - - Add UUID generation for wires - - Test with simple wire (100,100) → (200,100) - -3. **Implement Pin Discovery** (8 hours) - - Parse symbol definitions to extract pin data - - Handle pin coordinates relative to symbol - - Apply rotation transformation - - Test with R, C, LED from dynamic symbols - -4. **Fix add_schematic_connection** (4 hours) - - Use correct pin discovery - - Create direct wire between pins - - Handle error cases (pin not found, etc.) - - Test with R1 pin 2 → C1 pin 1 - -5. **Integration Testing** (4 hours) - - Create test schematic with R, C, LED - - Wire R to C - - Wire C to LED - - Verify schematic opens in KiCad - - Verify electrical connectivity - -**Deliverables:** -- Working `add_schematic_wire` tool -- Working `add_schematic_connection` tool -- Pin location discovery working -- Integration test passing -- Documentation updated - -**Success Criteria:** -- User can connect two resistor pins with MCP command -- Wire appears in KiCad schematic viewer -- Netlist shows electrical connection - ---- - -### Phase 2: Net Labels & Named Nets (Week 1-2) - -**Goal:** Enable named net connections (VCC, GND, etc.) - -**Tasks:** - -1. **Fix Net Label Creation** (4 hours) - - Update ConnectionManager.add_net_label() - - Use correct kicad-skip API or S-expression - - Position labels correctly - - Test label creation - -2. **Implement connect_to_net** (6 hours) - - Create wire stub from pin - - Attach label to wire endpoint - - Support common nets (VCC, GND, 3V3, etc.) - - Test with multiple components on same net - -3. **Net Connection Discovery** (6 hours) - - Parse wires and labels in schematic - - Build connectivity graph - - Implement get_net_connections() - - Return all pins on a net - -4. **Power Symbol Support** (8 hours) - - Add power symbols to templates (VCC, GND, 3V3, 5V) - - Or dynamically load from power library - - Connect power pins to power nets - - Test complete circuit with power - -5. **Testing** (4 hours) - - Create circuit with VCC, GND nets - - Connect multiple components to each net - - Verify net connectivity - - Generate and validate netlist - -**Deliverables:** -- Working `add_schematic_net_label` tool -- Working `connect_to_net` tool -- Working `get_net_connections` tool -- Power symbol support -- Netlist generation working - -**Success Criteria:** -- User can label nets VCC, GND -- Multiple components connect to same net -- Netlist correctly shows net membership - ---- - -### Phase 3: Advanced Features (Week 2-3) - -**Goal:** Professional schematic features - -**Tasks:** - -1. **Junction Support** (4 hours) - - Detect wire intersections - - Add junction dots at T-junctions - - S-expression: `(junction (at x y) (diameter 0) (uuid ...))` - -2. **No-Connect Flags** (2 hours) - - Add "X" marks on unused pins - - S-expression: `(no_connect (at x y) (uuid ...))` - -3. **Orthogonal Routing** (6 hours) - - Implement 2-segment wire routing - - Horizontal-then-vertical or vertical-then-horizontal - - Choose best path based on pin positions - -4. **Bus Support** (8 hours) - - Multi-bit signal buses - - Bus labels (e.g., "D[0..7]") - - Bus entries for individual signals - -5. **Hierarchical Labels** (8 hours) - - Labels for hierarchical sheets - - Input/Output/Bidirectional types - - 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 - ---- - -### Phase 4: Validation & Polish (Week 3-4) - -**Goal:** Production-ready reliability - -**Tasks:** - -1. **ERC Integration** (6 hours) - - Electrical Rule Check - - Detect floating nets - - Detect unconnected pins - - Detect short circuits - -2. **Visual Validation** (4 hours) - - Export schematic to PDF after wiring - - Verify wire appearance - - Check net label placement - -3. **Comprehensive Testing** (8 hours) - - Test with Device library components - - Test with IC components (multi-pin) - - Test with connectors - - Test complex circuits (10+ components) - -4. **Error Handling** (4 hours) - - Graceful failures - - Clear error messages - - Validation of coordinates - - Duplicate net label detection - -5. **Documentation** (6 hours) - - Update MCP tool descriptions - - Add usage examples to README - - Create wiring tutorial - - 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 - ---- - -## Technical Approach - -### Option A: Use kicad-skip Native API (Preferred) - -**If kicad-skip supports wires natively:** - -```python -# Add wire using native API -wire = schematic.wire.new( - start=[100, 100], - end=[200, 100] -) - -# Add label -label = schematic.label.new( - text="VCC", - at=[150, 100] -) -``` - -**Pros:** -- Clean, maintainable code -- Follows library patterns -- Less likely to break - -**Cons:** -- Depends on kicad-skip having these features -- May be limited in functionality - -### Option B: S-Expression Manipulation (Fallback) - -**If kicad-skip doesn't support wires:** - -Use the same approach as dynamic symbol loading: - -```python -import sexpdata -from sexpdata import Symbol - -# Read schematic -with open(schematic_path, 'r') as f: - sch_data = sexpdata.loads(f.read()) - -# Create wire S-expression -wire_sexp = [ - Symbol('wire'), - [Symbol('pts'), - [Symbol('xy'), 100, 100], - [Symbol('xy'), 200, 100] - ], - [Symbol('stroke'), [Symbol('width'), 0], [Symbol('type'), Symbol('default')]], - [Symbol('uuid'), str(uuid.uuid4())] -] - -# Insert into schematic -sch_data.append(wire_sexp) - -# Write back -with open(schematic_path, 'w') as f: - f.write(sexpdata.dumps(sch_data)) -``` - -**Pros:** -- Complete control -- Can implement any feature -- Works around library limitations - -**Cons:** -- More complex -- Requires deep KiCad format knowledge -- More maintenance - -### Hybrid Approach (Recommended) - -1. Try kicad-skip native API first -2. Fall back to S-expression if needed -3. Use S-expression for advanced features (junctions, buses) - ---- - -## Pin Discovery Algorithm - -### Step 1: Get Symbol Definition - -Symbols are stored in `lib_symbols` section: - -```lisp -(lib_symbols - (symbol "Device:R" - (symbol "R_0_1" - (rectangle (start -1 -2.54) (end 1 2.54) ...)) - (symbol "R_1_1" - (pin passive line (at 0 3.81 270) (length 1.27) - (name "~" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27))))) - (pin passive line (at 0 -3.81 90) (length 1.27) - (name "~" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27))))))) -``` - -### 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)` -- Length (distance from symbol body to connection point) - -### Step 3: Get Symbol Instance Position - -From symbol instance in schematic: - -```lisp -(symbol (lib_id "Device:R") (at 100 100 0) (unit 1) - (property "Reference" "R1" ...)) -``` - -Extract: -- Position: `(at 100 100 0)` = x=100, y=100, rotation=0° -- Reference: "R1" - -### Step 4: Calculate Absolute Pin Position - -```python -def get_absolute_pin_position(symbol_instance, pin_definition): - # Symbol position - symbol_x, symbol_y, symbol_rotation = symbol_instance.at.value - - # Pin position relative to symbol - pin_x, pin_y, pin_angle = pin_definition.at.value - - # Apply rotation transformation - if symbol_rotation != 0: - # Rotate pin coordinates around origin - rad = math.radians(symbol_rotation) - rotated_x = pin_x * math.cos(rad) - pin_y * math.sin(rad) - rotated_y = pin_x * math.sin(rad) + pin_y * math.cos(rad) - pin_x, pin_y = rotated_x, rotated_y - - # Translate to absolute position - abs_x = symbol_x + pin_x - abs_y = symbol_y + pin_y - - return [abs_x, abs_y] -``` - ---- - -## Wire Routing Strategies - -### Strategy 1: Direct Wire (Phase 1) - -Simplest: single wire segment from pin A to pin B. - -``` -R1 pin 2 C1 pin 1 - o-------------o -``` - -**Pros:** Simple, fast -**Cons:** Diagonal wires (not standard practice) - -### Strategy 2: Orthogonal 2-Segment (Phase 3) - -Two segments: horizontal then vertical, or vertical then horizontal. - -``` -R1 pin 2 C1 pin 1 - o-----┐ - │ - └------o -``` - -**Algorithm:** -1. Calculate midpoint -2. Route horizontal to midpoint -3. Route vertical to target -4. Or vice versa based on direction - -**Pros:** Standard practice, cleaner schematics -**Cons:** Slightly more complex - -### Strategy 3: Manhattan Routing (Future) - -Complex multi-segment paths avoiding components. - -**Pros:** Professional appearance -**Cons:** Requires collision detection, path planning - ---- - -## Testing Strategy - -### Unit Tests - -Test individual functions: -- `test_add_wire()` - Wire creation -- `test_get_pin_location()` - Pin discovery -- `test_add_net_label()` - Label creation -- `test_calculate_pin_position()` - Coordinate math - -### 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 -- `test_schematic_opens_in_kicad()` - File validity - -### Manual Validation - -- Create test schematic in KiCad manually -- Add same connections via MCP -- Compare results -- Verify electrical connectivity in KiCad - ---- - -## Success Metrics - -### Phase 1 Success: -- [ ] `add_schematic_wire` works (coordinates) -- [ ] `add_schematic_connection` works (pin to pin) -- [ ] Wires appear in KiCad schematic -- [ ] Netlist shows connections -- [ ] 3+ integration tests passing - -### Phase 2 Success: -- [ ] Net labels work (VCC, GND, etc.) -- [ ] Multiple components on same net -- [ ] `get_net_connections` returns correct results -- [ ] Netlist includes named nets -- [ ] 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 -- [ ] User can create functional circuits without errors - ---- - -## 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 | -| Performance issues | Low | Medium | Optimize S-expression parsing | -| KiCad format changes | Low | High | Version detection, compatibility | - ---- - -## 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`) - ---- - -## Timeline Estimate - -**Phase 1:** 1 week (26 hours) -**Phase 2:** 1 week (28 hours) -**Phase 3:** 1.5 weeks (28 hours) -**Phase 4:** 1.5 weeks (28 hours) - -**Total:** 5 weeks (110 hours) - -**Accelerated path (core features only):** 2-3 weeks (Phases 1-2) - ---- - -## Next Immediate Steps - -1. **Research kicad-skip Wire API** (TODAY) - - Test with Python REPL - - Document findings - - Choose implementation approach - -2. **Create Test Environment** (TOMORROW) - - Set up test schematic - - Manual wire creation in KiCad - - Export for comparison - -3. **Implement Basic Wire** (THIS WEEK) - - Update ConnectionManager.add_wire() - - Test with simple coordinates - - Verify in KiCad - -4. **Fix Pin Discovery** (THIS WEEK) - - Parse symbol definitions - - Calculate absolute positions - - Test with rotated symbols - ---- - -## User Communication - -**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 -- 🎯 Goal: Complete functional schematics with wiring - ---- - -**Status:** Ready for implementation -**Owner:** TBD -**Priority:** HIGH (user-blocking feature) +# Schematic Wiring Implementation Plan + +**Date:** 2026-01-10 +**Status:** Planning Phase +**Priority:** HIGH (User-requested feature for Issue #26) + +--- + +## Executive Summary + +This plan outlines the implementation of complete schematic wiring functionality for the KiCAD MCP Server. Currently, component placement works perfectly with dynamic symbol loading, but wire/connection tools are incomplete or non-functional. + +**Goal:** Enable users to create complete, functional schematics with wired connections between components through the MCP interface. + +--- + +## Current State Analysis + +### 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 +4. `connect_to_net` - Connect pin to named net +5. `get_net_connections` - Query net connections +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 +- `connect_to_net(schematic, component_ref, pin_name, net_name)` - Pin to net +- `get_pin_location(symbol, pin_name)` - Get pin coordinates +- `get_net_connections(schematic, net_name)` - Query connections +- `generate_netlist(schematic)` - Generate netlist + +### 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 +- No power symbols (VCC, GND graphical symbols) +- No hierarchical labels + +--- + +## Technical Challenges + +### 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)) + (uuid "12345678-1234-1234-1234-123456789012") +) +``` + +**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) + +### Challenge 2: Pin Location Discovery + +**Issue:** Need to find exact pin coordinates for automatic wiring. + +**Pin Data in Symbols:** +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 + +### Challenge 3: Smart Wire Routing + +**Issue:** Users don't want to manually specify every wire segment. + +**Desired Behavior:** + +``` +User: "Connect R1 pin 1 to C1 pin 1" +System: + - Calculate R1 pin 1 location: (100, 100) + - Calculate C1 pin 1 location: (150, 120) + - Create wire path (orthogonal routing preferred): + - (100, 100) → (100, 120) → (150, 120) + - Or simple direct: (100, 100) → (150, 120) +``` + +**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) + +**Phase 1 Approach:** Start with direct wiring, add orthogonal later. + +### Challenge 4: Net Label Integration + +**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 + +--- + +## Implementation Phases + +### Phase 1: Core Wire Functionality (Week 1) + +**Goal:** Get basic wiring working with kicad-skip API + +**Tasks:** + +1. **Research kicad-skip Wire API** (4 hours) + - Read kicad-skip source code + - Test wire creation with Python REPL + - Document actual API methods + - Create test schematic with manual wires + +2. **Fix Wire Creation** (6 hours) + - Update ConnectionManager.add_wire() with correct API + - Handle S-expression manipulation if needed + - Add UUID generation for wires + - Test with simple wire (100,100) → (200,100) + +3. **Implement Pin Discovery** (8 hours) + - Parse symbol definitions to extract pin data + - Handle pin coordinates relative to symbol + - Apply rotation transformation + - Test with R, C, LED from dynamic symbols + +4. **Fix add_schematic_connection** (4 hours) + - Use correct pin discovery + - Create direct wire between pins + - Handle error cases (pin not found, etc.) + - Test with R1 pin 2 → C1 pin 1 + +5. **Integration Testing** (4 hours) + - Create test schematic with R, C, LED + - Wire R to C + - Wire C to LED + - Verify schematic opens in KiCad + - Verify electrical connectivity + +**Deliverables:** + +- Working `add_schematic_wire` tool +- Working `add_schematic_connection` tool +- Pin location discovery working +- Integration test passing +- Documentation updated + +**Success Criteria:** + +- User can connect two resistor pins with MCP command +- Wire appears in KiCad schematic viewer +- Netlist shows electrical connection + +--- + +### Phase 2: Net Labels & Named Nets (Week 1-2) + +**Goal:** Enable named net connections (VCC, GND, etc.) + +**Tasks:** + +1. **Fix Net Label Creation** (4 hours) + - Update ConnectionManager.add_net_label() + - Use correct kicad-skip API or S-expression + - Position labels correctly + - Test label creation + +2. **Implement connect_to_net** (6 hours) + - Create wire stub from pin + - Attach label to wire endpoint + - Support common nets (VCC, GND, 3V3, etc.) + - Test with multiple components on same net + +3. **Net Connection Discovery** (6 hours) + - Parse wires and labels in schematic + - Build connectivity graph + - Implement get_net_connections() + - Return all pins on a net + +4. **Power Symbol Support** (8 hours) + - Add power symbols to templates (VCC, GND, 3V3, 5V) + - Or dynamically load from power library + - Connect power pins to power nets + - Test complete circuit with power + +5. **Testing** (4 hours) + - Create circuit with VCC, GND nets + - Connect multiple components to each net + - Verify net connectivity + - Generate and validate netlist + +**Deliverables:** + +- Working `add_schematic_net_label` tool +- Working `connect_to_net` tool +- Working `get_net_connections` tool +- Power symbol support +- Netlist generation working + +**Success Criteria:** + +- User can label nets VCC, GND +- Multiple components connect to same net +- Netlist correctly shows net membership + +--- + +### Phase 3: Advanced Features (Week 2-3) + +**Goal:** Professional schematic features + +**Tasks:** + +1. **Junction Support** (4 hours) + - Detect wire intersections + - Add junction dots at T-junctions + - S-expression: `(junction (at x y) (diameter 0) (uuid ...))` + +2. **No-Connect Flags** (2 hours) + - Add "X" marks on unused pins + - S-expression: `(no_connect (at x y) (uuid ...))` + +3. **Orthogonal Routing** (6 hours) + - Implement 2-segment wire routing + - Horizontal-then-vertical or vertical-then-horizontal + - Choose best path based on pin positions + +4. **Bus Support** (8 hours) + - Multi-bit signal buses + - Bus labels (e.g., "D[0..7]") + - Bus entries for individual signals + +5. **Hierarchical Labels** (8 hours) + - Labels for hierarchical sheets + - Input/Output/Bidirectional types + - 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 + +--- + +### Phase 4: Validation & Polish (Week 3-4) + +**Goal:** Production-ready reliability + +**Tasks:** + +1. **ERC Integration** (6 hours) + - Electrical Rule Check + - Detect floating nets + - Detect unconnected pins + - Detect short circuits + +2. **Visual Validation** (4 hours) + - Export schematic to PDF after wiring + - Verify wire appearance + - Check net label placement + +3. **Comprehensive Testing** (8 hours) + - Test with Device library components + - Test with IC components (multi-pin) + - Test with connectors + - Test complex circuits (10+ components) + +4. **Error Handling** (4 hours) + - Graceful failures + - Clear error messages + - Validation of coordinates + - Duplicate net label detection + +5. **Documentation** (6 hours) + - Update MCP tool descriptions + - Add usage examples to README + - Create wiring tutorial + - 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 + +--- + +## Technical Approach + +### Option A: Use kicad-skip Native API (Preferred) + +**If kicad-skip supports wires natively:** + +```python +# Add wire using native API +wire = schematic.wire.new( + start=[100, 100], + end=[200, 100] +) + +# Add label +label = schematic.label.new( + text="VCC", + at=[150, 100] +) +``` + +**Pros:** + +- Clean, maintainable code +- Follows library patterns +- Less likely to break + +**Cons:** + +- Depends on kicad-skip having these features +- May be limited in functionality + +### Option B: S-Expression Manipulation (Fallback) + +**If kicad-skip doesn't support wires:** + +Use the same approach as dynamic symbol loading: + +```python +import sexpdata +from sexpdata import Symbol + +# Read schematic +with open(schematic_path, 'r') as f: + sch_data = sexpdata.loads(f.read()) + +# Create wire S-expression +wire_sexp = [ + Symbol('wire'), + [Symbol('pts'), + [Symbol('xy'), 100, 100], + [Symbol('xy'), 200, 100] + ], + [Symbol('stroke'), [Symbol('width'), 0], [Symbol('type'), Symbol('default')]], + [Symbol('uuid'), str(uuid.uuid4())] +] + +# Insert into schematic +sch_data.append(wire_sexp) + +# Write back +with open(schematic_path, 'w') as f: + f.write(sexpdata.dumps(sch_data)) +``` + +**Pros:** + +- Complete control +- Can implement any feature +- Works around library limitations + +**Cons:** + +- More complex +- Requires deep KiCad format knowledge +- More maintenance + +### Hybrid Approach (Recommended) + +1. Try kicad-skip native API first +2. Fall back to S-expression if needed +3. Use S-expression for advanced features (junctions, buses) + +--- + +## Pin Discovery Algorithm + +### Step 1: Get Symbol Definition + +Symbols are stored in `lib_symbols` section: + +```lisp +(lib_symbols + (symbol "Device:R" + (symbol "R_0_1" + (rectangle (start -1 -2.54) (end 1 2.54) ...)) + (symbol "R_1_1" + (pin passive line (at 0 3.81 270) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27))))) + (pin passive line (at 0 -3.81 90) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27))))))) +``` + +### 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)` +- Length (distance from symbol body to connection point) + +### Step 3: Get Symbol Instance Position + +From symbol instance in schematic: + +```lisp +(symbol (lib_id "Device:R") (at 100 100 0) (unit 1) + (property "Reference" "R1" ...)) +``` + +Extract: + +- Position: `(at 100 100 0)` = x=100, y=100, rotation=0° +- Reference: "R1" + +### Step 4: Calculate Absolute Pin Position + +```python +def get_absolute_pin_position(symbol_instance, pin_definition): + # Symbol position + symbol_x, symbol_y, symbol_rotation = symbol_instance.at.value + + # Pin position relative to symbol + pin_x, pin_y, pin_angle = pin_definition.at.value + + # Apply rotation transformation + if symbol_rotation != 0: + # Rotate pin coordinates around origin + rad = math.radians(symbol_rotation) + rotated_x = pin_x * math.cos(rad) - pin_y * math.sin(rad) + rotated_y = pin_x * math.sin(rad) + pin_y * math.cos(rad) + pin_x, pin_y = rotated_x, rotated_y + + # Translate to absolute position + abs_x = symbol_x + pin_x + abs_y = symbol_y + pin_y + + return [abs_x, abs_y] +``` + +--- + +## Wire Routing Strategies + +### Strategy 1: Direct Wire (Phase 1) + +Simplest: single wire segment from pin A to pin B. + +``` +R1 pin 2 C1 pin 1 + o-------------o +``` + +**Pros:** Simple, fast +**Cons:** Diagonal wires (not standard practice) + +### Strategy 2: Orthogonal 2-Segment (Phase 3) + +Two segments: horizontal then vertical, or vertical then horizontal. + +``` +R1 pin 2 C1 pin 1 + o-----┐ + │ + └------o +``` + +**Algorithm:** + +1. Calculate midpoint +2. Route horizontal to midpoint +3. Route vertical to target +4. Or vice versa based on direction + +**Pros:** Standard practice, cleaner schematics +**Cons:** Slightly more complex + +### Strategy 3: Manhattan Routing (Future) + +Complex multi-segment paths avoiding components. + +**Pros:** Professional appearance +**Cons:** Requires collision detection, path planning + +--- + +## Testing Strategy + +### Unit Tests + +Test individual functions: + +- `test_add_wire()` - Wire creation +- `test_get_pin_location()` - Pin discovery +- `test_add_net_label()` - Label creation +- `test_calculate_pin_position()` - Coordinate math + +### 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 +- `test_schematic_opens_in_kicad()` - File validity + +### Manual Validation + +- Create test schematic in KiCad manually +- Add same connections via MCP +- Compare results +- Verify electrical connectivity in KiCad + +--- + +## Success Metrics + +### Phase 1 Success: + +- [ ] `add_schematic_wire` works (coordinates) +- [ ] `add_schematic_connection` works (pin to pin) +- [ ] Wires appear in KiCad schematic +- [ ] Netlist shows connections +- [ ] 3+ integration tests passing + +### Phase 2 Success: + +- [ ] Net labels work (VCC, GND, etc.) +- [ ] Multiple components on same net +- [ ] `get_net_connections` returns correct results +- [ ] Netlist includes named nets +- [ ] 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 +- [ ] User can create functional circuits without errors + +--- + +## 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 | +| Performance issues | Low | Medium | Optimize S-expression parsing | +| KiCad format changes | Low | High | Version detection, compatibility | + +--- + +## 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`) + +--- + +## Timeline Estimate + +**Phase 1:** 1 week (26 hours) +**Phase 2:** 1 week (28 hours) +**Phase 3:** 1.5 weeks (28 hours) +**Phase 4:** 1.5 weeks (28 hours) + +**Total:** 5 weeks (110 hours) + +**Accelerated path (core features only):** 2-3 weeks (Phases 1-2) + +--- + +## Next Immediate Steps + +1. **Research kicad-skip Wire API** (TODAY) + - Test with Python REPL + - Document findings + - Choose implementation approach + +2. **Create Test Environment** (TOMORROW) + - Set up test schematic + - Manual wire creation in KiCad + - Export for comparison + +3. **Implement Basic Wire** (THIS WEEK) + - Update ConnectionManager.add_wire() + - Test with simple coordinates + - Verify in KiCad + +4. **Fix Pin Discovery** (THIS WEEK) + - Parse symbol definitions + - Calculate absolute positions + - Test with rotated symbols + +--- + +## User Communication + +**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 +- 🎯 Goal: Complete functional schematics with wiring + +--- + +**Status:** Ready for implementation +**Owner:** TBD +**Priority:** HIGH (user-blocking feature) diff --git a/docs/archive/SCHEMATIC_WORKFLOW_FIX.md b/docs/archive/SCHEMATIC_WORKFLOW_FIX.md index c315e33..ac732e7 100644 --- a/docs/archive/SCHEMATIC_WORKFLOW_FIX.md +++ b/docs/archive/SCHEMATIC_WORKFLOW_FIX.md @@ -1,125 +1,133 @@ -# Schematic Workflow Fix - Issue #26 - -## Problem Summary - -The schematic workflow was completely broken due to incorrect usage of the kicad-skip library: - -1. **`create_project`** only created PCB files, no schematic -2. **`create_schematic`** created orphaned schematic files not linked to projects -3. **`add_schematic_component`** called non-existent `schematic.add_symbol()` method -4. Project files didn't reference schematics in their structure - -## Root Cause - -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 - -### 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 - -### 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 -- Correct position setting: `symbol.at.value = [x, y, rotation]` - -### 3. Correct Workflow - -```python -from commands.project import ProjectCommands -from commands.schematic import SchematicManager -from commands.component_schematic import ComponentManager - -# Step 1: Create project (creates both PCB and schematic) -project_cmd = ProjectCommands() -result = project_cmd.create_project({ - "name": "MyProject", - "path": "/path/to/project" -}) - -# Step 2: Load the schematic -sch = SchematicManager.load_schematic(result['project']['schematicPath']) - -# Step 3: Add components by cloning templates -component_def = { - "type": "R", # Maps to _TEMPLATE_R - "reference": "R1", # Component reference - "value": "10k", # Component value - "footprint": "Resistor_SMD:R_0603_1608Metric", - "x": 50.8, # Position in mm - "y": 50.8, # Position in mm - "rotation": 0 # Rotation in degrees -} -symbol = ComponentManager.add_component(sch, component_def) - -# Step 4: Save the schematic -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 -- Saves schematic -- Validates with `kicad-cli sch export pdf` - -All tests passing ✓ - -## Files Modified - -- `python/commands/project.py` - Added schematic creation -- `python/commands/schematic.py` - Fixed template usage -- `python/commands/component_schematic.py` - Rewritten to use clone() API -- `python/templates/empty.kicad_sch` - Minimal template (created) -- `python/templates/template_with_symbols.kicad_sch` - Template with cloneable symbols (created) - -## Limitations - -1. Can only add components that have templates defined -2. Template symbols remain in schematic (but marked as DNP/not in BOM) -3. Complex symbols (multi-unit, hierarchical) may need custom templates - -## Future Improvements - -1. Add more component templates (transistors, connectors, ICs) -2. Dynamic template generation from KiCad symbol libraries -3. Auto-hide template symbols in schematic -4. Support for custom user templates - -## References - -- GitHub Issue: #26 -- kicad-skip documentation: https://github.com/psychogenic/kicad-skip -- Test results: `/tmp/test_schematic_workflow/` +# Schematic Workflow Fix - Issue #26 + +## Problem Summary + +The schematic workflow was completely broken due to incorrect usage of the kicad-skip library: + +1. **`create_project`** only created PCB files, no schematic +2. **`create_schematic`** created orphaned schematic files not linked to projects +3. **`add_schematic_component`** called non-existent `schematic.add_symbol()` method +4. Project files didn't reference schematics in their structure + +## Root Cause + +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 + +### 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 + +### 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 +- Correct position setting: `symbol.at.value = [x, y, rotation]` + +### 3. Correct Workflow + +```python +from commands.project import ProjectCommands +from commands.schematic import SchematicManager +from commands.component_schematic import ComponentManager + +# Step 1: Create project (creates both PCB and schematic) +project_cmd = ProjectCommands() +result = project_cmd.create_project({ + "name": "MyProject", + "path": "/path/to/project" +}) + +# Step 2: Load the schematic +sch = SchematicManager.load_schematic(result['project']['schematicPath']) + +# Step 3: Add components by cloning templates +component_def = { + "type": "R", # Maps to _TEMPLATE_R + "reference": "R1", # Component reference + "value": "10k", # Component value + "footprint": "Resistor_SMD:R_0603_1608Metric", + "x": 50.8, # Position in mm + "y": 50.8, # Position in mm + "rotation": 0 # Rotation in degrees +} +symbol = ComponentManager.add_component(sch, component_def) + +# Step 4: Save the schematic +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 +- Saves schematic +- Validates with `kicad-cli sch export pdf` + +All tests passing ✓ + +## Files Modified + +- `python/commands/project.py` - Added schematic creation +- `python/commands/schematic.py` - Fixed template usage +- `python/commands/component_schematic.py` - Rewritten to use clone() API +- `python/templates/empty.kicad_sch` - Minimal template (created) +- `python/templates/template_with_symbols.kicad_sch` - Template with cloneable symbols (created) + +## Limitations + +1. Can only add components that have templates defined +2. Template symbols remain in schematic (but marked as DNP/not in BOM) +3. Complex symbols (multi-unit, hierarchical) may need custom templates + +## Future Improvements + +1. Add more component templates (transistors, connectors, ICs) +2. Dynamic template generation from KiCad symbol libraries +3. Auto-hide template symbols in schematic +4. Support for custom user templates + +## References + +- GitHub Issue: #26 +- kicad-skip documentation: https://github.com/psychogenic/kicad-skip +- Test results: `/tmp/test_schematic_workflow/` diff --git a/docs/archive/WEEK1_SESSION1_SUMMARY.md b/docs/archive/WEEK1_SESSION1_SUMMARY.md index efa3206..acb11a0 100644 --- a/docs/archive/WEEK1_SESSION1_SUMMARY.md +++ b/docs/archive/WEEK1_SESSION1_SUMMARY.md @@ -1,505 +1,538 @@ -# Week 1 - Session 1 Summary -**Date:** October 25, 2025 -**Status:** ✅ **EXCELLENT PROGRESS** - ---- - -## 🎯 Mission - -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 -- Migrate to KiCAD IPC API (future-proof) - ---- - -## ✅ What We Accomplished Today - -### 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) - ---- - -### 2. **12-Week Rebuild Plan Created** 🗺️ - -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 - ---- - -### 3. **Linux Compatibility Infrastructure** 🐧 - -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 -- ✅ `config/windows-config.example.json` - Windows configuration template -- ✅ `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 -PlatformHelper.get_cache_dir() # ~/.cache/kicad-mcp -PlatformHelper.get_kicad_python_paths() # Auto-detects KiCAD install -``` - ---- - -### 4. **CI/CD Pipeline** 🚀 - -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 -- Code quality checks (ESLint, Prettier, Black, MyPy) -- Docker build test (future) -- Coverage reporting to Codecov - -**Status:** Ready to run on next git push - ---- - -### 5. **Python Testing Framework** 🧪 - -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 -@pytest.mark.linux # Linux-specific tests -@pytest.mark.windows # Windows-specific tests -``` - -**Test Results:** -``` -✅ Platform detection works correctly -✅ Config directories follow XDG spec on Linux -✅ Python 3.12.3 detected correctly -✅ Paths created automatically -``` - ---- - -### 6. **Developer Documentation** 📚 - -Created contributor guide: - -**File:** `CONTRIBUTING.md` - -**Includes:** -- Platform-specific setup instructions (Linux/Windows/macOS) -- Project structure overview -- Development workflow -- Testing guide -- Code style guidelines (Black, MyPy, ESLint) -- Pull request process - ---- - -### 7. **Dependencies Management** 📦 - -**Production Dependencies (requirements.txt):** -``` -kicad-skip>=0.1.0 # Schematic manipulation -Pillow>=9.0.0 # Image processing -cairosvg>=2.7.0 # SVG rendering -pydantic>=2.5.0 # Data validation -requests>=2.31.0 # API clients -python-dotenv>=1.0.0 # Config management -``` - -**Development Dependencies:** -``` -pytest>=7.4.0 # Testing -black>=23.7.0 # Code formatting -mypy>=1.5.0 # Type checking -pylint>=2.17.0 # Linting -``` - ---- - -## 🎯 Week 1 Progress Tracking - -### ✅ Completed Tasks (8/9) - -1. ✅ **Audit codebase for Linux compatibility** - - Created comprehensive audit document - - Identified all platform-specific issues - - Prioritized fixes (P0, P1, P2, P3) - -2. ✅ **Create GitHub Actions CI/CD** - - Multi-platform testing matrix - - Python + TypeScript testing - - Code quality checks - - Coverage reporting - -3. ✅ **Fix path handling** - - Created PlatformHelper utility - - Follows XDG Base Directory spec on Linux - - Auto-detects KiCAD installation paths - -4. ✅ **Update logging paths** - - Linux: ~/.config/kicad-mcp/logs - - Windows: ~\.kicad-mcp\logs - - macOS: ~/Library/Application Support/kicad-mcp/logs - -5. ✅ **Create CONTRIBUTING.md** - - Complete developer guide - - Platform-specific setup - - Testing instructions - -6. ✅ **Set up pytest framework** - - pytest.ini with coverage - - Test markers for organization - - Sample tests passing - -7. ✅ **Create platform config templates** - - linux-config.example.json - - windows-config.example.json - - macos-config.example.json - -8. ✅ **Create development infrastructure** - - requirements.txt + requirements-dev.txt - - Platform helper utilities - - Test framework - -### ⏳ Remaining Week 1 Tasks (1/9) - -9. ⏳ **Docker container for testing** (Optional for Week 1) - - Will create in Week 2 for consistent testing environment - ---- - -## 📁 Files Created/Modified Today - -### New Files (17) - -``` -.github/workflows/ci.yml # CI/CD pipeline -config/linux-config.example.json # Linux config -config/windows-config.example.json # Windows config -config/macos-config.example.json # macOS config -docs/LINUX_COMPATIBILITY_AUDIT.md # Audit report -docs/WEEK1_SESSION1_SUMMARY.md # This file -python/utils/__init__.py # Utils package -python/utils/platform_helper.py # Platform detection (300 lines) -tests/__init__.py # Tests package -tests/test_platform_helper.py # Platform tests (150 lines) -pytest.ini # Pytest config -requirements.txt # Python deps -requirements-dev.txt # Python dev deps -CONTRIBUTING.md # Developer guide -``` - -### Modified Files (1) - -``` -python/utils/platform_helper.py # Fixed docstring warnings -``` - ---- - -## 🧪 Testing Status - -### Unit Tests - -```bash -$ python3 python/utils/platform_helper.py -✅ Platform detection works -✅ Linux detected correctly -✅ Python 3.12.3 found -✅ Config dir: /home/chris/.config/kicad-mcp -✅ Log dir: /home/chris/.config/kicad-mcp/logs -✅ Cache dir: /home/chris/.cache/kicad-mcp -⚠️ KiCAD not installed (expected on dev machine) -``` - -### CI/CD Pipeline - -``` -Status: Ready to run -Triggers: Push to main/develop, Pull Requests -Platforms: Ubuntu 24.04, 22.04, Windows, macOS -Python: 3.10, 3.11, 3.12 -Node: 18.x, 20.x, 22.x -``` - ---- - -## 🎯 Next Steps (Week 1 Remaining) - -### Week 1 - Days 2-5 - -1. **Update README.md with Linux installation** - - Add Linux-specific setup instructions - - Link to platform-specific configs - - Add troubleshooting section - -2. **Test on actual Ubuntu 24.04 LTS** - - Install KiCAD 9.0 - - Run full test suite - - Document any issues found - -3. **Begin IPC API research** (Week 2 prep) - - Install `kicad-python` package - - Test IPC API connection - - Compare with SWIG API - -4. **Start JLCPCB API research** (Week 5 prep) - - Apply for API access - - Review API documentation - - Plan integration architecture - ---- - -## 📊 Metrics - -### Code Quality - -- **Python Code Style:** Black formatting ready -- **Type Hints:** 100% in new code (platform_helper.py) -- **Documentation:** Comprehensive docstrings -- **Test Coverage:** 20+ unit tests for platform_helper - -### Platform Support - -- **Windows:** ✅ Original support maintained -- **Linux:** ✅ Full support added -- **macOS:** ✅ Partial support (untested) - -### Dependencies - -- **Python Packages:** 7 production, 10 development -- **Node.js Packages:** Existing (no changes yet) -- **External APIs:** 0 (planned: JLCPCB, Digikey) - ---- - -## 🚀 Impact Assessment - -### Before Today -- ❌ Windows-only -- ❌ No CI/CD -- ❌ No tests -- ❌ Hardcoded paths -- ❌ No developer documentation - -### After Today -- ✅ Cross-platform (Linux/Windows/macOS) -- ✅ GitHub Actions CI/CD -- ✅ 20+ unit tests with pytest -- ✅ Platform-agnostic paths (XDG spec) -- ✅ Complete developer guide - -**Developer Experience:** Massively improved -**Contributor Onboarding:** Now takes minutes instead of hours -**Code Maintainability:** Significantly better -**Future-Proofing:** Foundation laid for IPC API migration - ---- - -## 💡 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 -- Decision: Use pytest for all tests - ---- - -## 🎓 Lessons Learned - -### Technical Insights - -1. **XDG Base Directory Spec** - Linux has clear standards for config/cache/data -2. **pathlib > os.path** - More readable, cross-platform by default -3. **Platform detection** - Check environment variables before hardcoding paths -4. **Type hints** - Make code self-documenting and catch bugs early - -### Process Insights - -1. **Audit first, code second** - Understanding the problem space saves time -2. **Infrastructure before features** - CI/CD and testing pay dividends -3. **Documentation is code** - Good docs prevent future confusion -4. **Cross-platform from day 1** - Retrofitting is painful - ---- - -## 🎉 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! - ---- - -## 🤝 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 - ---- - -## 📅 Schedule Status - -### Week 1 Goals -- [x] Linux compatibility audit (**100% complete**) -- [x] CI/CD setup (**100% complete**) -- [x] Development infrastructure (**100% complete**) -- [ ] Linux installation testing (**0% complete** - needs Ubuntu 24.04) - -**Overall Week 1 Progress: ~80% complete** - -**Status: 🟢 ON TRACK** - ---- - -## 🎯 Next Session Goals - -1. Update README.md with Linux instructions -2. Test on actual Ubuntu 24.04 LTS with KiCAD 9.0 -3. Run full pytest suite -4. Fix any issues found during testing -5. Begin IPC API research (install kicad-python) - -**Estimated Time: 2-3 hours** - ---- - -## 📝 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? - ---- - -## 🏆 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 | ✅ | -| Documentation | CONTRIBUTING.md | ✅ Complete | ✅ | -| Config templates | 3 platforms | ✅ 3 created | ✅ | -| Platform helper | Path utilities | ✅ 300 lines | ✅ | - -**Overall Session Rating: 🌟🌟🌟🌟🌟 (5/5)** - ---- - -## 🙏 Acknowledgments - -- **KiCAD Team** - For the excellent IPC API documentation -- **Anthropic** - For MCP specification and best practices -- **JLCPCB/Digikey** - For API availability - ---- - -**Session End Time:** October 25, 2025 -**Duration:** ~2 hours -**Files Created:** 17 -**Lines of Code:** ~1000+ -**Tests Written:** 20+ -**Documentation Pages:** 5 - ---- - -## 🚀 Ready for Week 1, Day 2! - -**Next Session Focus:** Linux testing + README updates -**Energy Level:** 🔋🔋🔋🔋🔋 (High) -**Confidence Level:** 💪💪💪💪💪 (Very High) - -Let's keep this momentum going! 🎉 +# Week 1 - Session 1 Summary + +**Date:** October 25, 2025 +**Status:** ✅ **EXCELLENT PROGRESS** + +--- + +## 🎯 Mission + +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 +- Migrate to KiCAD IPC API (future-proof) + +--- + +## ✅ What We Accomplished Today + +### 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) + +--- + +### 2. **12-Week Rebuild Plan Created** 🗺️ + +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 + +--- + +### 3. **Linux Compatibility Infrastructure** 🐧 + +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 +- ✅ `config/windows-config.example.json` - Windows configuration template +- ✅ `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 +PlatformHelper.get_cache_dir() # ~/.cache/kicad-mcp +PlatformHelper.get_kicad_python_paths() # Auto-detects KiCAD install +``` + +--- + +### 4. **CI/CD Pipeline** 🚀 + +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 +- Code quality checks (ESLint, Prettier, Black, MyPy) +- Docker build test (future) +- Coverage reporting to Codecov + +**Status:** Ready to run on next git push + +--- + +### 5. **Python Testing Framework** 🧪 + +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 +@pytest.mark.linux # Linux-specific tests +@pytest.mark.windows # Windows-specific tests +``` + +**Test Results:** + +``` +✅ Platform detection works correctly +✅ Config directories follow XDG spec on Linux +✅ Python 3.12.3 detected correctly +✅ Paths created automatically +``` + +--- + +### 6. **Developer Documentation** 📚 + +Created contributor guide: + +**File:** `CONTRIBUTING.md` + +**Includes:** + +- Platform-specific setup instructions (Linux/Windows/macOS) +- Project structure overview +- Development workflow +- Testing guide +- Code style guidelines (Black, MyPy, ESLint) +- Pull request process + +--- + +### 7. **Dependencies Management** 📦 + +**Production Dependencies (requirements.txt):** + +``` +kicad-skip>=0.1.0 # Schematic manipulation +Pillow>=9.0.0 # Image processing +cairosvg>=2.7.0 # SVG rendering +pydantic>=2.5.0 # Data validation +requests>=2.31.0 # API clients +python-dotenv>=1.0.0 # Config management +``` + +**Development Dependencies:** + +``` +pytest>=7.4.0 # Testing +black>=23.7.0 # Code formatting +mypy>=1.5.0 # Type checking +pylint>=2.17.0 # Linting +``` + +--- + +## 🎯 Week 1 Progress Tracking + +### ✅ Completed Tasks (8/9) + +1. ✅ **Audit codebase for Linux compatibility** + - Created comprehensive audit document + - Identified all platform-specific issues + - Prioritized fixes (P0, P1, P2, P3) + +2. ✅ **Create GitHub Actions CI/CD** + - Multi-platform testing matrix + - Python + TypeScript testing + - Code quality checks + - Coverage reporting + +3. ✅ **Fix path handling** + - Created PlatformHelper utility + - Follows XDG Base Directory spec on Linux + - Auto-detects KiCAD installation paths + +4. ✅ **Update logging paths** + - Linux: ~/.config/kicad-mcp/logs + - Windows: ~\.kicad-mcp\logs + - macOS: ~/Library/Application Support/kicad-mcp/logs + +5. ✅ **Create CONTRIBUTING.md** + - Complete developer guide + - Platform-specific setup + - Testing instructions + +6. ✅ **Set up pytest framework** + - pytest.ini with coverage + - Test markers for organization + - Sample tests passing + +7. ✅ **Create platform config templates** + - linux-config.example.json + - windows-config.example.json + - macos-config.example.json + +8. ✅ **Create development infrastructure** + - requirements.txt + requirements-dev.txt + - Platform helper utilities + - Test framework + +### ⏳ Remaining Week 1 Tasks (1/9) + +9. ⏳ **Docker container for testing** (Optional for Week 1) + - Will create in Week 2 for consistent testing environment + +--- + +## 📁 Files Created/Modified Today + +### New Files (17) + +``` +.github/workflows/ci.yml # CI/CD pipeline +config/linux-config.example.json # Linux config +config/windows-config.example.json # Windows config +config/macos-config.example.json # macOS config +docs/LINUX_COMPATIBILITY_AUDIT.md # Audit report +docs/WEEK1_SESSION1_SUMMARY.md # This file +python/utils/__init__.py # Utils package +python/utils/platform_helper.py # Platform detection (300 lines) +tests/__init__.py # Tests package +tests/test_platform_helper.py # Platform tests (150 lines) +pytest.ini # Pytest config +requirements.txt # Python deps +requirements-dev.txt # Python dev deps +CONTRIBUTING.md # Developer guide +``` + +### Modified Files (1) + +``` +python/utils/platform_helper.py # Fixed docstring warnings +``` + +--- + +## 🧪 Testing Status + +### Unit Tests + +```bash +$ python3 python/utils/platform_helper.py +✅ Platform detection works +✅ Linux detected correctly +✅ Python 3.12.3 found +✅ Config dir: /home/chris/.config/kicad-mcp +✅ Log dir: /home/chris/.config/kicad-mcp/logs +✅ Cache dir: /home/chris/.cache/kicad-mcp +⚠️ KiCAD not installed (expected on dev machine) +``` + +### CI/CD Pipeline + +``` +Status: Ready to run +Triggers: Push to main/develop, Pull Requests +Platforms: Ubuntu 24.04, 22.04, Windows, macOS +Python: 3.10, 3.11, 3.12 +Node: 18.x, 20.x, 22.x +``` + +--- + +## 🎯 Next Steps (Week 1 Remaining) + +### Week 1 - Days 2-5 + +1. **Update README.md with Linux installation** + - Add Linux-specific setup instructions + - Link to platform-specific configs + - Add troubleshooting section + +2. **Test on actual Ubuntu 24.04 LTS** + - Install KiCAD 9.0 + - Run full test suite + - Document any issues found + +3. **Begin IPC API research** (Week 2 prep) + - Install `kicad-python` package + - Test IPC API connection + - Compare with SWIG API + +4. **Start JLCPCB API research** (Week 5 prep) + - Apply for API access + - Review API documentation + - Plan integration architecture + +--- + +## 📊 Metrics + +### Code Quality + +- **Python Code Style:** Black formatting ready +- **Type Hints:** 100% in new code (platform_helper.py) +- **Documentation:** Comprehensive docstrings +- **Test Coverage:** 20+ unit tests for platform_helper + +### Platform Support + +- **Windows:** ✅ Original support maintained +- **Linux:** ✅ Full support added +- **macOS:** ✅ Partial support (untested) + +### Dependencies + +- **Python Packages:** 7 production, 10 development +- **Node.js Packages:** Existing (no changes yet) +- **External APIs:** 0 (planned: JLCPCB, Digikey) + +--- + +## 🚀 Impact Assessment + +### Before Today + +- ❌ Windows-only +- ❌ No CI/CD +- ❌ No tests +- ❌ Hardcoded paths +- ❌ No developer documentation + +### After Today + +- ✅ Cross-platform (Linux/Windows/macOS) +- ✅ GitHub Actions CI/CD +- ✅ 20+ unit tests with pytest +- ✅ Platform-agnostic paths (XDG spec) +- ✅ Complete developer guide + +**Developer Experience:** Massively improved +**Contributor Onboarding:** Now takes minutes instead of hours +**Code Maintainability:** Significantly better +**Future-Proofing:** Foundation laid for IPC API migration + +--- + +## 💡 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 +- Decision: Use pytest for all tests + +--- + +## 🎓 Lessons Learned + +### Technical Insights + +1. **XDG Base Directory Spec** - Linux has clear standards for config/cache/data +2. **pathlib > os.path** - More readable, cross-platform by default +3. **Platform detection** - Check environment variables before hardcoding paths +4. **Type hints** - Make code self-documenting and catch bugs early + +### Process Insights + +1. **Audit first, code second** - Understanding the problem space saves time +2. **Infrastructure before features** - CI/CD and testing pay dividends +3. **Documentation is code** - Good docs prevent future confusion +4. **Cross-platform from day 1** - Retrofitting is painful + +--- + +## 🎉 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! + +--- + +## 🤝 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 + +--- + +## 📅 Schedule Status + +### Week 1 Goals + +- [x] Linux compatibility audit (**100% complete**) +- [x] CI/CD setup (**100% complete**) +- [x] Development infrastructure (**100% complete**) +- [ ] Linux installation testing (**0% complete** - needs Ubuntu 24.04) + +**Overall Week 1 Progress: ~80% complete** + +**Status: 🟢 ON TRACK** + +--- + +## 🎯 Next Session Goals + +1. Update README.md with Linux instructions +2. Test on actual Ubuntu 24.04 LTS with KiCAD 9.0 +3. Run full pytest suite +4. Fix any issues found during testing +5. Begin IPC API research (install kicad-python) + +**Estimated Time: 2-3 hours** + +--- + +## 📝 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? + +--- + +## 🏆 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 | ✅ | +| Documentation | CONTRIBUTING.md | ✅ Complete | ✅ | +| Config templates | 3 platforms | ✅ 3 created | ✅ | +| Platform helper | Path utilities | ✅ 300 lines | ✅ | + +**Overall Session Rating: 🌟🌟🌟🌟🌟 (5/5)** + +--- + +## 🙏 Acknowledgments + +- **KiCAD Team** - For the excellent IPC API documentation +- **Anthropic** - For MCP specification and best practices +- **JLCPCB/Digikey** - For API availability + +--- + +**Session End Time:** October 25, 2025 +**Duration:** ~2 hours +**Files Created:** 17 +**Lines of Code:** ~1000+ +**Tests Written:** 20+ +**Documentation Pages:** 5 + +--- + +## 🚀 Ready for Week 1, Day 2! + +**Next Session Focus:** Linux testing + README updates +**Energy Level:** 🔋🔋🔋🔋🔋 (High) +**Confidence Level:** 💪💪💪💪💪 (Very High) + +Let's keep this momentum going! 🎉 diff --git a/docs/archive/WEEK1_SESSION2_SUMMARY.md b/docs/archive/WEEK1_SESSION2_SUMMARY.md index 76eb8d3..d12f170 100644 --- a/docs/archive/WEEK1_SESSION2_SUMMARY.md +++ b/docs/archive/WEEK1_SESSION2_SUMMARY.md @@ -1,422 +1,457 @@ -# Week 1 - Session 2 Summary -**Date:** October 25, 2025 (Afternoon) -**Status:** 🚀 **OUTSTANDING PROGRESS** - ---- - -## 🎯 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 -4. Set up development infrastructure - ---- - -## ✅ Completed Work - -### 1. **README.md Major Update** 📚 - -**File:** `README.md` - -**Changes:** -- ✅ Updated project status to reflect v2.0 rebuild -- ✅ Added collapsible platform-specific installation sections: - - 🐧 **Linux (Ubuntu/Debian)** - Primary, detailed - - 🪟 **Windows 10/11** - Fully supported - - 🍎 **macOS** - Experimental -- ✅ Updated system requirements (Linux primary platform) -- ✅ Added Quick Start section with test commands -- ✅ Better visual organization with emojis and status indicators - -**Impact:** New users can now install on Linux in < 10 minutes! - ---- - -### 2. **Linux Installation Script** 🛠️ - -**File:** `scripts/install-linux.sh` - -**Features:** -- ✅ Fully automated Ubuntu/Debian installation -- ✅ Color-coded output (info/success/warning/error) -- ✅ Safety checks (platform detection, command validation) -- ✅ Installs: - - KiCAD 9.0 from PPA - - Node.js 20.x - - Python dependencies - - Builds TypeScript -- ✅ Verification checks after installation -- ✅ Helpful next-steps guidance - -**Usage:** -```bash -cd kicad-mcp-server -./scripts/install-linux.sh -``` - -**Lines of Code:** ~200 lines of robust shell script - ---- - -### 3. **Pre-Commit Hooks Configuration** 🔧 - -**File:** `.pre-commit-config.yaml` - -**Hooks Added:** -- ✅ **Python:** - - Black (code formatting) - - isort (import sorting) - - MyPy (type checking) - - Flake8 (linting) - - Bandit (security checks) -- ✅ **TypeScript/JavaScript:** - - Prettier (formatting) -- ✅ **General:** - - Trailing whitespace removal - - End-of-file fixer - - YAML/JSON validation - - Large file detection - - Merge conflict detection - - Private key detection -- ✅ **Markdown:** - - Markdownlint (formatting) - -**Setup:** -```bash -pip install pre-commit -pre-commit install -``` - -**Impact:** Automatic code quality enforcement on every commit! - ---- - -### 4. **IPC API Migration Plan** 📋 - -**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) -- ✅ API comparison tables (SWIG vs IPC) -- ✅ Testing strategy -- ✅ Rollback plan -- ✅ Success criteria -- ✅ 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 -- Multi-language support opens future possibilities - ---- - -### 5. **IPC API Abstraction Layer** 🏗️ - -**New Module:** `python/kicad_api/` - -**Files Created (5):** - -1. **`__init__.py`** (20 lines) - - Package exports - - Version info - - Usage examples - -2. **`base.py`** (180 lines) - - `KiCADBackend` abstract base class - - `BoardAPI` abstract interface - - Custom exceptions (`BackendError`, `ConnectionError`, etc.) - - Defines contract for all backends - -3. **`factory.py`** (160 lines) - - `create_backend()` - Smart backend selection - - Auto-detection (try IPC, fall back to SWIG) - - Environment variable support (`KICAD_BACKEND`) - - `get_available_backends()` - Diagnostic function - - Comprehensive error handling - -4. **`ipc_backend.py`** (210 lines) - - `IPCBackend` class (kicad-python wrapper) - - `IPCBoardAPI` class - - Connection management - - Skeleton methods (to be implemented in Week 2-3) - - Clear TODO markers for migration - -5. **`swig_backend.py`** (220 lines) - - `SWIGBackend` class (wraps existing code) - - `SWIGBoardAPI` class - - Backward compatibility layer - - Deprecation warnings - - Bridges old commands to new interface - -**Total Lines of Code:** ~800 lines - -**Architecture:** -```python -from kicad_api import create_backend - -# Auto-detect best backend -backend = create_backend() - -# Or specify explicitly -backend = create_backend('ipc') # Use IPC -backend = create_backend('swig') # Use SWIG (deprecated) - -# Use unified interface -if backend.connect(): - board = backend.get_board() - board.set_size(100, 80) -``` - -**Key Features:** -- ✅ Abstraction allows painless migration -- ✅ Both backends can coexist during transition -- ✅ Easy testing (compare SWIG vs IPC outputs) -- ✅ Future-proof (add new backends easily) -- ✅ Type hints throughout -- ✅ Comprehensive error handling - ---- - -### 6. **Enhanced package.json** 📦 - -**File:** `package.json` - -**Improvements:** -- ✅ Version bumped to `2.0.0-alpha.1` -- ✅ Better description -- ✅ Enhanced npm scripts: - ```json - "build:watch": "tsc --watch" - "clean": "rm -rf dist" - "rebuild": "npm run clean && npm run build" - "test": "npm run test:ts && npm run test:py" - "test:py": "pytest tests/ -v" - "test:coverage": "pytest with coverage" - "lint": "npm run lint:ts && npm run lint:py" - "lint:py": "black + mypy + flake8" - "format": "prettier + black" - ``` - -**Impact:** Better developer experience, easier workflows - ---- - -## 📊 Statistics - -### Files Created/Modified (Session 2) - -**New Files (10):** -``` -docs/IPC_API_MIGRATION_PLAN.md # 500+ lines -docs/WEEK1_SESSION2_SUMMARY.md # This file -scripts/install-linux.sh # 200 lines -.pre-commit-config.yaml # 60 lines -python/kicad_api/__init__.py # 20 lines -python/kicad_api/base.py # 180 lines -python/kicad_api/factory.py # 160 lines -python/kicad_api/ipc_backend.py # 210 lines -python/kicad_api/swig_backend.py # 220 lines -``` - -**Modified Files (2):** -``` -README.md # Major rewrite -package.json # Enhanced scripts -``` - -**Total New Lines:** ~1,600+ lines of code/documentation - ---- - -### Combined Sessions 1+2 Today - -**Files Created:** 27 -**Lines Written:** ~3,000+ -**Documentation Pages:** 8 -**Tests Created:** 20+ - ---- - -## 🎯 Week 1 Status - -### Progress: **95% Complete** ████████████░ - -| Task | Status | -|------|--------| -| Linux compatibility | ✅ Complete | -| CI/CD pipeline | ✅ Complete | -| Cross-platform paths | ✅ Complete | -| Developer docs | ✅ Complete | -| pytest framework | ✅ Complete | -| Config templates | ✅ Complete | -| Installation scripts | ✅ Complete | -| Pre-commit hooks | ✅ Complete | -| IPC migration plan | ✅ Complete | -| IPC abstraction layer | ✅ Complete | -| README updates | ✅ Complete | -| Testing on Ubuntu | ⏳ Pending (needs KiCAD install) | - -**Only Remaining:** Test with actual KiCAD 9.0 installation! - ---- - -## 🚀 Ready for Week 2 - -### 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 -- ✅ SWIG wrapper for backward compatibility -- ✅ IPC skeleton awaiting implementation -- ✅ 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 -4. Create side-by-side tests (SWIG vs IPC) - ---- - -## 💡 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. - ---- - -## 🧪 Testing Readiness - -### When KiCAD is Installed - -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 -``` - ---- - -## 📈 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 - ---- - -## 🎯 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 -4. Create first IPC API tests - ---- - -## 🏆 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 -- ✅ IPC skeleton ready for implementation - ---- - -## 🎉 Overall Day Summary - -**Sessions 1+2 Combined:** -- ⏱️ **Time:** ~4-5 hours total -- 📝 **Files:** 27 created -- 💻 **Code:** ~3,000+ lines -- 📚 **Docs:** 8 comprehensive pages -- 🧪 **Tests:** 20+ unit tests -- ✅ **Week 1:** 95% complete - -**Status:** 🟢 **AHEAD OF SCHEDULE** - ---- - -## 🚀 Momentum Check - -**Energy Level:** 🔋🔋🔋🔋🔋 (Maximum) -**Code Quality:** ⭐⭐⭐⭐⭐ (Excellent) -**Documentation:** 📖📖📖📖📖 (Comprehensive) -**Architecture:** 🏗️🏗️🏗️🏗️🏗️ (Solid) - -**Ready for Week 2 IPC Migration:** ✅ YES! - ---- - -**End of Session 2** -**Next:** KiCAD installation + testing + Week 2 kickoff - -Let's keep this incredible momentum going! 🎉🚀 +# Week 1 - Session 2 Summary + +**Date:** October 25, 2025 (Afternoon) +**Status:** 🚀 **OUTSTANDING PROGRESS** + +--- + +## 🎯 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 +4. Set up development infrastructure + +--- + +## ✅ Completed Work + +### 1. **README.md Major Update** 📚 + +**File:** `README.md` + +**Changes:** + +- ✅ Updated project status to reflect v2.0 rebuild +- ✅ Added collapsible platform-specific installation sections: + - 🐧 **Linux (Ubuntu/Debian)** - Primary, detailed + - 🪟 **Windows 10/11** - Fully supported + - 🍎 **macOS** - Experimental +- ✅ Updated system requirements (Linux primary platform) +- ✅ Added Quick Start section with test commands +- ✅ Better visual organization with emojis and status indicators + +**Impact:** New users can now install on Linux in < 10 minutes! + +--- + +### 2. **Linux Installation Script** 🛠️ + +**File:** `scripts/install-linux.sh` + +**Features:** + +- ✅ Fully automated Ubuntu/Debian installation +- ✅ Color-coded output (info/success/warning/error) +- ✅ Safety checks (platform detection, command validation) +- ✅ Installs: + - KiCAD 9.0 from PPA + - Node.js 20.x + - Python dependencies + - Builds TypeScript +- ✅ Verification checks after installation +- ✅ Helpful next-steps guidance + +**Usage:** + +```bash +cd kicad-mcp-server +./scripts/install-linux.sh +``` + +**Lines of Code:** ~200 lines of robust shell script + +--- + +### 3. **Pre-Commit Hooks Configuration** 🔧 + +**File:** `.pre-commit-config.yaml` + +**Hooks Added:** + +- ✅ **Python:** + - Black (code formatting) + - isort (import sorting) + - MyPy (type checking) + - Flake8 (linting) + - Bandit (security checks) +- ✅ **TypeScript/JavaScript:** + - Prettier (formatting) +- ✅ **General:** + - Trailing whitespace removal + - End-of-file fixer + - YAML/JSON validation + - Large file detection + - Merge conflict detection + - Private key detection +- ✅ **Markdown:** + - Markdownlint (formatting) + +**Setup:** + +```bash +pip install pre-commit +pre-commit install +``` + +**Impact:** Automatic code quality enforcement on every commit! + +--- + +### 4. **IPC API Migration Plan** 📋 + +**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) +- ✅ API comparison tables (SWIG vs IPC) +- ✅ Testing strategy +- ✅ Rollback plan +- ✅ Success criteria +- ✅ 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 +- Multi-language support opens future possibilities + +--- + +### 5. **IPC API Abstraction Layer** 🏗️ + +**New Module:** `python/kicad_api/` + +**Files Created (5):** + +1. **`__init__.py`** (20 lines) + - Package exports + - Version info + - Usage examples + +2. **`base.py`** (180 lines) + - `KiCADBackend` abstract base class + - `BoardAPI` abstract interface + - Custom exceptions (`BackendError`, `ConnectionError`, etc.) + - Defines contract for all backends + +3. **`factory.py`** (160 lines) + - `create_backend()` - Smart backend selection + - Auto-detection (try IPC, fall back to SWIG) + - Environment variable support (`KICAD_BACKEND`) + - `get_available_backends()` - Diagnostic function + - Comprehensive error handling + +4. **`ipc_backend.py`** (210 lines) + - `IPCBackend` class (kicad-python wrapper) + - `IPCBoardAPI` class + - Connection management + - Skeleton methods (to be implemented in Week 2-3) + - Clear TODO markers for migration + +5. **`swig_backend.py`** (220 lines) + - `SWIGBackend` class (wraps existing code) + - `SWIGBoardAPI` class + - Backward compatibility layer + - Deprecation warnings + - Bridges old commands to new interface + +**Total Lines of Code:** ~800 lines + +**Architecture:** + +```python +from kicad_api import create_backend + +# Auto-detect best backend +backend = create_backend() + +# Or specify explicitly +backend = create_backend('ipc') # Use IPC +backend = create_backend('swig') # Use SWIG (deprecated) + +# Use unified interface +if backend.connect(): + board = backend.get_board() + board.set_size(100, 80) +``` + +**Key Features:** + +- ✅ Abstraction allows painless migration +- ✅ Both backends can coexist during transition +- ✅ Easy testing (compare SWIG vs IPC outputs) +- ✅ Future-proof (add new backends easily) +- ✅ Type hints throughout +- ✅ Comprehensive error handling + +--- + +### 6. **Enhanced package.json** 📦 + +**File:** `package.json` + +**Improvements:** + +- ✅ Version bumped to `2.0.0-alpha.1` +- ✅ Better description +- ✅ Enhanced npm scripts: + ```json + "build:watch": "tsc --watch" + "clean": "rm -rf dist" + "rebuild": "npm run clean && npm run build" + "test": "npm run test:ts && npm run test:py" + "test:py": "pytest tests/ -v" + "test:coverage": "pytest with coverage" + "lint": "npm run lint:ts && npm run lint:py" + "lint:py": "black + mypy + flake8" + "format": "prettier + black" + ``` + +**Impact:** Better developer experience, easier workflows + +--- + +## 📊 Statistics + +### Files Created/Modified (Session 2) + +**New Files (10):** + +``` +docs/IPC_API_MIGRATION_PLAN.md # 500+ lines +docs/WEEK1_SESSION2_SUMMARY.md # This file +scripts/install-linux.sh # 200 lines +.pre-commit-config.yaml # 60 lines +python/kicad_api/__init__.py # 20 lines +python/kicad_api/base.py # 180 lines +python/kicad_api/factory.py # 160 lines +python/kicad_api/ipc_backend.py # 210 lines +python/kicad_api/swig_backend.py # 220 lines +``` + +**Modified Files (2):** + +``` +README.md # Major rewrite +package.json # Enhanced scripts +``` + +**Total New Lines:** ~1,600+ lines of code/documentation + +--- + +### Combined Sessions 1+2 Today + +**Files Created:** 27 +**Lines Written:** ~3,000+ +**Documentation Pages:** 8 +**Tests Created:** 20+ + +--- + +## 🎯 Week 1 Status + +### Progress: **95% Complete** ████████████░ + +| Task | Status | +| --------------------- | -------------------------------- | +| Linux compatibility | ✅ Complete | +| CI/CD pipeline | ✅ Complete | +| Cross-platform paths | ✅ Complete | +| Developer docs | ✅ Complete | +| pytest framework | ✅ Complete | +| Config templates | ✅ Complete | +| Installation scripts | ✅ Complete | +| Pre-commit hooks | ✅ Complete | +| IPC migration plan | ✅ Complete | +| IPC abstraction layer | ✅ Complete | +| README updates | ✅ Complete | +| Testing on Ubuntu | ⏳ Pending (needs KiCAD install) | + +**Only Remaining:** Test with actual KiCAD 9.0 installation! + +--- + +## 🚀 Ready for Week 2 + +### 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 +- ✅ SWIG wrapper for backward compatibility +- ✅ IPC skeleton awaiting implementation +- ✅ 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 +4. Create side-by-side tests (SWIG vs IPC) + +--- + +## 💡 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. + +--- + +## 🧪 Testing Readiness + +### When KiCAD is Installed + +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 +``` + +--- + +## 📈 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 + +--- + +## 🎯 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 +4. Create first IPC API tests + +--- + +## 🏆 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 +- ✅ IPC skeleton ready for implementation + +--- + +## 🎉 Overall Day Summary + +**Sessions 1+2 Combined:** + +- ⏱️ **Time:** ~4-5 hours total +- 📝 **Files:** 27 created +- 💻 **Code:** ~3,000+ lines +- 📚 **Docs:** 8 comprehensive pages +- 🧪 **Tests:** 20+ unit tests +- ✅ **Week 1:** 95% complete + +**Status:** 🟢 **AHEAD OF SCHEDULE** + +--- + +## 🚀 Momentum Check + +**Energy Level:** 🔋🔋🔋🔋🔋 (Maximum) +**Code Quality:** ⭐⭐⭐⭐⭐ (Excellent) +**Documentation:** 📖📖📖📖📖 (Comprehensive) +**Architecture:** 🏗️🏗️🏗️🏗️🏗️ (Solid) + +**Ready for Week 2 IPC Migration:** ✅ YES! + +--- + +**End of Session 2** +**Next:** KiCAD installation + testing + Week 2 kickoff + +Let's keep this incredible momentum going! 🎉🚀 diff --git a/docs/mcp-router-guide.md b/docs/mcp-router-guide.md index bb1ae93..54921c3 100644 --- a/docs/mcp-router-guide.md +++ b/docs/mcp-router-guide.md @@ -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" + description: "Polygon vertices in mm", }, - priority: { type: "number", description: "Fill priority (higher fills first)" } + priority: { type: "number", description: "Fill priority (higher fills first)" }, }, - required: ["net_name", "layer", "points"] + 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 }) => { 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 Promise>(); @@ -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, }; @@ -734,12 +760,12 @@ 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 | -| **Database** | connect, query, list_tables, describe_table | +| 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 | +| **Database** | connect, query, list_tables, describe_table | ### Routed Tools (Hidden Behind Router) @@ -753,13 +779,13 @@ 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 | -| `bulk_*` | Batch operations | -| `config/settings` | One-time setup | +| Category | Why Route It | +| ----------------- | ---------------------------- | +| `export` | Only used at end of workflow | +| `drc/validation` | Used during review phase | +| `advanced_*` | Specialty operations | +| `bulk_*` | Batch operations | +| `config/settings` | One-time setup | --- @@ -832,18 +858,18 @@ For an IDA Pro MCP server with 100+ tools: ```typescript const directTools = [ - "open_database", // Load IDB - "save_database", // Save IDB - "get_function", // Get function by address/name - "list_functions", // List all functions - "decompile", // Decompile function (Hex-Rays) - "add_comment", // Add comment at address - "rename", // Rename address/function - "get_xrefs_to", // Get cross-references to address - "get_xrefs_from", // Get cross-references from address - "get_strings", // List strings - "search_bytes", // Search for byte pattern - "get_segments", // List segments + "open_database", // Load IDB + "save_database", // Save IDB + "get_function", // Get function by address/name + "list_functions", // List all functions + "decompile", // Decompile function (Hex-Rays) + "add_comment", // Add comment at address + "rename", // Rename address/function + "get_xrefs_to", // Get cross-references to address + "get_xrefs_from", // Get cross-references from address + "get_strings", // List strings + "search_bytes", // Search for byte pattern + "get_segments", // List segments ]; ``` @@ -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(); }); @@ -1141,11 +1162,11 @@ 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 | -| Context starvation | Room for conversation | +| Before | After | +| ------------------------ | --------------------- | +| 100 tools visible | 15-18 tools visible | +| ~60K+ tokens consumed | ~10K tokens consumed | +| Tool selection confusion | Clear categories | +| Context starvation | Room for conversation | The router pattern gives you unlimited tool capacity while keeping Claude focused and efficient. diff --git a/package-lock.json b/package-lock.json index e806c4c..b3d4947 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@types/glob": "^8.1.0", "@types/node": "^20.19.0", "nodemon": "^3.0.1", + "prettier": "^3.8.1", "typescript": "^5.9.3" } }, @@ -1150,6 +1151,22 @@ "node": ">=16.20.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/package.json b/package.json index 64e5e62..1d4c9a5 100644 --- a/package.json +++ b/package.json @@ -1,49 +1,50 @@ -{ - "name": "kicad-mcp", - "version": "2.1.0-alpha", - "description": "AI-assisted PCB design with KiCAD via Model Context Protocol", - "type": "module", - "main": "dist/index.js", - "scripts": { - "build": "tsc", - "build:watch": "tsc --watch", - "start": "node dist/index.js", - "dev": "npm run build:watch & nodemon dist/index.js", - "clean": "rm -rf dist", - "rebuild": "npm run clean && npm run build", - "test": "npm run test:ts && npm run test:py", - "test:ts": "echo 'TypeScript tests not yet configured'", - "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:py": "cd python && black . && mypy . && flake8 .", - "format": "prettier --write 'src/**/*.ts' && black python/", - "prepare": "npm run build", - "pretest": "npm run build" - }, - "keywords": [ - "kicad", - "mcp", - "model-context-protocol", - "pcb-design", - "ai", - "claude" - ], - "author": "", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/sdk": "^1.21.0", - "dotenv": "^17.0.0", - "express": "^5.1.0", - "zod": "^3.25.0" - }, - "devDependencies": { - "@cfworker/json-schema": "^4.1.1", - "@types/express": "^5.0.5", - "@types/glob": "^8.1.0", - "@types/node": "^20.19.0", - "nodemon": "^3.0.1", - "typescript": "^5.9.3" - } -} +{ + "name": "kicad-mcp", + "version": "2.1.0-alpha", + "description": "AI-assisted PCB design with KiCAD via Model Context Protocol", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "build:watch": "tsc --watch", + "start": "node dist/index.js", + "dev": "npm run build:watch & nodemon dist/index.js", + "clean": "rm -rf dist", + "rebuild": "npm run clean && npm run build", + "test": "npm run test:ts && npm run test:py", + "test:ts": "echo 'TypeScript tests not yet configured'", + "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:py": "cd python && black . && mypy . && flake8 .", + "format": "prettier --write 'src/**/*.ts' && black python/", + "prepare": "npm run build", + "pretest": "npm run build" + }, + "keywords": [ + "kicad", + "mcp", + "model-context-protocol", + "pcb-design", + "ai", + "claude" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.21.0", + "dotenv": "^17.0.0", + "express": "^5.1.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@cfworker/json-schema": "^4.1.1", + "@types/express": "^5.0.5", + "@types/glob": "^8.1.0", + "@types/node": "^20.19.0", + "nodemon": "^3.0.1", + "prettier": "^3.8.1", + "typescript": "^5.9.3" + } +} diff --git a/src/config.ts b/src/config.ts index 38e6fb6..ac4aeb0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,31 +2,31 @@ * Configuration handling for KiCAD MCP server */ -import { readFile } from 'fs/promises'; -import { existsSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { z } from 'zod'; -import { logger } from './logger.js'; +import { readFile } from "fs/promises"; +import { existsSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import { z } from "zod"; +import { logger } from "./logger.js"; // Get the current directory const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Default config location -const DEFAULT_CONFIG_PATH = join(dirname(__dirname), 'config', 'default-config.json'); +const DEFAULT_CONFIG_PATH = join(dirname(__dirname), "config", "default-config.json"); /** * Server configuration schema */ const ConfigSchema = z.object({ - name: z.string().default('kicad-mcp-server'), - version: z.string().default('1.0.0'), - description: z.string().default('MCP server for KiCAD PCB design operations'), + name: z.string().default("kicad-mcp-server"), + version: z.string().default("1.0.0"), + description: z.string().default("MCP server for KiCAD PCB design operations"), pythonPath: z.string().optional(), kicadPath: z.string().optional(), - logLevel: z.enum(['error', 'warn', 'info', 'debug']).default('info'), - logDir: z.string().optional() + logLevel: z.enum(["error", "warn", "info", "debug"]).default("info"), + logDir: z.string().optional(), }); /** @@ -52,7 +52,7 @@ export async function loadConfig(configPath?: string): Promise { } // Read and parse configuration - const configData = await readFile(filePath, 'utf-8'); + const configData = await readFile(filePath, "utf-8"); const config = JSON.parse(configData); // Validate configuration diff --git a/src/index.ts b/src/index.ts index 05bbe7f..1ddec83 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,11 +3,11 @@ * Main entry point */ -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { KiCADMcpServer } from './server.js'; -import { loadConfig } from './config.js'; -import { logger } from './logger.js'; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import { KiCADMcpServer } from "./server.js"; +import { loadConfig } from "./config.js"; +import { logger } from "./logger.js"; // Get the current directory const __filename = fileURLToPath(import.meta.url); @@ -26,13 +26,10 @@ async function main() { const config = await loadConfig(options.configPath); // Path to the Python script that interfaces with KiCAD - const kicadScriptPath = join(dirname(__dirname), 'python', 'kicad_interface.py'); + const kicadScriptPath = join(dirname(__dirname), "python", "kicad_interface.py"); // Create the server - const server = new KiCADMcpServer( - kicadScriptPath, - config.logLevel - ); + const server = new KiCADMcpServer(kicadScriptPath, config.logLevel); // Start the server await server.start(); @@ -40,8 +37,7 @@ async function main() { // Setup graceful shutdown setupGracefulShutdown(server); - logger.info('KiCAD MCP server started with STDIO transport'); - + logger.info("KiCAD MCP server started with STDIO transport"); } catch (error) { logger.error(`Failed to start KiCAD MCP server: ${error}`); process.exit(1); @@ -55,7 +51,7 @@ function parseCommandLineArgs(args: string[]) { let configPath = undefined; for (let i = 0; i < args.length; i++) { - if (args[i] === '--config' && i + 1 < args.length) { + if (args[i] === "--config" && i + 1 < args.length) { configPath = args[i + 1]; i++; } @@ -69,24 +65,24 @@ function parseCommandLineArgs(args: string[]) { */ function setupGracefulShutdown(server: KiCADMcpServer) { // Handle termination signals - process.on('SIGINT', async () => { - logger.info('Received SIGINT signal. Shutting down...'); + process.on("SIGINT", async () => { + logger.info("Received SIGINT signal. Shutting down..."); await shutdownServer(server); }); - process.on('SIGTERM', async () => { - logger.info('Received SIGTERM signal. Shutting down...'); + process.on("SIGTERM", async () => { + logger.info("Received SIGTERM signal. Shutting down..."); await shutdownServer(server); }); // Handle uncaught exceptions - process.on('uncaughtException', async (error) => { + process.on("uncaughtException", async (error) => { logger.error(`Uncaught exception: ${error}`); await shutdownServer(server); }); // Handle unhandled promise rejections - process.on('unhandledRejection', async (reason) => { + process.on("unhandledRejection", async (reason) => { logger.error(`Unhandled promise rejection: ${reason}`); await shutdownServer(server); }); @@ -97,9 +93,9 @@ function setupGracefulShutdown(server: KiCADMcpServer) { */ async function shutdownServer(server: KiCADMcpServer) { try { - logger.info('Shutting down KiCAD MCP server...'); + logger.info("Shutting down KiCAD MCP server..."); await server.stop(); - logger.info('Server shutdown complete. Exiting...'); + logger.info("Server shutdown complete. Exiting..."); process.exit(0); } catch (error) { logger.error(`Error during shutdown: ${error}`); diff --git a/src/kicad-server.ts b/src/kicad-server.ts index dd633c4..b8f365b 100644 --- a/src/kicad-server.ts +++ b/src/kicad-server.ts @@ -1,500 +1,504 @@ -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; -import { spawn, ChildProcess } from 'child_process'; -import { existsSync } from 'fs'; -import path from 'path'; - -// Import all tool definitions for reference -// import { registerBoardTools } from './tools/board.js'; -// import { registerComponentTools } from './tools/component.js'; -// import { registerRoutingTools } from './tools/routing.js'; -// import { registerDesignRuleTools } from './tools/design-rules.js'; -// import { registerExportTools } from './tools/export.js'; -// import { registerProjectTools } from './tools/project.js'; -// import { registerSchematicTools } from './tools/schematic.js'; - -class KiCADServer { - private server: Server; - private pythonProcess: ChildProcess | null = null; - private kicadScriptPath: string; - private requestQueue: Array<{ request: any, resolve: Function, reject: Function }> = []; - private processingRequest = false; - - constructor() { - // Set absolute path to the Python KiCAD interface script - // Using a hardcoded path to avoid cwd() issues when running from Cline - this.kicadScriptPath = 'c:/repo/KiCAD-MCP/python/kicad_interface.py'; - - // Check if script exists - if (!existsSync(this.kicadScriptPath)) { - throw new Error(`KiCAD interface script not found: ${this.kicadScriptPath}`); - } - - // Initialize the server - this.server = new Server( - { - name: 'kicad-mcp-server', - version: '1.0.0' - }, - { - capabilities: { - tools: { - // Empty object here, tools will be registered dynamically - } - } - } - ); - - // Initialize handler with direct pass-through to Python KiCAD interface - // We don't register TypeScript tools since we'll handle everything in Python - - // Register tool list handler - this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - // Project tools - { - name: 'create_project', - description: 'Create a new KiCAD project', - inputSchema: { - type: 'object', - properties: { - projectName: { type: 'string', description: 'Name of the new project' }, - path: { type: 'string', description: 'Path where to create the project' }, - template: { type: 'string', description: 'Optional template to use' } - }, - required: ['projectName'] - } - }, - { - name: 'open_project', - description: 'Open an existing KiCAD project', - inputSchema: { - type: 'object', - properties: { - filename: { type: 'string', description: 'Path to the project file' } - }, - required: ['filename'] - } - }, - { - name: 'save_project', - description: 'Save the current KiCAD project', - inputSchema: { - type: 'object', - properties: { - filename: { type: 'string', description: 'Optional path to save to' } - } - } - }, - { - name: 'get_project_info', - description: 'Get information about the current project', - inputSchema: { - type: 'object', - properties: {} - } - }, - - // Board tools - { - name: 'set_board_size', - description: 'Set the size of the PCB board', - inputSchema: { - type: 'object', - properties: { - width: { type: 'number', description: 'Board width' }, - height: { type: 'number', description: 'Board height' }, - unit: { type: 'string', description: 'Unit of measurement (mm or inch)' } - }, - required: ['width', 'height'] - } - }, - { - name: 'add_board_outline', - description: 'Add a board outline to the PCB', - inputSchema: { - type: 'object', - properties: { - shape: { type: 'string', description: 'Shape of outline (rectangle, circle, polygon, rounded_rectangle)' }, - width: { type: 'number', description: 'Width for rectangle shapes' }, - height: { type: 'number', description: 'Height for rectangle shapes' }, - radius: { type: 'number', description: 'Radius for circle shapes' }, - cornerRadius: { type: 'number', description: 'Corner radius for rounded rectangles' }, - points: { type: 'array', description: 'Array of points for polygon shapes' }, - centerX: { type: 'number', description: 'X coordinate of center' }, - centerY: { type: 'number', description: 'Y coordinate of center' }, - unit: { type: 'string', description: 'Unit of measurement (mm or inch)' } - } - } - }, - - // Component tools - { - name: 'place_component', - description: 'Place a component on the PCB', - inputSchema: { - type: 'object', - properties: { - componentId: { type: 'string', description: 'Component ID/footprint to place' }, - position: { type: 'object', description: 'Position coordinates' }, - reference: { type: 'string', description: 'Component reference designator' }, - value: { type: 'string', description: 'Component value' }, - rotation: { type: 'number', description: 'Rotation angle in degrees' }, - layer: { type: 'string', description: 'Layer to place component on' } - }, - required: ['componentId', 'position'] - } - }, - - // Routing tools - { - name: 'add_net', - description: 'Add a new net to the PCB', - inputSchema: { - type: 'object', - properties: { - name: { type: 'string', description: 'Net name' }, - class: { type: 'string', description: 'Net class' } - }, - required: ['name'] - } - }, - { - name: 'route_trace', - description: 'Route a trace between two points or pads', - inputSchema: { - type: 'object', - properties: { - start: { type: 'object', description: 'Start point or pad' }, - end: { type: 'object', description: 'End point or pad' }, - layer: { type: 'string', description: 'Layer to route on' }, - width: { type: 'number', description: 'Track width' }, - net: { type: 'string', description: 'Net name' } - }, - required: ['start', 'end'] - } - }, - - // Schematic tools - { - name: 'create_schematic', - description: 'Create a new KiCAD schematic', - inputSchema: { - type: 'object', - properties: { - projectName: { type: 'string', description: 'Name of the schematic project' }, - path: { type: 'string', description: 'Path where to create the schematic file' }, - metadata: { type: 'object', description: 'Optional metadata for the schematic' } - }, - required: ['projectName'] - } - }, - { - name: 'load_schematic', - description: 'Load an existing KiCAD schematic', - inputSchema: { - type: 'object', - properties: { - filename: { type: 'string', description: 'Path to the schematic file to load' } - }, - required: ['filename'] - } - }, - { - name: 'add_schematic_component', - description: 'Add a component to a KiCAD schematic', - inputSchema: { - type: 'object', - properties: { - schematicPath: { type: 'string', description: 'Path to the schematic file' }, - component: { - type: 'object', - description: 'Component definition', - properties: { - type: { type: 'string', description: 'Component type (e.g., R, C, LED)' }, - reference: { type: 'string', description: 'Reference designator (e.g., R1, C2)' }, - value: { type: 'string', description: 'Component value (e.g., 10k, 0.1uF)' }, - library: { type: 'string', description: 'Symbol library name' }, - x: { type: 'number', description: 'X position in schematic' }, - y: { type: 'number', description: 'Y position in schematic' }, - rotation: { type: 'number', description: 'Rotation angle in degrees' }, - properties: { type: 'object', description: 'Additional properties' } - }, - required: ['type', 'reference'] - } - }, - required: ['schematicPath', 'component'] - } - }, - { - name: 'add_schematic_wire', - description: 'Add a wire connection to a KiCAD schematic', - inputSchema: { - type: 'object', - properties: { - schematicPath: { type: 'string', description: 'Path to the schematic file' }, - startPoint: { - type: 'array', - description: 'Starting point coordinates [x, y]', - items: { type: 'number' }, - minItems: 2, - maxItems: 2 - }, - endPoint: { - type: 'array', - description: 'Ending point coordinates [x, y]', - items: { type: 'number' }, - minItems: 2, - maxItems: 2 - } - }, - required: ['schematicPath', 'startPoint', 'endPoint'] - } - }, - { - name: 'list_schematic_libraries', - description: 'List available KiCAD symbol libraries', - inputSchema: { - type: 'object', - properties: { - searchPaths: { - type: 'array', - description: 'Optional search paths for libraries', - items: { type: 'string' } - } - } - } - }, - { - name: 'export_schematic_pdf', - description: 'Export a KiCAD schematic to PDF', - inputSchema: { - type: 'object', - properties: { - schematicPath: { type: 'string', description: 'Path to the schematic file' }, - outputPath: { type: 'string', description: 'Path for the output PDF file' } - }, - required: ['schematicPath', 'outputPath'] - } - } - ] - })); - - // Register tool call handler - this.server.setRequestHandler(CallToolRequestSchema, async (request: any) => { - const toolName = request.params.name; - const args = request.params.arguments || {}; - - // Pass all commands directly to KiCAD Python interface - try { - return await this.callKicadScript(toolName, args); - } catch (error) { - console.error(`Error executing tool ${toolName}:`, error); - throw new Error(`Unknown tool: ${toolName}`); - } - }); - } - - async start() { - try { - console.error('Starting KiCAD MCP server...'); - - // Start the Python process for KiCAD scripting - console.error(`Starting Python process with script: ${this.kicadScriptPath}`); - const pythonExe = 'C:\\Program Files\\KiCad\\9.0\\bin\\python.exe'; - - console.error(`Using Python executable: ${pythonExe}`); - this.pythonProcess = spawn(pythonExe, [this.kicadScriptPath], { - stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - PYTHONPATH: 'C:/Program Files/KiCad/9.0/lib/python3/dist-packages' - } - }); - - // Listen for process exit - this.pythonProcess.on('exit', (code, signal) => { - console.error(`Python process exited with code ${code} and signal ${signal}`); - this.pythonProcess = null; - }); - - // Listen for process errors - this.pythonProcess.on('error', (err) => { - console.error(`Python process error: ${err.message}`); - }); - - // Set up error logging for stderr - if (this.pythonProcess.stderr) { - this.pythonProcess.stderr.on('data', (data: Buffer) => { - console.error(`Python stderr: ${data.toString()}`); - }); - } - - // Connect to transport - const transport = new StdioServerTransport(); - await this.server.connect(transport); - console.error('KiCAD MCP server running'); - - // Keep the process running - process.on('SIGINT', () => { - if (this.pythonProcess) { - this.pythonProcess.kill(); - } - this.server.close().catch(console.error); - process.exit(0); - }); - - } catch (error: unknown) { - if (error instanceof Error) { - console.error('Failed to start MCP server:', error.message); - } else { - console.error('Failed to start MCP server: Unknown error'); - } - process.exit(1); - } - } - - private async callKicadScript(command: string, params: any): Promise { - return new Promise((resolve, reject) => { - // Check if Python process is running - if (!this.pythonProcess) { - console.error('Python process is not running'); - reject(new Error("Python process for KiCAD scripting is not running")); - return; - } - - // Add request to queue - this.requestQueue.push({ - request: { command, params }, - resolve, - reject - }); - - // Process the queue if not already processing - if (!this.processingRequest) { - this.processNextRequest(); - } - }); - } - - private processNextRequest(): void { - // If no more requests or already processing, return - if (this.requestQueue.length === 0 || this.processingRequest) { - return; - } - - // Set processing flag - this.processingRequest = true; - - // Get the next request - const { request, resolve, reject } = this.requestQueue.shift()!; - - try { - console.error(`Processing KiCAD command: ${request.command}`); - - // Format the command and parameters as JSON - const requestStr = JSON.stringify(request); - - // Set up response handling - let responseData = ''; - - // Clear any previous listeners - if (this.pythonProcess?.stdout) { - this.pythonProcess.stdout.removeAllListeners('data'); - } - - // Set up new listeners - if (this.pythonProcess?.stdout) { - this.pythonProcess.stdout.on('data', (data: Buffer) => { - const chunk = data.toString(); - console.error(`Received data chunk: ${chunk.length} bytes`); - responseData += chunk; - - // Check if we have a complete response - try { - // Try to parse the response as JSON - const result = JSON.parse(responseData); - - // If we get here, we have a valid JSON response - console.error(`Completed KiCAD command: ${request.command} with result: ${JSON.stringify(result)}`); - - // Reset processing flag - this.processingRequest = false; - - // Process next request if any - setTimeout(() => this.processNextRequest(), 0); - - // Clear listeners - if (this.pythonProcess?.stdout) { - this.pythonProcess.stdout.removeAllListeners('data'); - } - - // Resolve with the expected MCP tool response format - if (result.success) { - resolve({ - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2) - } - ] - }); - } else { - resolve({ - content: [ - { - type: 'text', - text: result.errorDetails || result.message || 'Unknown error' - } - ], - isError: true - }); - } - } catch (e) { - // Not a complete JSON yet, keep collecting data - } - }); - } - - // Set a timeout - const timeout = setTimeout(() => { - console.error(`Command timeout: ${request.command}`); - - // Clear listeners - if (this.pythonProcess?.stdout) { - this.pythonProcess.stdout.removeAllListeners('data'); - } - - // Reset processing flag - this.processingRequest = false; - - // Process next request - setTimeout(() => this.processNextRequest(), 0); - - // Reject the promise - reject(new Error(`Command timeout: ${request.command}`)); - }, 30000); // 30 seconds timeout - - // Write the request to the Python process - console.error(`Sending request: ${requestStr}`); - this.pythonProcess?.stdin?.write(requestStr + '\n'); - } catch (error) { - console.error(`Error processing request: ${error}`); - - // Reset processing flag - this.processingRequest = false; - - // Process next request - setTimeout(() => this.processNextRequest(), 0); - - // Reject the promise - reject(error); - } - } -} - -// Start the server -const server = new KiCADServer(); -server.start().catch(console.error); +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { spawn, ChildProcess } from "child_process"; +import { existsSync } from "fs"; +import path from "path"; + +// Import all tool definitions for reference +// import { registerBoardTools } from './tools/board.js'; +// import { registerComponentTools } from './tools/component.js'; +// import { registerRoutingTools } from './tools/routing.js'; +// import { registerDesignRuleTools } from './tools/design-rules.js'; +// import { registerExportTools } from './tools/export.js'; +// import { registerProjectTools } from './tools/project.js'; +// import { registerSchematicTools } from './tools/schematic.js'; + +class KiCADServer { + private server: Server; + private pythonProcess: ChildProcess | null = null; + private kicadScriptPath: string; + private requestQueue: Array<{ request: any; resolve: Function; reject: Function }> = []; + private processingRequest = false; + + constructor() { + // Set absolute path to the Python KiCAD interface script + // Using a hardcoded path to avoid cwd() issues when running from Cline + this.kicadScriptPath = "c:/repo/KiCAD-MCP/python/kicad_interface.py"; + + // Check if script exists + if (!existsSync(this.kicadScriptPath)) { + throw new Error(`KiCAD interface script not found: ${this.kicadScriptPath}`); + } + + // Initialize the server + this.server = new Server( + { + name: "kicad-mcp-server", + version: "1.0.0", + }, + { + capabilities: { + tools: { + // Empty object here, tools will be registered dynamically + }, + }, + }, + ); + + // Initialize handler with direct pass-through to Python KiCAD interface + // We don't register TypeScript tools since we'll handle everything in Python + + // Register tool list handler + this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + // Project tools + { + name: "create_project", + description: "Create a new KiCAD project", + inputSchema: { + type: "object", + properties: { + projectName: { type: "string", description: "Name of the new project" }, + path: { type: "string", description: "Path where to create the project" }, + template: { type: "string", description: "Optional template to use" }, + }, + required: ["projectName"], + }, + }, + { + name: "open_project", + description: "Open an existing KiCAD project", + inputSchema: { + type: "object", + properties: { + filename: { type: "string", description: "Path to the project file" }, + }, + required: ["filename"], + }, + }, + { + name: "save_project", + description: "Save the current KiCAD project", + inputSchema: { + type: "object", + properties: { + filename: { type: "string", description: "Optional path to save to" }, + }, + }, + }, + { + name: "get_project_info", + description: "Get information about the current project", + inputSchema: { + type: "object", + properties: {}, + }, + }, + + // Board tools + { + name: "set_board_size", + description: "Set the size of the PCB board", + inputSchema: { + type: "object", + properties: { + width: { type: "number", description: "Board width" }, + height: { type: "number", description: "Board height" }, + unit: { type: "string", description: "Unit of measurement (mm or inch)" }, + }, + required: ["width", "height"], + }, + }, + { + name: "add_board_outline", + description: "Add a board outline to the PCB", + inputSchema: { + type: "object", + properties: { + shape: { + type: "string", + description: "Shape of outline (rectangle, circle, polygon, rounded_rectangle)", + }, + width: { type: "number", description: "Width for rectangle shapes" }, + height: { type: "number", description: "Height for rectangle shapes" }, + radius: { type: "number", description: "Radius for circle shapes" }, + cornerRadius: { type: "number", description: "Corner radius for rounded rectangles" }, + points: { type: "array", description: "Array of points for polygon shapes" }, + centerX: { type: "number", description: "X coordinate of center" }, + centerY: { type: "number", description: "Y coordinate of center" }, + unit: { type: "string", description: "Unit of measurement (mm or inch)" }, + }, + }, + }, + + // Component tools + { + name: "place_component", + description: "Place a component on the PCB", + inputSchema: { + type: "object", + properties: { + componentId: { type: "string", description: "Component ID/footprint to place" }, + position: { type: "object", description: "Position coordinates" }, + reference: { type: "string", description: "Component reference designator" }, + value: { type: "string", description: "Component value" }, + rotation: { type: "number", description: "Rotation angle in degrees" }, + layer: { type: "string", description: "Layer to place component on" }, + }, + required: ["componentId", "position"], + }, + }, + + // Routing tools + { + name: "add_net", + description: "Add a new net to the PCB", + inputSchema: { + type: "object", + properties: { + name: { type: "string", description: "Net name" }, + class: { type: "string", description: "Net class" }, + }, + required: ["name"], + }, + }, + { + name: "route_trace", + description: "Route a trace between two points or pads", + inputSchema: { + type: "object", + properties: { + start: { type: "object", description: "Start point or pad" }, + end: { type: "object", description: "End point or pad" }, + layer: { type: "string", description: "Layer to route on" }, + width: { type: "number", description: "Track width" }, + net: { type: "string", description: "Net name" }, + }, + required: ["start", "end"], + }, + }, + + // Schematic tools + { + name: "create_schematic", + description: "Create a new KiCAD schematic", + inputSchema: { + type: "object", + properties: { + projectName: { type: "string", description: "Name of the schematic project" }, + path: { type: "string", description: "Path where to create the schematic file" }, + metadata: { type: "object", description: "Optional metadata for the schematic" }, + }, + required: ["projectName"], + }, + }, + { + name: "load_schematic", + description: "Load an existing KiCAD schematic", + inputSchema: { + type: "object", + properties: { + filename: { type: "string", description: "Path to the schematic file to load" }, + }, + required: ["filename"], + }, + }, + { + name: "add_schematic_component", + description: "Add a component to a KiCAD schematic", + inputSchema: { + type: "object", + properties: { + schematicPath: { type: "string", description: "Path to the schematic file" }, + component: { + type: "object", + description: "Component definition", + properties: { + type: { type: "string", description: "Component type (e.g., R, C, LED)" }, + reference: { type: "string", description: "Reference designator (e.g., R1, C2)" }, + value: { type: "string", description: "Component value (e.g., 10k, 0.1uF)" }, + library: { type: "string", description: "Symbol library name" }, + x: { type: "number", description: "X position in schematic" }, + y: { type: "number", description: "Y position in schematic" }, + rotation: { type: "number", description: "Rotation angle in degrees" }, + properties: { type: "object", description: "Additional properties" }, + }, + required: ["type", "reference"], + }, + }, + required: ["schematicPath", "component"], + }, + }, + { + name: "add_schematic_wire", + description: "Add a wire connection to a KiCAD schematic", + inputSchema: { + type: "object", + properties: { + schematicPath: { type: "string", description: "Path to the schematic file" }, + startPoint: { + type: "array", + description: "Starting point coordinates [x, y]", + items: { type: "number" }, + minItems: 2, + maxItems: 2, + }, + endPoint: { + type: "array", + description: "Ending point coordinates [x, y]", + items: { type: "number" }, + minItems: 2, + maxItems: 2, + }, + }, + required: ["schematicPath", "startPoint", "endPoint"], + }, + }, + { + name: "list_schematic_libraries", + description: "List available KiCAD symbol libraries", + inputSchema: { + type: "object", + properties: { + searchPaths: { + type: "array", + description: "Optional search paths for libraries", + items: { type: "string" }, + }, + }, + }, + }, + { + name: "export_schematic_pdf", + description: "Export a KiCAD schematic to PDF", + inputSchema: { + type: "object", + properties: { + schematicPath: { type: "string", description: "Path to the schematic file" }, + outputPath: { type: "string", description: "Path for the output PDF file" }, + }, + required: ["schematicPath", "outputPath"], + }, + }, + ], + })); + + // Register tool call handler + this.server.setRequestHandler(CallToolRequestSchema, async (request: any) => { + const toolName = request.params.name; + const args = request.params.arguments || {}; + + // Pass all commands directly to KiCAD Python interface + try { + return await this.callKicadScript(toolName, args); + } catch (error) { + console.error(`Error executing tool ${toolName}:`, error); + throw new Error(`Unknown tool: ${toolName}`); + } + }); + } + + async start() { + try { + console.error("Starting KiCAD MCP server..."); + + // Start the Python process for KiCAD scripting + console.error(`Starting Python process with script: ${this.kicadScriptPath}`); + const pythonExe = "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe"; + + console.error(`Using Python executable: ${pythonExe}`); + this.pythonProcess = spawn(pythonExe, [this.kicadScriptPath], { + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + PYTHONPATH: "C:/Program Files/KiCad/9.0/lib/python3/dist-packages", + }, + }); + + // Listen for process exit + this.pythonProcess.on("exit", (code, signal) => { + console.error(`Python process exited with code ${code} and signal ${signal}`); + this.pythonProcess = null; + }); + + // Listen for process errors + this.pythonProcess.on("error", (err) => { + console.error(`Python process error: ${err.message}`); + }); + + // Set up error logging for stderr + if (this.pythonProcess.stderr) { + this.pythonProcess.stderr.on("data", (data: Buffer) => { + console.error(`Python stderr: ${data.toString()}`); + }); + } + + // Connect to transport + const transport = new StdioServerTransport(); + await this.server.connect(transport); + console.error("KiCAD MCP server running"); + + // Keep the process running + process.on("SIGINT", () => { + if (this.pythonProcess) { + this.pythonProcess.kill(); + } + this.server.close().catch(console.error); + process.exit(0); + }); + } catch (error: unknown) { + if (error instanceof Error) { + console.error("Failed to start MCP server:", error.message); + } else { + console.error("Failed to start MCP server: Unknown error"); + } + process.exit(1); + } + } + + private async callKicadScript(command: string, params: any): Promise { + return new Promise((resolve, reject) => { + // Check if Python process is running + if (!this.pythonProcess) { + console.error("Python process is not running"); + reject(new Error("Python process for KiCAD scripting is not running")); + return; + } + + // Add request to queue + this.requestQueue.push({ + request: { command, params }, + resolve, + reject, + }); + + // Process the queue if not already processing + if (!this.processingRequest) { + this.processNextRequest(); + } + }); + } + + private processNextRequest(): void { + // If no more requests or already processing, return + if (this.requestQueue.length === 0 || this.processingRequest) { + return; + } + + // Set processing flag + this.processingRequest = true; + + // Get the next request + const { request, resolve, reject } = this.requestQueue.shift()!; + + try { + console.error(`Processing KiCAD command: ${request.command}`); + + // Format the command and parameters as JSON + const requestStr = JSON.stringify(request); + + // Set up response handling + let responseData = ""; + + // Clear any previous listeners + if (this.pythonProcess?.stdout) { + this.pythonProcess.stdout.removeAllListeners("data"); + } + + // Set up new listeners + if (this.pythonProcess?.stdout) { + this.pythonProcess.stdout.on("data", (data: Buffer) => { + const chunk = data.toString(); + console.error(`Received data chunk: ${chunk.length} bytes`); + responseData += chunk; + + // Check if we have a complete response + try { + // Try to parse the response as JSON + const result = JSON.parse(responseData); + + // If we get here, we have a valid JSON response + console.error( + `Completed KiCAD command: ${request.command} with result: ${JSON.stringify(result)}`, + ); + + // Reset processing flag + this.processingRequest = false; + + // Process next request if any + setTimeout(() => this.processNextRequest(), 0); + + // Clear listeners + if (this.pythonProcess?.stdout) { + this.pythonProcess.stdout.removeAllListeners("data"); + } + + // Resolve with the expected MCP tool response format + if (result.success) { + resolve({ + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }); + } else { + resolve({ + content: [ + { + type: "text", + text: result.errorDetails || result.message || "Unknown error", + }, + ], + isError: true, + }); + } + } catch (e) { + // Not a complete JSON yet, keep collecting data + } + }); + } + + // Set a timeout + const timeout = setTimeout(() => { + console.error(`Command timeout: ${request.command}`); + + // Clear listeners + if (this.pythonProcess?.stdout) { + this.pythonProcess.stdout.removeAllListeners("data"); + } + + // Reset processing flag + this.processingRequest = false; + + // Process next request + setTimeout(() => this.processNextRequest(), 0); + + // Reject the promise + reject(new Error(`Command timeout: ${request.command}`)); + }, 30000); // 30 seconds timeout + + // Write the request to the Python process + console.error(`Sending request: ${requestStr}`); + this.pythonProcess?.stdin?.write(requestStr + "\n"); + } catch (error) { + console.error(`Error processing request: ${error}`); + + // Reset processing flag + this.processingRequest = false; + + // Process next request + setTimeout(() => this.processNextRequest(), 0); + + // Reject the promise + reject(error); + } + } +} + +// Start the server +const server = new KiCADServer(); +server.start().catch(console.error); diff --git a/src/logger.ts b/src/logger.ts index cb1cef7..7246152 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -2,21 +2,21 @@ * Logger for KiCAD MCP server */ -import { existsSync, mkdirSync, appendFileSync } from 'fs'; -import { join } from 'path'; -import * as os from 'os'; +import { existsSync, mkdirSync, appendFileSync } from "fs"; +import { join } from "path"; +import * as os from "os"; // Log levels -type LogLevel = 'error' | 'warn' | 'info' | 'debug'; +type LogLevel = "error" | "warn" | "info" | "debug"; // Default log directory -const DEFAULT_LOG_DIR = join(os.homedir(), '.kicad-mcp', 'logs'); +const DEFAULT_LOG_DIR = join(os.homedir(), ".kicad-mcp", "logs"); /** * Logger class for KiCAD MCP server */ class Logger { - private logLevel: LogLevel = 'info'; + private logLevel: LogLevel = "info"; private logDir: string = DEFAULT_LOG_DIR; /** @@ -45,7 +45,7 @@ class Logger { * @param message Message to log */ error(message: string): void { - this.log('error', message); + this.log("error", message); } /** @@ -53,8 +53,8 @@ class Logger { * @param message Message to log */ warn(message: string): void { - if (['error', 'warn', 'info', 'debug'].includes(this.logLevel)) { - this.log('warn', message); + if (["error", "warn", "info", "debug"].includes(this.logLevel)) { + this.log("warn", message); } } @@ -63,8 +63,8 @@ class Logger { * @param message Message to log */ info(message: string): void { - if (['info', 'debug'].includes(this.logLevel)) { - this.log('info', message); + if (["info", "debug"].includes(this.logLevel)) { + this.log("info", message); } } @@ -73,8 +73,8 @@ class Logger { * @param message Message to log */ debug(message: string): void { - if (this.logLevel === 'debug') { - this.log('debug', message); + if (this.logLevel === "debug") { + this.log("debug", message); } } @@ -85,8 +85,9 @@ class Logger { */ private log(level: LogLevel, message: string): void { const now = new Date(); - const pad = (n: number, w = 2) => String(n).padStart(w, '0'); - const timestamp = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())} ` + + const pad = (n: number, w = 2) => String(n).padStart(w, "0"); + const timestamp = + `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` + `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())},${pad(now.getMilliseconds(), 3)}`; const formattedMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`; @@ -101,8 +102,8 @@ class Logger { mkdirSync(this.logDir, { recursive: true }); } - const logFile = join(this.logDir, `kicad-mcp-${new Date().toISOString().split('T')[0]}.log`); - appendFileSync(logFile, formattedMessage + '\n'); + const logFile = join(this.logDir, `kicad-mcp-${new Date().toISOString().split("T")[0]}.log`); + appendFileSync(logFile, formattedMessage + "\n"); } catch (error) { console.error(`Failed to write to log file: ${error}`); } diff --git a/src/prompts/component.ts b/src/prompts/component.ts index 4379609..ba25631 100644 --- a/src/prompts/component.ts +++ b/src/prompts/component.ts @@ -1,231 +1,237 @@ -/** - * Component prompts for KiCAD MCP server - * - * These prompts guide the LLM in providing assistance with component-related tasks - * in KiCAD PCB design. - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import { logger } from '../logger.js'; - -/** - * Register component prompts with the MCP server - * - * @param server MCP server instance - */ -export function registerComponentPrompts(server: McpServer): void { - logger.info('Registering component prompts'); - - // ------------------------------------------------------ - // Component Selection Prompt - // ------------------------------------------------------ - server.prompt( - "component_selection", - { - requirements: z.string().describe("Description of the circuit requirements and constraints") - }, - () => ({ - messages: [ - { - role: "user", - content: { - type: "text", - text: `You're helping to select components for a circuit design. Given the following requirements: - -{{requirements}} - -Suggest appropriate components with their values, ratings, and footprints. Consider factors like: -- Power and voltage ratings -- Current handling capabilities -- Tolerance requirements -- Physical size constraints and package types -- Availability and cost considerations -- Thermal characteristics -- Performance specifications - -For each component type, recommend specific values and provide a brief explanation of your recommendation. If appropriate, suggest alternatives with different trade-offs.` - } - } - ] - }) - ); - - // ------------------------------------------------------ - // Component Placement Strategy Prompt - // ------------------------------------------------------ - server.prompt( - "component_placement_strategy", - { - components: z.string().describe("List of components to be placed on the PCB") - }, - () => ({ - messages: [ - { - role: "user", - content: { - type: "text", - text: `You're helping with component placement for a PCB layout. Here are the components to place: - -{{components}} - -Provide a strategy for optimal placement considering: - -1. Signal Integrity: - - Group related components to minimize signal path length - - Keep sensitive signals away from noisy components - - Consider appropriate placement for bypass/decoupling capacitors - -2. Thermal Management: - - Distribute heat-generating components - - Ensure adequate spacing for cooling - - Placement near heat sinks or vias for thermal dissipation - -3. EMI/EMC Concerns: - - Separate digital and analog sections - - Consider ground plane partitioning - - Shield sensitive components - -4. Manufacturing and Assembly: - - Component orientation for automated assembly - - Adequate spacing for rework - - Consider component height distribution - -Group components functionally and suggest a logical arrangement. If possible, provide a rough sketch or description of component zones.` - } - } - ] - }) - ); - - // ------------------------------------------------------ - // Component Replacement Analysis Prompt - // ------------------------------------------------------ - server.prompt( - "component_replacement_analysis", - { - component_info: z.string().describe("Information about the component that needs to be replaced") - }, - () => ({ - messages: [ - { - role: "user", - content: { - type: "text", - text: `You're helping to find a replacement for a component that is unavailable or needs to be updated. Here's the original component information: - -{{component_info}} - -Consider these factors when suggesting replacements: - -1. Electrical Compatibility: - - Match or exceed key electrical specifications - - Ensure voltage/current/power ratings are compatible - - Consider parametric equivalents - -2. Physical Compatibility: - - Footprint compatibility or adaptation requirements - - Package differences and mounting considerations - - Size and clearance requirements - -3. Performance Impact: - - How the replacement might affect circuit performance - - Potential need for circuit adjustments - -4. Availability and Cost: - - Current market availability - - Cost comparison with original part - - Lead time considerations - -Suggest suitable replacement options and explain the advantages and disadvantages of each. Include any circuit modifications that might be necessary.` - } - } - ] - }) - ); - - // ------------------------------------------------------ - // Component Troubleshooting Prompt - // ------------------------------------------------------ - server.prompt( - "component_troubleshooting", - { - issue_description: z.string().describe("Description of the component or circuit issue being troubleshooted") - }, - () => ({ - messages: [ - { - role: "user", - content: { - type: "text", - text: `You're helping to troubleshoot an issue with a component or circuit section in a PCB design. Here's the issue description: - -{{issue_description}} - -Use the following systematic approach to diagnose the problem: - -1. Component Verification: - - Check component values, footprints, and orientation - - Verify correct part numbers and specifications - - Examine for potential manufacturing defects - -2. Circuit Analysis: - - Review the schematic for design errors - - Check for proper connections and signal paths - - Verify power and ground connections - -3. Layout Review: - - Examine component placement and orientation - - Check for adequate clearances - - Review trace routing and potential interference - -4. Environmental Factors: - - Consider temperature, humidity, and other environmental impacts - - Check for potential EMI/RFI issues - - Review mechanical stress or vibration effects - -Based on the available information, suggest likely causes of the issue and recommend specific steps to diagnose and resolve the problem.` - } - } - ] - }) - ); - - // ------------------------------------------------------ - // Component Value Calculation Prompt - // ------------------------------------------------------ - server.prompt( - "component_value_calculation", - { - circuit_requirements: z.string().describe("Description of the circuit function and performance requirements") - }, - () => ({ - messages: [ - { - role: "user", - content: { - type: "text", - text: `You're helping to calculate appropriate component values for a specific circuit function. Here's the circuit description and requirements: - -{{circuit_requirements}} - -Follow these steps to determine the optimal component values: - -1. Identify the relevant circuit equations and design formulas -2. Consider the design constraints and performance requirements -3. Calculate initial component values based on ideal behavior -4. Adjust for real-world factors: - - Component tolerances - - Temperature coefficients - - Parasitic effects - - Available standard values - -Present your calculations step-by-step, showing your work and explaining your reasoning. Recommend specific component values, explaining why they're appropriate for this application. If there are multiple valid approaches, discuss the trade-offs between them.` - } - } - ] - }) - ); - - logger.info('Component prompts registered'); -} +/** + * Component prompts for KiCAD MCP server + * + * These prompts guide the LLM in providing assistance with component-related tasks + * in KiCAD PCB design. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { logger } from "../logger.js"; + +/** + * Register component prompts with the MCP server + * + * @param server MCP server instance + */ +export function registerComponentPrompts(server: McpServer): void { + logger.info("Registering component prompts"); + + // ------------------------------------------------------ + // Component Selection Prompt + // ------------------------------------------------------ + server.prompt( + "component_selection", + { + requirements: z.string().describe("Description of the circuit requirements and constraints"), + }, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `You're helping to select components for a circuit design. Given the following requirements: + +{{requirements}} + +Suggest appropriate components with their values, ratings, and footprints. Consider factors like: +- Power and voltage ratings +- Current handling capabilities +- Tolerance requirements +- Physical size constraints and package types +- Availability and cost considerations +- Thermal characteristics +- Performance specifications + +For each component type, recommend specific values and provide a brief explanation of your recommendation. If appropriate, suggest alternatives with different trade-offs.`, + }, + }, + ], + }), + ); + + // ------------------------------------------------------ + // Component Placement Strategy Prompt + // ------------------------------------------------------ + server.prompt( + "component_placement_strategy", + { + components: z.string().describe("List of components to be placed on the PCB"), + }, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `You're helping with component placement for a PCB layout. Here are the components to place: + +{{components}} + +Provide a strategy for optimal placement considering: + +1. Signal Integrity: + - Group related components to minimize signal path length + - Keep sensitive signals away from noisy components + - Consider appropriate placement for bypass/decoupling capacitors + +2. Thermal Management: + - Distribute heat-generating components + - Ensure adequate spacing for cooling + - Placement near heat sinks or vias for thermal dissipation + +3. EMI/EMC Concerns: + - Separate digital and analog sections + - Consider ground plane partitioning + - Shield sensitive components + +4. Manufacturing and Assembly: + - Component orientation for automated assembly + - Adequate spacing for rework + - Consider component height distribution + +Group components functionally and suggest a logical arrangement. If possible, provide a rough sketch or description of component zones.`, + }, + }, + ], + }), + ); + + // ------------------------------------------------------ + // Component Replacement Analysis Prompt + // ------------------------------------------------------ + server.prompt( + "component_replacement_analysis", + { + component_info: z + .string() + .describe("Information about the component that needs to be replaced"), + }, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `You're helping to find a replacement for a component that is unavailable or needs to be updated. Here's the original component information: + +{{component_info}} + +Consider these factors when suggesting replacements: + +1. Electrical Compatibility: + - Match or exceed key electrical specifications + - Ensure voltage/current/power ratings are compatible + - Consider parametric equivalents + +2. Physical Compatibility: + - Footprint compatibility or adaptation requirements + - Package differences and mounting considerations + - Size and clearance requirements + +3. Performance Impact: + - How the replacement might affect circuit performance + - Potential need for circuit adjustments + +4. Availability and Cost: + - Current market availability + - Cost comparison with original part + - Lead time considerations + +Suggest suitable replacement options and explain the advantages and disadvantages of each. Include any circuit modifications that might be necessary.`, + }, + }, + ], + }), + ); + + // ------------------------------------------------------ + // Component Troubleshooting Prompt + // ------------------------------------------------------ + server.prompt( + "component_troubleshooting", + { + issue_description: z + .string() + .describe("Description of the component or circuit issue being troubleshooted"), + }, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `You're helping to troubleshoot an issue with a component or circuit section in a PCB design. Here's the issue description: + +{{issue_description}} + +Use the following systematic approach to diagnose the problem: + +1. Component Verification: + - Check component values, footprints, and orientation + - Verify correct part numbers and specifications + - Examine for potential manufacturing defects + +2. Circuit Analysis: + - Review the schematic for design errors + - Check for proper connections and signal paths + - Verify power and ground connections + +3. Layout Review: + - Examine component placement and orientation + - Check for adequate clearances + - Review trace routing and potential interference + +4. Environmental Factors: + - Consider temperature, humidity, and other environmental impacts + - Check for potential EMI/RFI issues + - Review mechanical stress or vibration effects + +Based on the available information, suggest likely causes of the issue and recommend specific steps to diagnose and resolve the problem.`, + }, + }, + ], + }), + ); + + // ------------------------------------------------------ + // Component Value Calculation Prompt + // ------------------------------------------------------ + server.prompt( + "component_value_calculation", + { + circuit_requirements: z + .string() + .describe("Description of the circuit function and performance requirements"), + }, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `You're helping to calculate appropriate component values for a specific circuit function. Here's the circuit description and requirements: + +{{circuit_requirements}} + +Follow these steps to determine the optimal component values: + +1. Identify the relevant circuit equations and design formulas +2. Consider the design constraints and performance requirements +3. Calculate initial component values based on ideal behavior +4. Adjust for real-world factors: + - Component tolerances + - Temperature coefficients + - Parasitic effects + - Available standard values + +Present your calculations step-by-step, showing your work and explaining your reasoning. Recommend specific component values, explaining why they're appropriate for this application. If there are multiple valid approaches, discuss the trade-offs between them.`, + }, + }, + ], + }), + ); + + logger.info("Component prompts registered"); +} diff --git a/src/prompts/design.ts b/src/prompts/design.ts index 5c2f5cb..0082ec1 100644 --- a/src/prompts/design.ts +++ b/src/prompts/design.ts @@ -1,321 +1,345 @@ -/** - * Design prompts for KiCAD MCP server - * - * These prompts guide the LLM in providing assistance with general PCB design tasks - * in KiCAD. - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import { logger } from '../logger.js'; - -/** - * Register design prompts with the MCP server - * - * @param server MCP server instance - */ -export function registerDesignPrompts(server: McpServer): void { - logger.info('Registering design prompts'); - - // ------------------------------------------------------ - // PCB Layout Review Prompt - // ------------------------------------------------------ - server.prompt( - "pcb_layout_review", - { - pcb_design_info: z.string().describe("Information about the current PCB design, including board dimensions, layer stack-up, component placement, and routing details") - }, - () => ({ - messages: [ - { - role: "user", - content: { - type: "text", - text: `You're helping to review a PCB layout for potential issues and improvements. Here's information about the current PCB design: - -{{pcb_design_info}} - -When reviewing the PCB layout, consider these key areas: - -1. Component Placement: - - Logical grouping of related components - - Orientation for efficient routing - - Thermal considerations for heat-generating components - - Mechanical constraints (mounting holes, connectors at edges) - - Accessibility for testing and rework - -2. Signal Integrity: - - Trace lengths for critical signals - - Differential pair routing quality - - Potential crosstalk issues - - Return path continuity - - Decoupling capacitor placement - -3. Power Distribution: - - Adequate copper for power rails - - Power plane design and continuity - - Decoupling strategy effectiveness - - Voltage regulator thermal management - -4. EMI/EMC Considerations: - - Ground plane integrity - - Potential antenna effects - - Shielding requirements - - Loop area minimization - - Edge radiation control - -5. Manufacturing and Assembly: - - DFM (Design for Manufacturing) issues - - DFA (Design for Assembly) considerations - - Testability features - - Silkscreen clarity and usefulness - - Solder mask considerations - -Based on the provided information, identify potential issues and suggest specific improvements to enhance the PCB design.` - } - } - ] - }) - ); - - // ------------------------------------------------------ - // Layer Stack-up Planning Prompt - // ------------------------------------------------------ - server.prompt( - "layer_stackup_planning", - { - design_requirements: z.string().describe("Information about the PCB design requirements, including signal types, speed/frequency, power requirements, and any special considerations") - }, - () => ({ - messages: [ - { - role: "user", - content: { - type: "text", - text: `You're helping to plan an appropriate layer stack-up for a PCB design. Here's information about the design requirements: - -{{design_requirements}} - -When planning a PCB layer stack-up, consider these important factors: - -1. Signal Integrity Requirements: - - Controlled impedance needs - - High-speed signal routing - - EMI/EMC considerations - - Crosstalk mitigation - -2. Power Distribution Needs: - - Current requirements for power rails - - Power integrity considerations - - Decoupling effectiveness - - Thermal management - -3. Manufacturing Constraints: - - Fabrication capabilities and limitations - - Cost considerations - - Available materials and their properties - - Standard vs. specialized processes - -4. Layer Types and Arrangement: - - Signal layers - - Power and ground planes - - Mixed signal/plane layers - - Microstrip vs. stripline configurations - -5. Material Selection: - - Dielectric constant (Er) requirements - - Loss tangent considerations for high-speed - - Thermal properties - - Mechanical stability - -Based on the provided requirements, recommend an appropriate layer stack-up, including the number of layers, their arrangement, material specifications, and thickness parameters. Explain the rationale behind your recommendations.` - } - } - ] - }) - ); - - // ------------------------------------------------------ - // Design Rule Development Prompt - // ------------------------------------------------------ - server.prompt( - "design_rule_development", - { - project_requirements: z.string().describe("Information about the PCB project requirements, including technology, speed/frequency, manufacturing capabilities, and any special considerations") - }, - () => ({ - messages: [ - { - role: "user", - content: { - type: "text", - text: `You're helping to develop appropriate design rules for a PCB project. Here's information about the project requirements: - -{{project_requirements}} - -When developing PCB design rules, consider these key areas: - -1. Clearance Rules: - - Minimum spacing between copper features - - Different clearance requirements for different net classes - - High-voltage clearance requirements - - Polygon pour clearances - -2. Width Rules: - - Minimum trace widths for signal nets - - Power trace width requirements based on current - - Differential pair width and spacing - - Net class-specific width rules - -3. Via Rules: - - Minimum via size and drill diameter - - Via annular ring requirements - - Microvias and buried/blind via specifications - - Via-in-pad rules - -4. Manufacturing Constraints: - - Minimum hole size - - Aspect ratio limitations - - Soldermask and silkscreen constraints - - Edge clearances - -5. Special Requirements: - - Impedance control specifications - - High-speed routing constraints - - Thermal relief parameters - - Teardrop specifications - -Based on the provided project requirements, recommend a comprehensive set of design rules that will ensure signal integrity, manufacturability, and reliability of the PCB. Provide specific values where appropriate and explain the rationale behind critical rules.` - } - } - ] - }) - ); - - // ------------------------------------------------------ - // Component Selection Guidance Prompt - // ------------------------------------------------------ - server.prompt( - "component_selection_guidance", - { - circuit_requirements: z.string().describe("Information about the circuit requirements, including functionality, performance needs, operating environment, and any special considerations") - }, - () => ({ - messages: [ - { - role: "user", - content: { - type: "text", - text: `You're helping with component selection for a PCB design. Here's information about the circuit requirements: - -{{circuit_requirements}} - -When selecting components for a PCB design, consider these important factors: - -1. Electrical Specifications: - - Voltage and current ratings - - Power handling capabilities - - Speed/frequency requirements - - Noise and precision considerations - - Operating temperature range - -2. Package and Footprint: - - Space constraints on the PCB - - Thermal dissipation requirements - - Manual vs. automated assembly - - Inspection and rework considerations - - Available footprint libraries - -3. Availability and Sourcing: - - Multiple source options - - Lead time considerations - - Lifecycle status (new, mature, end-of-life) - - Cost considerations - - Minimum order quantities - -4. Reliability and Quality: - - Industrial vs. commercial vs. automotive grade - - Expected lifetime of the product - - Environmental conditions - - Compliance with relevant standards - -5. Special Considerations: - - EMI/EMC performance - - Thermal characteristics - - Moisture sensitivity - - RoHS/REACH compliance - - Special handling requirements - -Based on the provided circuit requirements, recommend appropriate component types, packages, and specific considerations for this design. Provide guidance on critical component selections and explain the rationale behind your recommendations.` - } - } - ] - }) - ); - - // ------------------------------------------------------ - // PCB Design Optimization Prompt - // ------------------------------------------------------ - server.prompt( - "pcb_design_optimization", - { - design_info: z.string().describe("Information about the current PCB design, including board dimensions, layer stack-up, component placement, and routing details"), - optimization_goals: z.string().describe("Specific goals for optimization, such as performance improvement, cost reduction, size reduction, or manufacturability enhancement") - }, - () => ({ - messages: [ - { - role: "user", - content: { - type: "text", - text: `You're helping to optimize a PCB design. Here's information about the current design and optimization goals: - -{{design_info}} -{{optimization_goals}} - -When optimizing a PCB design, consider these key areas based on the stated goals: - -1. Performance Optimization: - - Critical signal path length reduction - - Impedance control improvement - - Decoupling strategy enhancement - - Thermal management improvement - - EMI/EMC reduction techniques - -2. Manufacturability Optimization: - - DFM rule compliance - - Testability improvements - - Assembly process simplification - - Yield improvement opportunities - - Tolerance and variation management - -3. Cost Optimization: - - Board size reduction opportunities - - Layer count optimization - - Component consolidation - - Alternative component options - - Panelization efficiency - -4. Reliability Optimization: - - Stress point identification and mitigation - - Environmental robustness improvements - - Failure mode mitigation - - Margin analysis and improvement - - Redundancy considerations - -5. Space/Size Optimization: - - Component placement density - - 3D space utilization - - Flex and rigid-flex opportunities - - Alternative packaging approaches - - Connector and interface optimization - -Based on the provided information and optimization goals, suggest specific, actionable improvements to the PCB design. Prioritize your recommendations based on their potential impact and implementation feasibility.` - } - } - ] - }) - ); - - logger.info('Design prompts registered'); -} +/** + * Design prompts for KiCAD MCP server + * + * These prompts guide the LLM in providing assistance with general PCB design tasks + * in KiCAD. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { logger } from "../logger.js"; + +/** + * Register design prompts with the MCP server + * + * @param server MCP server instance + */ +export function registerDesignPrompts(server: McpServer): void { + logger.info("Registering design prompts"); + + // ------------------------------------------------------ + // PCB Layout Review Prompt + // ------------------------------------------------------ + server.prompt( + "pcb_layout_review", + { + pcb_design_info: z + .string() + .describe( + "Information about the current PCB design, including board dimensions, layer stack-up, component placement, and routing details", + ), + }, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `You're helping to review a PCB layout for potential issues and improvements. Here's information about the current PCB design: + +{{pcb_design_info}} + +When reviewing the PCB layout, consider these key areas: + +1. Component Placement: + - Logical grouping of related components + - Orientation for efficient routing + - Thermal considerations for heat-generating components + - Mechanical constraints (mounting holes, connectors at edges) + - Accessibility for testing and rework + +2. Signal Integrity: + - Trace lengths for critical signals + - Differential pair routing quality + - Potential crosstalk issues + - Return path continuity + - Decoupling capacitor placement + +3. Power Distribution: + - Adequate copper for power rails + - Power plane design and continuity + - Decoupling strategy effectiveness + - Voltage regulator thermal management + +4. EMI/EMC Considerations: + - Ground plane integrity + - Potential antenna effects + - Shielding requirements + - Loop area minimization + - Edge radiation control + +5. Manufacturing and Assembly: + - DFM (Design for Manufacturing) issues + - DFA (Design for Assembly) considerations + - Testability features + - Silkscreen clarity and usefulness + - Solder mask considerations + +Based on the provided information, identify potential issues and suggest specific improvements to enhance the PCB design.`, + }, + }, + ], + }), + ); + + // ------------------------------------------------------ + // Layer Stack-up Planning Prompt + // ------------------------------------------------------ + server.prompt( + "layer_stackup_planning", + { + design_requirements: z + .string() + .describe( + "Information about the PCB design requirements, including signal types, speed/frequency, power requirements, and any special considerations", + ), + }, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `You're helping to plan an appropriate layer stack-up for a PCB design. Here's information about the design requirements: + +{{design_requirements}} + +When planning a PCB layer stack-up, consider these important factors: + +1. Signal Integrity Requirements: + - Controlled impedance needs + - High-speed signal routing + - EMI/EMC considerations + - Crosstalk mitigation + +2. Power Distribution Needs: + - Current requirements for power rails + - Power integrity considerations + - Decoupling effectiveness + - Thermal management + +3. Manufacturing Constraints: + - Fabrication capabilities and limitations + - Cost considerations + - Available materials and their properties + - Standard vs. specialized processes + +4. Layer Types and Arrangement: + - Signal layers + - Power and ground planes + - Mixed signal/plane layers + - Microstrip vs. stripline configurations + +5. Material Selection: + - Dielectric constant (Er) requirements + - Loss tangent considerations for high-speed + - Thermal properties + - Mechanical stability + +Based on the provided requirements, recommend an appropriate layer stack-up, including the number of layers, their arrangement, material specifications, and thickness parameters. Explain the rationale behind your recommendations.`, + }, + }, + ], + }), + ); + + // ------------------------------------------------------ + // Design Rule Development Prompt + // ------------------------------------------------------ + server.prompt( + "design_rule_development", + { + project_requirements: z + .string() + .describe( + "Information about the PCB project requirements, including technology, speed/frequency, manufacturing capabilities, and any special considerations", + ), + }, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `You're helping to develop appropriate design rules for a PCB project. Here's information about the project requirements: + +{{project_requirements}} + +When developing PCB design rules, consider these key areas: + +1. Clearance Rules: + - Minimum spacing between copper features + - Different clearance requirements for different net classes + - High-voltage clearance requirements + - Polygon pour clearances + +2. Width Rules: + - Minimum trace widths for signal nets + - Power trace width requirements based on current + - Differential pair width and spacing + - Net class-specific width rules + +3. Via Rules: + - Minimum via size and drill diameter + - Via annular ring requirements + - Microvias and buried/blind via specifications + - Via-in-pad rules + +4. Manufacturing Constraints: + - Minimum hole size + - Aspect ratio limitations + - Soldermask and silkscreen constraints + - Edge clearances + +5. Special Requirements: + - Impedance control specifications + - High-speed routing constraints + - Thermal relief parameters + - Teardrop specifications + +Based on the provided project requirements, recommend a comprehensive set of design rules that will ensure signal integrity, manufacturability, and reliability of the PCB. Provide specific values where appropriate and explain the rationale behind critical rules.`, + }, + }, + ], + }), + ); + + // ------------------------------------------------------ + // Component Selection Guidance Prompt + // ------------------------------------------------------ + server.prompt( + "component_selection_guidance", + { + circuit_requirements: z + .string() + .describe( + "Information about the circuit requirements, including functionality, performance needs, operating environment, and any special considerations", + ), + }, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `You're helping with component selection for a PCB design. Here's information about the circuit requirements: + +{{circuit_requirements}} + +When selecting components for a PCB design, consider these important factors: + +1. Electrical Specifications: + - Voltage and current ratings + - Power handling capabilities + - Speed/frequency requirements + - Noise and precision considerations + - Operating temperature range + +2. Package and Footprint: + - Space constraints on the PCB + - Thermal dissipation requirements + - Manual vs. automated assembly + - Inspection and rework considerations + - Available footprint libraries + +3. Availability and Sourcing: + - Multiple source options + - Lead time considerations + - Lifecycle status (new, mature, end-of-life) + - Cost considerations + - Minimum order quantities + +4. Reliability and Quality: + - Industrial vs. commercial vs. automotive grade + - Expected lifetime of the product + - Environmental conditions + - Compliance with relevant standards + +5. Special Considerations: + - EMI/EMC performance + - Thermal characteristics + - Moisture sensitivity + - RoHS/REACH compliance + - Special handling requirements + +Based on the provided circuit requirements, recommend appropriate component types, packages, and specific considerations for this design. Provide guidance on critical component selections and explain the rationale behind your recommendations.`, + }, + }, + ], + }), + ); + + // ------------------------------------------------------ + // PCB Design Optimization Prompt + // ------------------------------------------------------ + server.prompt( + "pcb_design_optimization", + { + design_info: z + .string() + .describe( + "Information about the current PCB design, including board dimensions, layer stack-up, component placement, and routing details", + ), + optimization_goals: z + .string() + .describe( + "Specific goals for optimization, such as performance improvement, cost reduction, size reduction, or manufacturability enhancement", + ), + }, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `You're helping to optimize a PCB design. Here's information about the current design and optimization goals: + +{{design_info}} +{{optimization_goals}} + +When optimizing a PCB design, consider these key areas based on the stated goals: + +1. Performance Optimization: + - Critical signal path length reduction + - Impedance control improvement + - Decoupling strategy enhancement + - Thermal management improvement + - EMI/EMC reduction techniques + +2. Manufacturability Optimization: + - DFM rule compliance + - Testability improvements + - Assembly process simplification + - Yield improvement opportunities + - Tolerance and variation management + +3. Cost Optimization: + - Board size reduction opportunities + - Layer count optimization + - Component consolidation + - Alternative component options + - Panelization efficiency + +4. Reliability Optimization: + - Stress point identification and mitigation + - Environmental robustness improvements + - Failure mode mitigation + - Margin analysis and improvement + - Redundancy considerations + +5. Space/Size Optimization: + - Component placement density + - 3D space utilization + - Flex and rigid-flex opportunities + - Alternative packaging approaches + - Connector and interface optimization + +Based on the provided information and optimization goals, suggest specific, actionable improvements to the PCB design. Prioritize your recommendations based on their potential impact and implementation feasibility.`, + }, + }, + ], + }), + ); + + logger.info("Design prompts registered"); +} diff --git a/src/prompts/footprint.ts b/src/prompts/footprint.ts index 81556af..623104c 100644 --- a/src/prompts/footprint.ts +++ b/src/prompts/footprint.ts @@ -23,10 +23,7 @@ export function registerFootprintPrompts(server: McpServer): void { .describe( "Component description, e.g. 'SOT-23 NPN transistor' or '2-pin JST XH 2.5mm connector'", ), - libraryPath: z - .string() - .optional() - .describe("Target .pretty library path (optional)"), + libraryPath: z.string().optional().describe("Target .pretty library path (optional)"), }, () => ({ messages: [ @@ -107,9 +104,7 @@ Now create the footprint for: {{component}}`, server.prompt( "footprint_ipc_checklist", { - footprintPath: z - .string() - .describe("Path to the .kicad_mod file to review"), + footprintPath: z.string().describe("Path to the .kicad_mod file to review"), }, () => ({ messages: [ diff --git a/src/prompts/routing.ts b/src/prompts/routing.ts index cd8eb92..0f146ac 100644 --- a/src/prompts/routing.ts +++ b/src/prompts/routing.ts @@ -1,288 +1,308 @@ -/** - * Routing prompts for KiCAD MCP server - * - * These prompts guide the LLM in providing assistance with routing-related tasks - * in KiCAD PCB design. - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import { logger } from '../logger.js'; - -/** - * Register routing prompts with the MCP server - * - * @param server MCP server instance - */ -export function registerRoutingPrompts(server: McpServer): void { - logger.info('Registering routing prompts'); - - // ------------------------------------------------------ - // Routing Strategy Prompt - // ------------------------------------------------------ - server.prompt( - "routing_strategy", - { - board_info: z.string().describe("Information about the PCB board, including dimensions, layer stack-up, and components") - }, - () => ({ - messages: [ - { - role: "user", - content: { - type: "text", - text: `You're helping to develop a routing strategy for a PCB design. Here's information about the board: - -{{board_info}} - -Consider the following aspects when developing your routing strategy: - -1. Signal Integrity: - - Group related signals and keep them close - - Minimize trace length for high-speed signals - - Consider differential pair routing for appropriate signals - - Avoid right-angle bends in traces - -2. Power Distribution: - - Use appropriate trace widths for power and ground - - Consider using power planes for better distribution - - Place decoupling capacitors close to ICs - -3. EMI/EMC Considerations: - - Keep digital and analog sections separated - - Consider ground plane partitioning - - Minimize loop areas for sensitive signals - -4. Manufacturing Constraints: - - Adhere to minimum trace width and spacing requirements - - Consider via size and placement restrictions - - Account for soldermask and silkscreen limitations - -5. Layer Stack-up Utilization: - - Determine which signals go on which layers - - Plan for layer transitions (vias) - - Consider impedance control requirements - -Provide a comprehensive routing strategy that addresses these aspects, with specific recommendations for this particular board design.` - } - } - ] - }) - ); - - // ------------------------------------------------------ - // Differential Pair Routing Prompt - // ------------------------------------------------------ - server.prompt( - "differential_pair_routing", - { - differential_pairs: z.string().describe("Information about the differential pairs to be routed, including signal names, source and destination components, and speed/frequency requirements") - }, - () => ({ - messages: [ - { - role: "user", - content: { - type: "text", - text: `You're helping with routing differential pairs on a PCB. Here's information about the differential pairs: - -{{differential_pairs}} - -When routing differential pairs, follow these best practices: - -1. Length Matching: - - Keep both traces in each pair the same length - - Maintain consistent spacing between the traces - - Use serpentine routing (meanders) for length matching when necessary - -2. Impedance Control: - - Maintain consistent trace width and spacing to control impedance - - Consider the layer stack-up and dielectric properties - - Avoid changing layers if possible; when necessary, use symmetrical via pairs - -3. Coupling and Crosstalk: - - Keep differential pairs tightly coupled to each other - - Maintain adequate spacing between different differential pairs - - Route away from single-ended signals that could cause interference - -4. Reference Planes: - - Route over continuous reference planes - - Avoid splits in reference planes under differential pairs - - Consider the return path for the signals - -5. Termination: - - Plan for proper termination at the ends of the pairs - - Consider the need for series or parallel termination resistors - - Place termination components close to the endpoints - -Based on the provided information, suggest specific routing approaches for these differential pairs, including recommended trace width, spacing, and any special considerations for this particular design.` - } - } - ] - }) - ); - - // ------------------------------------------------------ - // High-Speed Routing Prompt - // ------------------------------------------------------ - server.prompt( - "high_speed_routing", - { - high_speed_signals: z.string().describe("Information about the high-speed signals to be routed, including signal names, source and destination components, and speed/frequency requirements") - }, - () => ({ - messages: [ - { - role: "user", - content: { - type: "text", - text: `You're helping with routing high-speed signals on a PCB. Here's information about the high-speed signals: - -{{high_speed_signals}} - -When routing high-speed signals, consider these critical factors: - -1. Impedance Control: - - Maintain consistent trace width to control impedance - - Use controlled impedance calculations based on layer stack-up - - Consider microstrip vs. stripline routing depending on signal requirements - -2. Signal Integrity: - - Minimize trace length to reduce propagation delay - - Avoid sharp corners (use 45° angles or curves) - - Minimize vias to reduce discontinuities - - Consider using teardrops at pad connections - -3. Crosstalk Mitigation: - - Maintain adequate spacing between high-speed traces - - Use ground traces or planes for isolation - - Cross traces at 90° when traces must cross on adjacent layers - -4. Return Path Management: - - Ensure continuous return path under the signal - - Avoid reference plane splits under high-speed signals - - Use ground vias near signal vias for return path continuity - -5. Termination and Loading: - - Plan for proper termination (series, parallel, AC, etc.) - - Consider transmission line effects - - Account for capacitive loading from components and vias - -Based on the provided information, suggest specific routing approaches for these high-speed signals, including recommended trace width, layer assignment, and any special considerations for this particular design.` - } - } - ] - }) - ); - - // ------------------------------------------------------ - // Power Distribution Prompt - // ------------------------------------------------------ - server.prompt( - "power_distribution", - { - power_requirements: z.string().describe("Information about the power requirements, including voltage rails, current needs, and components requiring power") - }, - () => ({ - messages: [ - { - role: "user", - content: { - type: "text", - text: `You're helping with designing the power distribution network for a PCB. Here's information about the power requirements: - -{{power_requirements}} - -Consider these key aspects of power distribution network design: - -1. Power Planes vs. Traces: - - Determine when to use power planes versus wide traces - - Consider current requirements and voltage drop - - Plan the layer stack-up to accommodate power distribution - -2. Decoupling Strategy: - - Place decoupling capacitors close to ICs - - Use appropriate capacitor values and types - - Consider high-frequency and bulk decoupling needs - - Plan for power entry filtering - -3. Current Capacity: - - Calculate trace widths based on current requirements - - Consider thermal issues and heat dissipation - - Plan for current return paths - -4. Voltage Regulation: - - Place regulators strategically - - Consider thermal management for regulators - - Plan feedback paths for regulators - -5. EMI/EMC Considerations: - - Minimize loop areas - - Keep power and ground planes closely coupled - - Consider filtering for noise-sensitive circuits - -Based on the provided information, suggest a comprehensive power distribution strategy, including specific recommendations for plane usage, trace widths, decoupling, and any special considerations for this particular design.` - } - } - ] - }) - ); - - // ------------------------------------------------------ - // Via Usage Prompt - // ------------------------------------------------------ - server.prompt( - "via_usage", - { - board_info: z.string().describe("Information about the PCB board, including layer count, thickness, and design requirements") - }, - () => ({ - messages: [ - { - role: "user", - content: { - type: "text", - text: `You're helping with planning via usage in a PCB design. Here's information about the board: - -{{board_info}} - -Consider these important aspects of via usage: - -1. Via Types: - - Through-hole vias (span all layers) - - Blind vias (connect outer layer to inner layer) - - Buried vias (connect inner layers only) - - Microvias (small diameter vias for HDI designs) - -2. Manufacturing Constraints: - - Minimum via diameter and drill size - - Aspect ratio limitations (board thickness to hole diameter) - - Annular ring requirements - - Via-in-pad considerations and special processing - -3. Signal Integrity Impact: - - Capacitive loading effects of vias - - Impedance discontinuities - - Stub effects in through-hole vias - - Strategies to minimize via impact on high-speed signals - -4. Thermal Considerations: - - Using vias for thermal relief - - Via patterns for heat dissipation - - Thermal via sizing and spacing - -5. Design Optimization: - - Via fanout strategies - - Sharing vias between signals vs. dedicated vias - - Via placement to minimize trace length - - Tenting and plugging options - -Based on the provided information, recommend appropriate via strategies for this PCB design, including specific via types, sizes, and placement guidelines.` - } - } - ] - }) - ); - - logger.info('Routing prompts registered'); -} +/** + * Routing prompts for KiCAD MCP server + * + * These prompts guide the LLM in providing assistance with routing-related tasks + * in KiCAD PCB design. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { logger } from "../logger.js"; + +/** + * Register routing prompts with the MCP server + * + * @param server MCP server instance + */ +export function registerRoutingPrompts(server: McpServer): void { + logger.info("Registering routing prompts"); + + // ------------------------------------------------------ + // Routing Strategy Prompt + // ------------------------------------------------------ + server.prompt( + "routing_strategy", + { + board_info: z + .string() + .describe( + "Information about the PCB board, including dimensions, layer stack-up, and components", + ), + }, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `You're helping to develop a routing strategy for a PCB design. Here's information about the board: + +{{board_info}} + +Consider the following aspects when developing your routing strategy: + +1. Signal Integrity: + - Group related signals and keep them close + - Minimize trace length for high-speed signals + - Consider differential pair routing for appropriate signals + - Avoid right-angle bends in traces + +2. Power Distribution: + - Use appropriate trace widths for power and ground + - Consider using power planes for better distribution + - Place decoupling capacitors close to ICs + +3. EMI/EMC Considerations: + - Keep digital and analog sections separated + - Consider ground plane partitioning + - Minimize loop areas for sensitive signals + +4. Manufacturing Constraints: + - Adhere to minimum trace width and spacing requirements + - Consider via size and placement restrictions + - Account for soldermask and silkscreen limitations + +5. Layer Stack-up Utilization: + - Determine which signals go on which layers + - Plan for layer transitions (vias) + - Consider impedance control requirements + +Provide a comprehensive routing strategy that addresses these aspects, with specific recommendations for this particular board design.`, + }, + }, + ], + }), + ); + + // ------------------------------------------------------ + // Differential Pair Routing Prompt + // ------------------------------------------------------ + server.prompt( + "differential_pair_routing", + { + differential_pairs: z + .string() + .describe( + "Information about the differential pairs to be routed, including signal names, source and destination components, and speed/frequency requirements", + ), + }, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `You're helping with routing differential pairs on a PCB. Here's information about the differential pairs: + +{{differential_pairs}} + +When routing differential pairs, follow these best practices: + +1. Length Matching: + - Keep both traces in each pair the same length + - Maintain consistent spacing between the traces + - Use serpentine routing (meanders) for length matching when necessary + +2. Impedance Control: + - Maintain consistent trace width and spacing to control impedance + - Consider the layer stack-up and dielectric properties + - Avoid changing layers if possible; when necessary, use symmetrical via pairs + +3. Coupling and Crosstalk: + - Keep differential pairs tightly coupled to each other + - Maintain adequate spacing between different differential pairs + - Route away from single-ended signals that could cause interference + +4. Reference Planes: + - Route over continuous reference planes + - Avoid splits in reference planes under differential pairs + - Consider the return path for the signals + +5. Termination: + - Plan for proper termination at the ends of the pairs + - Consider the need for series or parallel termination resistors + - Place termination components close to the endpoints + +Based on the provided information, suggest specific routing approaches for these differential pairs, including recommended trace width, spacing, and any special considerations for this particular design.`, + }, + }, + ], + }), + ); + + // ------------------------------------------------------ + // High-Speed Routing Prompt + // ------------------------------------------------------ + server.prompt( + "high_speed_routing", + { + high_speed_signals: z + .string() + .describe( + "Information about the high-speed signals to be routed, including signal names, source and destination components, and speed/frequency requirements", + ), + }, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `You're helping with routing high-speed signals on a PCB. Here's information about the high-speed signals: + +{{high_speed_signals}} + +When routing high-speed signals, consider these critical factors: + +1. Impedance Control: + - Maintain consistent trace width to control impedance + - Use controlled impedance calculations based on layer stack-up + - Consider microstrip vs. stripline routing depending on signal requirements + +2. Signal Integrity: + - Minimize trace length to reduce propagation delay + - Avoid sharp corners (use 45° angles or curves) + - Minimize vias to reduce discontinuities + - Consider using teardrops at pad connections + +3. Crosstalk Mitigation: + - Maintain adequate spacing between high-speed traces + - Use ground traces or planes for isolation + - Cross traces at 90° when traces must cross on adjacent layers + +4. Return Path Management: + - Ensure continuous return path under the signal + - Avoid reference plane splits under high-speed signals + - Use ground vias near signal vias for return path continuity + +5. Termination and Loading: + - Plan for proper termination (series, parallel, AC, etc.) + - Consider transmission line effects + - Account for capacitive loading from components and vias + +Based on the provided information, suggest specific routing approaches for these high-speed signals, including recommended trace width, layer assignment, and any special considerations for this particular design.`, + }, + }, + ], + }), + ); + + // ------------------------------------------------------ + // Power Distribution Prompt + // ------------------------------------------------------ + server.prompt( + "power_distribution", + { + power_requirements: z + .string() + .describe( + "Information about the power requirements, including voltage rails, current needs, and components requiring power", + ), + }, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `You're helping with designing the power distribution network for a PCB. Here's information about the power requirements: + +{{power_requirements}} + +Consider these key aspects of power distribution network design: + +1. Power Planes vs. Traces: + - Determine when to use power planes versus wide traces + - Consider current requirements and voltage drop + - Plan the layer stack-up to accommodate power distribution + +2. Decoupling Strategy: + - Place decoupling capacitors close to ICs + - Use appropriate capacitor values and types + - Consider high-frequency and bulk decoupling needs + - Plan for power entry filtering + +3. Current Capacity: + - Calculate trace widths based on current requirements + - Consider thermal issues and heat dissipation + - Plan for current return paths + +4. Voltage Regulation: + - Place regulators strategically + - Consider thermal management for regulators + - Plan feedback paths for regulators + +5. EMI/EMC Considerations: + - Minimize loop areas + - Keep power and ground planes closely coupled + - Consider filtering for noise-sensitive circuits + +Based on the provided information, suggest a comprehensive power distribution strategy, including specific recommendations for plane usage, trace widths, decoupling, and any special considerations for this particular design.`, + }, + }, + ], + }), + ); + + // ------------------------------------------------------ + // Via Usage Prompt + // ------------------------------------------------------ + server.prompt( + "via_usage", + { + board_info: z + .string() + .describe( + "Information about the PCB board, including layer count, thickness, and design requirements", + ), + }, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `You're helping with planning via usage in a PCB design. Here's information about the board: + +{{board_info}} + +Consider these important aspects of via usage: + +1. Via Types: + - Through-hole vias (span all layers) + - Blind vias (connect outer layer to inner layer) + - Buried vias (connect inner layers only) + - Microvias (small diameter vias for HDI designs) + +2. Manufacturing Constraints: + - Minimum via diameter and drill size + - Aspect ratio limitations (board thickness to hole diameter) + - Annular ring requirements + - Via-in-pad considerations and special processing + +3. Signal Integrity Impact: + - Capacitive loading effects of vias + - Impedance discontinuities + - Stub effects in through-hole vias + - Strategies to minimize via impact on high-speed signals + +4. Thermal Considerations: + - Using vias for thermal relief + - Via patterns for heat dissipation + - Thermal via sizing and spacing + +5. Design Optimization: + - Via fanout strategies + - Sharing vias between signals vs. dedicated vias + - Via placement to minimize trace length + - Tenting and plugging options + +Based on the provided information, recommend appropriate via strategies for this PCB design, including specific via types, sizes, and placement guidelines.`, + }, + }, + ], + }), + ); + + logger.info("Routing prompts registered"); +} diff --git a/src/resources/board.ts b/src/resources/board.ts index ce5d1f3..11f86b8 100644 --- a/src/resources/board.ts +++ b/src/resources/board.ts @@ -1,354 +1,372 @@ -/** - * Board resources for KiCAD MCP server - * - * These resources provide information about the PCB board - * to the LLM, enabling better context-aware assistance. - */ - -import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import { logger } from '../logger.js'; -import { createJsonResponse, createBinaryResponse } from '../utils/resource-helpers.js'; - -// Command function type for KiCAD script calls -type CommandFunction = (command: string, params: Record) => Promise; - -/** - * Register board resources with the MCP server - * - * @param server MCP server instance - * @param callKicadScript Function to call KiCAD script commands - */ -export function registerBoardResources(server: McpServer, callKicadScript: CommandFunction): void { - logger.info('Registering board resources'); - - // ------------------------------------------------------ - // Board Information Resource - // ------------------------------------------------------ - server.resource( - "board_info", - "kicad://board/info", - async (uri) => { - logger.debug('Retrieving board information'); - const result = await callKicadScript("get_board_info", {}); - - if (!result.success) { - logger.error(`Failed to retrieve board information: ${result.errorDetails}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: "Failed to retrieve board information", - details: result.errorDetails - }), - mimeType: "application/json" - }] - }; - } - - logger.debug('Successfully retrieved board information'); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify(result), - mimeType: "application/json" - }] - }; - } - ); - - // ------------------------------------------------------ - // Layer List Resource - // ------------------------------------------------------ - server.resource( - "layer_list", - "kicad://board/layers", - async (uri) => { - logger.debug('Retrieving layer list'); - const result = await callKicadScript("get_layer_list", {}); - - if (!result.success) { - logger.error(`Failed to retrieve layer list: ${result.errorDetails}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: "Failed to retrieve layer list", - details: result.errorDetails - }), - mimeType: "application/json" - }] - }; - } - - logger.debug(`Successfully retrieved ${result.layers?.length || 0} layers`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify(result), - mimeType: "application/json" - }] - }; - } - ); - - // ------------------------------------------------------ - // Board Extents Resource - // ------------------------------------------------------ - server.resource( - "board_extents", - new ResourceTemplate("kicad://board/extents/{unit?}", { - list: async () => ({ - resources: [ - { uri: "kicad://board/extents/mm", name: "Millimeters" }, - { uri: "kicad://board/extents/inch", name: "Inches" } - ] - }) - }), - async (uri, params) => { - const unit = params.unit || 'mm'; - - logger.debug(`Retrieving board extents in ${unit}`); - const result = await callKicadScript("get_board_extents", { unit }); - - if (!result.success) { - logger.error(`Failed to retrieve board extents: ${result.errorDetails}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: "Failed to retrieve board extents", - details: result.errorDetails - }), - mimeType: "application/json" - }] - }; - } - - logger.debug('Successfully retrieved board extents'); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify(result), - mimeType: "application/json" - }] - }; - } - ); - - // ------------------------------------------------------ - // Board 2D View Resource - // ------------------------------------------------------ - server.resource( - "board_2d_view", - new ResourceTemplate("kicad://board/2d-view/{format?}", { - list: async () => ({ - resources: [ - { uri: "kicad://board/2d-view/png", name: "PNG Format" }, - { uri: "kicad://board/2d-view/jpg", name: "JPEG Format" }, - { uri: "kicad://board/2d-view/svg", name: "SVG Format" } - ] - }) - }), - async (uri, params) => { - const format = (params.format || 'png') as 'png' | 'jpg' | 'svg'; - const width = params.width ? parseInt(params.width as string) : undefined; - const height = params.height ? parseInt(params.height as string) : undefined; - // Handle layers parameter - could be string or array - const layers = typeof params.layers === 'string' ? params.layers.split(',') : params.layers; - - logger.debug('Retrieving 2D board view'); - const result = await callKicadScript("get_board_2d_view", { - layers, - width, - height, - format - }); - - if (!result.success) { - logger.error(`Failed to retrieve 2D board view: ${result.errorDetails}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: "Failed to retrieve 2D board view", - details: result.errorDetails - }), - mimeType: "application/json" - }] - }; - } - - logger.debug('Successfully retrieved 2D board view'); - - if (format === 'svg') { - return { - contents: [{ - uri: uri.href, - text: result.imageData, - mimeType: "image/svg+xml" - }] - }; - } else { - return { - contents: [{ - uri: uri.href, - blob: result.imageData, - mimeType: format === "jpg" ? "image/jpeg" : "image/png" - }] - }; - } - } - ); - - // ------------------------------------------------------ - // Board 3D View Resource - // ------------------------------------------------------ - server.resource( - "board_3d_view", - new ResourceTemplate("kicad://board/3d-view/{angle?}", { - list: async () => ({ - resources: [ - { uri: "kicad://board/3d-view/isometric", name: "Isometric View" }, - { uri: "kicad://board/3d-view/top", name: "Top View" }, - { uri: "kicad://board/3d-view/bottom", name: "Bottom View" } - ] - }) - }), - async (uri, params) => { - const angle = params.angle || 'isometric'; - const width = params.width ? parseInt(params.width as string) : undefined; - const height = params.height ? parseInt(params.height as string) : undefined; - - logger.debug(`Retrieving 3D board view from ${angle} angle`); - const result = await callKicadScript("get_board_3d_view", { - width, - height, - angle - }); - - if (!result.success) { - logger.error(`Failed to retrieve 3D board view: ${result.errorDetails}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: "Failed to retrieve 3D board view", - details: result.errorDetails - }), - mimeType: "application/json" - }] - }; - } - - logger.debug('Successfully retrieved 3D board view'); - return { - contents: [{ - uri: uri.href, - blob: result.imageData, - mimeType: "image/png" - }] - }; - } - ); - - // ------------------------------------------------------ - // Board Statistics Resource - // ------------------------------------------------------ - server.resource( - "board_statistics", - "kicad://board/statistics", - async (uri) => { - logger.debug('Generating board statistics'); - - // Get board info - const boardResult = await callKicadScript("get_board_info", {}); - if (!boardResult.success) { - logger.error(`Failed to retrieve board information: ${boardResult.errorDetails}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: "Failed to generate board statistics", - details: boardResult.errorDetails - }), - mimeType: "application/json" - }] - }; - } - - // Get component list - const componentsResult = await callKicadScript("get_component_list", {}); - if (!componentsResult.success) { - logger.error(`Failed to retrieve component list: ${componentsResult.errorDetails}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: "Failed to generate board statistics", - details: componentsResult.errorDetails - }), - mimeType: "application/json" - }] - }; - } - - // Get nets list - const netsResult = await callKicadScript("get_nets_list", {}); - if (!netsResult.success) { - logger.error(`Failed to retrieve nets list: ${netsResult.errorDetails}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: "Failed to generate board statistics", - details: netsResult.errorDetails - }), - mimeType: "application/json" - }] - }; - } - - // Combine all information into statistics - const statistics = { - board: { - size: boardResult.size, - layers: boardResult.layers?.length || 0, - title: boardResult.title - }, - components: { - count: componentsResult.components?.length || 0, - types: countComponentTypes(componentsResult.components || []) - }, - nets: { - count: netsResult.nets?.length || 0 - } - }; - - logger.debug('Successfully generated board statistics'); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify(statistics), - mimeType: "application/json" - }] - }; - } - ); - - logger.info('Board resources registered'); -} - -/** - * Helper function to count component types - */ -function countComponentTypes(components: any[]): Record { - const typeCounts: Record = {}; - - for (const component of components) { - const type = component.value?.split(' ')[0] || 'Unknown'; - typeCounts[type] = (typeCounts[type] || 0) + 1; - } - - return typeCounts; -} +/** + * Board resources for KiCAD MCP server + * + * These resources provide information about the PCB board + * to the LLM, enabling better context-aware assistance. + */ + +import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { logger } from "../logger.js"; +import { createJsonResponse, createBinaryResponse } from "../utils/resource-helpers.js"; + +// Command function type for KiCAD script calls +type CommandFunction = (command: string, params: Record) => Promise; + +/** + * Register board resources with the MCP server + * + * @param server MCP server instance + * @param callKicadScript Function to call KiCAD script commands + */ +export function registerBoardResources(server: McpServer, callKicadScript: CommandFunction): void { + logger.info("Registering board resources"); + + // ------------------------------------------------------ + // Board Information Resource + // ------------------------------------------------------ + server.resource("board_info", "kicad://board/info", async (uri) => { + logger.debug("Retrieving board information"); + const result = await callKicadScript("get_board_info", {}); + + if (!result.success) { + logger.error(`Failed to retrieve board information: ${result.errorDetails}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: "Failed to retrieve board information", + details: result.errorDetails, + }), + mimeType: "application/json", + }, + ], + }; + } + + logger.debug("Successfully retrieved board information"); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(result), + mimeType: "application/json", + }, + ], + }; + }); + + // ------------------------------------------------------ + // Layer List Resource + // ------------------------------------------------------ + server.resource("layer_list", "kicad://board/layers", async (uri) => { + logger.debug("Retrieving layer list"); + const result = await callKicadScript("get_layer_list", {}); + + if (!result.success) { + logger.error(`Failed to retrieve layer list: ${result.errorDetails}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: "Failed to retrieve layer list", + details: result.errorDetails, + }), + mimeType: "application/json", + }, + ], + }; + } + + logger.debug(`Successfully retrieved ${result.layers?.length || 0} layers`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(result), + mimeType: "application/json", + }, + ], + }; + }); + + // ------------------------------------------------------ + // Board Extents Resource + // ------------------------------------------------------ + server.resource( + "board_extents", + new ResourceTemplate("kicad://board/extents/{unit?}", { + list: async () => ({ + resources: [ + { uri: "kicad://board/extents/mm", name: "Millimeters" }, + { uri: "kicad://board/extents/inch", name: "Inches" }, + ], + }), + }), + async (uri, params) => { + const unit = params.unit || "mm"; + + logger.debug(`Retrieving board extents in ${unit}`); + const result = await callKicadScript("get_board_extents", { unit }); + + if (!result.success) { + logger.error(`Failed to retrieve board extents: ${result.errorDetails}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: "Failed to retrieve board extents", + details: result.errorDetails, + }), + mimeType: "application/json", + }, + ], + }; + } + + logger.debug("Successfully retrieved board extents"); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(result), + mimeType: "application/json", + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Board 2D View Resource + // ------------------------------------------------------ + server.resource( + "board_2d_view", + new ResourceTemplate("kicad://board/2d-view/{format?}", { + list: async () => ({ + resources: [ + { uri: "kicad://board/2d-view/png", name: "PNG Format" }, + { uri: "kicad://board/2d-view/jpg", name: "JPEG Format" }, + { uri: "kicad://board/2d-view/svg", name: "SVG Format" }, + ], + }), + }), + async (uri, params) => { + const format = (params.format || "png") as "png" | "jpg" | "svg"; + const width = params.width ? parseInt(params.width as string) : undefined; + const height = params.height ? parseInt(params.height as string) : undefined; + // Handle layers parameter - could be string or array + const layers = typeof params.layers === "string" ? params.layers.split(",") : params.layers; + + logger.debug("Retrieving 2D board view"); + const result = await callKicadScript("get_board_2d_view", { + layers, + width, + height, + format, + }); + + if (!result.success) { + logger.error(`Failed to retrieve 2D board view: ${result.errorDetails}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: "Failed to retrieve 2D board view", + details: result.errorDetails, + }), + mimeType: "application/json", + }, + ], + }; + } + + logger.debug("Successfully retrieved 2D board view"); + + if (format === "svg") { + return { + contents: [ + { + uri: uri.href, + text: result.imageData, + mimeType: "image/svg+xml", + }, + ], + }; + } else { + return { + contents: [ + { + uri: uri.href, + blob: result.imageData, + mimeType: format === "jpg" ? "image/jpeg" : "image/png", + }, + ], + }; + } + }, + ); + + // ------------------------------------------------------ + // Board 3D View Resource + // ------------------------------------------------------ + server.resource( + "board_3d_view", + new ResourceTemplate("kicad://board/3d-view/{angle?}", { + list: async () => ({ + resources: [ + { uri: "kicad://board/3d-view/isometric", name: "Isometric View" }, + { uri: "kicad://board/3d-view/top", name: "Top View" }, + { uri: "kicad://board/3d-view/bottom", name: "Bottom View" }, + ], + }), + }), + async (uri, params) => { + const angle = params.angle || "isometric"; + const width = params.width ? parseInt(params.width as string) : undefined; + const height = params.height ? parseInt(params.height as string) : undefined; + + logger.debug(`Retrieving 3D board view from ${angle} angle`); + const result = await callKicadScript("get_board_3d_view", { + width, + height, + angle, + }); + + if (!result.success) { + logger.error(`Failed to retrieve 3D board view: ${result.errorDetails}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: "Failed to retrieve 3D board view", + details: result.errorDetails, + }), + mimeType: "application/json", + }, + ], + }; + } + + logger.debug("Successfully retrieved 3D board view"); + return { + contents: [ + { + uri: uri.href, + blob: result.imageData, + mimeType: "image/png", + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Board Statistics Resource + // ------------------------------------------------------ + server.resource("board_statistics", "kicad://board/statistics", async (uri) => { + logger.debug("Generating board statistics"); + + // Get board info + const boardResult = await callKicadScript("get_board_info", {}); + if (!boardResult.success) { + logger.error(`Failed to retrieve board information: ${boardResult.errorDetails}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: "Failed to generate board statistics", + details: boardResult.errorDetails, + }), + mimeType: "application/json", + }, + ], + }; + } + + // Get component list + const componentsResult = await callKicadScript("get_component_list", {}); + if (!componentsResult.success) { + logger.error(`Failed to retrieve component list: ${componentsResult.errorDetails}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: "Failed to generate board statistics", + details: componentsResult.errorDetails, + }), + mimeType: "application/json", + }, + ], + }; + } + + // Get nets list + const netsResult = await callKicadScript("get_nets_list", {}); + if (!netsResult.success) { + logger.error(`Failed to retrieve nets list: ${netsResult.errorDetails}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: "Failed to generate board statistics", + details: netsResult.errorDetails, + }), + mimeType: "application/json", + }, + ], + }; + } + + // Combine all information into statistics + const statistics = { + board: { + size: boardResult.size, + layers: boardResult.layers?.length || 0, + title: boardResult.title, + }, + components: { + count: componentsResult.components?.length || 0, + types: countComponentTypes(componentsResult.components || []), + }, + nets: { + count: netsResult.nets?.length || 0, + }, + }; + + logger.debug("Successfully generated board statistics"); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(statistics), + mimeType: "application/json", + }, + ], + }; + }); + + logger.info("Board resources registered"); +} + +/** + * Helper function to count component types + */ +function countComponentTypes(components: any[]): Record { + const typeCounts: Record = {}; + + for (const component of components) { + const type = component.value?.split(" ")[0] || "Unknown"; + typeCounts[type] = (typeCounts[type] || 0) + 1; + } + + return typeCounts; +} diff --git a/src/resources/component.ts b/src/resources/component.ts index 947c4a1..05432e1 100644 --- a/src/resources/component.ts +++ b/src/resources/component.ts @@ -5,8 +5,8 @@ * to the LLM, enabling better context-aware assistance. */ -import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { logger } from '../logger.js'; +import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { logger } from "../logger.js"; // Command function type for KiCAD script calls type CommandFunction = (command: string, params: Record) => Promise; @@ -17,43 +17,46 @@ type CommandFunction = (command: string, params: Record) => Pro * @param server MCP server instance * @param callKicadScript Function to call KiCAD script commands */ -export function registerComponentResources(server: McpServer, callKicadScript: CommandFunction): void { - logger.info('Registering component resources'); +export function registerComponentResources( + server: McpServer, + callKicadScript: CommandFunction, +): void { + logger.info("Registering component resources"); // ------------------------------------------------------ // Component List Resource // ------------------------------------------------------ - server.resource( - "component_list", - "kicad://components", - async (uri) => { - logger.debug('Retrieving component list'); - const result = await callKicadScript("get_component_list", {}); + server.resource("component_list", "kicad://components", async (uri) => { + logger.debug("Retrieving component list"); + const result = await callKicadScript("get_component_list", {}); - if (!result.success) { - logger.error(`Failed to retrieve component list: ${result.errorDetails}`); - return { - contents: [{ + if (!result.success) { + logger.error(`Failed to retrieve component list: ${result.errorDetails}`); + return { + contents: [ + { uri: uri.href, text: JSON.stringify({ error: "Failed to retrieve component list", - details: result.errorDetails + details: result.errorDetails, }), - mimeType: "application/json" - }] - }; - } - - logger.debug(`Successfully retrieved ${result.components?.length || 0} components`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify(result), - mimeType: "application/json" - }] + mimeType: "application/json", + }, + ], }; } - ); + + logger.debug(`Successfully retrieved ${result.components?.length || 0} components`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(result), + mimeType: "application/json", + }, + ], + }; + }); // ------------------------------------------------------ // Component Details Resource @@ -61,38 +64,42 @@ export function registerComponentResources(server: McpServer, callKicadScript: C server.resource( "component_details", new ResourceTemplate("kicad://component/{reference}/details", { - list: undefined + list: undefined, }), async (uri, params) => { const { reference } = params; logger.debug(`Retrieving details for component: ${reference}`); const result = await callKicadScript("get_component_properties", { - reference + reference, }); if (!result.success) { logger.error(`Failed to retrieve component details: ${result.errorDetails}`); return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: `Failed to retrieve details for component ${reference}`, - details: result.errorDetails - }), - mimeType: "application/json" - }] + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: `Failed to retrieve details for component ${reference}`, + details: result.errorDetails, + }), + mimeType: "application/json", + }, + ], }; } logger.debug(`Successfully retrieved details for component: ${reference}`); return { - contents: [{ - uri: uri.href, - text: JSON.stringify(result), - mimeType: "application/json" - }] + contents: [ + { + uri: uri.href, + text: JSON.stringify(result), + mimeType: "application/json", + }, + ], }; - } + }, ); // ------------------------------------------------------ @@ -101,109 +108,113 @@ export function registerComponentResources(server: McpServer, callKicadScript: C server.resource( "component_connections", new ResourceTemplate("kicad://component/{reference}/connections", { - list: undefined + list: undefined, }), async (uri, params) => { const { reference } = params; logger.debug(`Retrieving connections for component: ${reference}`); const result = await callKicadScript("get_component_connections", { - reference + reference, }); if (!result.success) { logger.error(`Failed to retrieve component connections: ${result.errorDetails}`); return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: `Failed to retrieve connections for component ${reference}`, - details: result.errorDetails - }), - mimeType: "application/json" - }] + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: `Failed to retrieve connections for component ${reference}`, + details: result.errorDetails, + }), + mimeType: "application/json", + }, + ], }; } logger.debug(`Successfully retrieved connections for component: ${reference}`); return { - contents: [{ - uri: uri.href, - text: JSON.stringify(result), - mimeType: "application/json" - }] + contents: [ + { + uri: uri.href, + text: JSON.stringify(result), + mimeType: "application/json", + }, + ], }; - } + }, ); // ------------------------------------------------------ // Component Placement Resource // ------------------------------------------------------ - server.resource( - "component_placement", - "kicad://components/placement", - async (uri) => { - logger.debug('Retrieving component placement information'); - const result = await callKicadScript("get_component_placement", {}); + server.resource("component_placement", "kicad://components/placement", async (uri) => { + logger.debug("Retrieving component placement information"); + const result = await callKicadScript("get_component_placement", {}); - if (!result.success) { - logger.error(`Failed to retrieve component placement: ${result.errorDetails}`); - return { - contents: [{ + if (!result.success) { + logger.error(`Failed to retrieve component placement: ${result.errorDetails}`); + return { + contents: [ + { uri: uri.href, text: JSON.stringify({ error: "Failed to retrieve component placement information", - details: result.errorDetails + details: result.errorDetails, }), - mimeType: "application/json" - }] - }; - } - - logger.debug('Successfully retrieved component placement information'); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify(result), - mimeType: "application/json" - }] + mimeType: "application/json", + }, + ], }; } - ); + + logger.debug("Successfully retrieved component placement information"); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(result), + mimeType: "application/json", + }, + ], + }; + }); // ------------------------------------------------------ // Component Groups Resource // ------------------------------------------------------ - server.resource( - "component_groups", - "kicad://components/groups", - async (uri) => { - logger.debug('Retrieving component groups'); - const result = await callKicadScript("get_component_groups", {}); + server.resource("component_groups", "kicad://components/groups", async (uri) => { + logger.debug("Retrieving component groups"); + const result = await callKicadScript("get_component_groups", {}); - if (!result.success) { - logger.error(`Failed to retrieve component groups: ${result.errorDetails}`); - return { - contents: [{ + if (!result.success) { + logger.error(`Failed to retrieve component groups: ${result.errorDetails}`); + return { + contents: [ + { uri: uri.href, text: JSON.stringify({ error: "Failed to retrieve component groups", - details: result.errorDetails + details: result.errorDetails, }), - mimeType: "application/json" - }] - }; - } - - logger.debug(`Successfully retrieved ${result.groups?.length || 0} component groups`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify(result), - mimeType: "application/json" - }] + mimeType: "application/json", + }, + ], }; } - ); + + logger.debug(`Successfully retrieved ${result.groups?.length || 0} component groups`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(result), + mimeType: "application/json", + }, + ], + }; + }); // ------------------------------------------------------ // Component Visualization Resource @@ -211,39 +222,43 @@ export function registerComponentResources(server: McpServer, callKicadScript: C server.resource( "component_visualization", new ResourceTemplate("kicad://component/{reference}/visualization", { - list: undefined + list: undefined, }), async (uri, params) => { const { reference } = params; logger.debug(`Generating visualization for component: ${reference}`); const result = await callKicadScript("get_component_visualization", { - reference + reference, }); if (!result.success) { logger.error(`Failed to generate component visualization: ${result.errorDetails}`); return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: `Failed to generate visualization for component ${reference}`, - details: result.errorDetails - }), - mimeType: "application/json" - }] + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: `Failed to generate visualization for component ${reference}`, + details: result.errorDetails, + }), + mimeType: "application/json", + }, + ], }; } logger.debug(`Successfully generated visualization for component: ${reference}`); return { - contents: [{ - uri: uri.href, - blob: result.imageData, // Base64 encoded image data - mimeType: "image/png" - }] + contents: [ + { + uri: uri.href, + blob: result.imageData, // Base64 encoded image data + mimeType: "image/png", + }, + ], }; - } + }, ); - logger.info('Component resources registered'); + logger.info("Component resources registered"); } diff --git a/src/resources/index.ts b/src/resources/index.ts index c025fc3..db94cff 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -1,10 +1,10 @@ -/** - * Resources index for KiCAD MCP server - * - * Exports all resource registration functions - */ - -export { registerProjectResources } from './project.js'; -export { registerBoardResources } from './board.js'; -export { registerComponentResources } from './component.js'; -export { registerLibraryResources } from './library.js'; +/** + * Resources index for KiCAD MCP server + * + * Exports all resource registration functions + */ + +export { registerProjectResources } from "./project.js"; +export { registerBoardResources } from "./board.js"; +export { registerComponentResources } from "./component.js"; +export { registerLibraryResources } from "./library.js"; diff --git a/src/resources/library.ts b/src/resources/library.ts index 1b7acde..7e23dd9 100644 --- a/src/resources/library.ts +++ b/src/resources/library.ts @@ -1,290 +1,323 @@ -/** - * Library resources for KiCAD MCP server - * - * These resources provide information about KiCAD component libraries - * to the LLM, enabling better context-aware assistance. - */ - -import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import { logger } from '../logger.js'; - -// Command function type for KiCAD script calls -type CommandFunction = (command: string, params: Record) => Promise; - -/** - * Register library resources with the MCP server - * - * @param server MCP server instance - * @param callKicadScript Function to call KiCAD script commands - */ -export function registerLibraryResources(server: McpServer, callKicadScript: CommandFunction): void { - logger.info('Registering library resources'); - - // ------------------------------------------------------ - // Component Library Resource - // ------------------------------------------------------ - server.resource( - "component_library", - new ResourceTemplate("kicad://components/{filter?}/{library?}", { - list: async () => ({ - resources: [ - { uri: "kicad://components", name: "All Components" } - ] - }) - }), - async (uri, params) => { - const filter = params.filter || ''; - const library = params.library || ''; - const limit = Number(params.limit) || undefined; - - logger.debug(`Retrieving component library${filter ? ` with filter: ${filter}` : ''}${library ? ` from library: ${library}` : ''}`); - - const result = await callKicadScript("get_component_library", { - filter, - library, - limit - }); - - if (!result.success) { - logger.error(`Failed to retrieve component library: ${result.errorDetails}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: "Failed to retrieve component library", - details: result.errorDetails - }), - mimeType: "application/json" - }] - }; - } - - logger.debug(`Successfully retrieved ${result.components?.length || 0} components from library`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify(result), - mimeType: "application/json" - }] - }; - } - ); - - // ------------------------------------------------------ - // Library List Resource - // ------------------------------------------------------ - server.resource( - "library_list", - "kicad://libraries", - async (uri) => { - logger.debug('Retrieving library list'); - const result = await callKicadScript("get_library_list", {}); - - if (!result.success) { - logger.error(`Failed to retrieve library list: ${result.errorDetails}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: "Failed to retrieve library list", - details: result.errorDetails - }), - mimeType: "application/json" - }] - }; - } - - logger.debug(`Successfully retrieved ${result.libraries?.length || 0} libraries`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify(result), - mimeType: "application/json" - }] - }; - } - ); - - // ------------------------------------------------------ - // Library Component Details Resource - // ------------------------------------------------------ - server.resource( - "library_component_details", - new ResourceTemplate("kicad://library/component/{componentId}/{library?}", { - list: undefined - }), - async (uri, params) => { - const { componentId, library } = params; - logger.debug(`Retrieving details for component: ${componentId}${library ? ` from library: ${library}` : ''}`); - - const result = await callKicadScript("get_component_details", { - componentId, - library - }); - - if (!result.success) { - logger.error(`Failed to retrieve component details: ${result.errorDetails}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: `Failed to retrieve details for component ${componentId}`, - details: result.errorDetails - }), - mimeType: "application/json" - }] - }; - } - - logger.debug(`Successfully retrieved details for component: ${componentId}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify(result), - mimeType: "application/json" - }] - }; - } - ); - - // ------------------------------------------------------ - // Component Footprint Resource - // ------------------------------------------------------ - server.resource( - "component_footprint", - new ResourceTemplate("kicad://footprint/{componentId}/{footprint?}", { - list: undefined - }), - async (uri, params) => { - const { componentId, footprint } = params; - logger.debug(`Retrieving footprint for component: ${componentId}${footprint ? ` (${footprint})` : ''}`); - - const result = await callKicadScript("get_component_footprint", { - componentId, - footprint - }); - - if (!result.success) { - logger.error(`Failed to retrieve component footprint: ${result.errorDetails}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: `Failed to retrieve footprint for component ${componentId}`, - details: result.errorDetails - }), - mimeType: "application/json" - }] - }; - } - - logger.debug(`Successfully retrieved footprint for component: ${componentId}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify(result), - mimeType: "application/json" - }] - }; - } - ); - - // ------------------------------------------------------ - // Component Symbol Resource - // ------------------------------------------------------ - server.resource( - "component_symbol", - new ResourceTemplate("kicad://symbol/{componentId}", { - list: undefined - }), - async (uri, params) => { - const { componentId } = params; - logger.debug(`Retrieving symbol for component: ${componentId}`); - - const result = await callKicadScript("get_component_symbol", { - componentId - }); - - if (!result.success) { - logger.error(`Failed to retrieve component symbol: ${result.errorDetails}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: `Failed to retrieve symbol for component ${componentId}`, - details: result.errorDetails - }), - mimeType: "application/json" - }] - }; - } - - logger.debug(`Successfully retrieved symbol for component: ${componentId}`); - - // If the result includes SVG data, return it as SVG - if (result.svgData) { - return { - contents: [{ - uri: uri.href, - text: result.svgData, - mimeType: "image/svg+xml" - }] - }; - } - - // Otherwise return the JSON result - return { - contents: [{ - uri: uri.href, - text: JSON.stringify(result), - mimeType: "application/json" - }] - }; - } - ); - - // ------------------------------------------------------ - // Component 3D Model Resource - // ------------------------------------------------------ - server.resource( - "component_3d_model", - new ResourceTemplate("kicad://3d-model/{componentId}/{footprint?}", { - list: undefined - }), - async (uri, params) => { - const { componentId, footprint } = params; - logger.debug(`Retrieving 3D model for component: ${componentId}${footprint ? ` (${footprint})` : ''}`); - - const result = await callKicadScript("get_component_3d_model", { - componentId, - footprint - }); - - if (!result.success) { - logger.error(`Failed to retrieve component 3D model: ${result.errorDetails}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: `Failed to retrieve 3D model for component ${componentId}`, - details: result.errorDetails - }), - mimeType: "application/json" - }] - }; - } - - logger.debug(`Successfully retrieved 3D model for component: ${componentId}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify(result), - mimeType: "application/json" - }] - }; - } - ); - - logger.info('Library resources registered'); -} +/** + * Library resources for KiCAD MCP server + * + * These resources provide information about KiCAD component libraries + * to the LLM, enabling better context-aware assistance. + */ + +import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { logger } from "../logger.js"; + +// Command function type for KiCAD script calls +type CommandFunction = (command: string, params: Record) => Promise; + +/** + * Register library resources with the MCP server + * + * @param server MCP server instance + * @param callKicadScript Function to call KiCAD script commands + */ +export function registerLibraryResources( + server: McpServer, + callKicadScript: CommandFunction, +): void { + logger.info("Registering library resources"); + + // ------------------------------------------------------ + // Component Library Resource + // ------------------------------------------------------ + server.resource( + "component_library", + new ResourceTemplate("kicad://components/{filter?}/{library?}", { + list: async () => ({ + resources: [{ uri: "kicad://components", name: "All Components" }], + }), + }), + async (uri, params) => { + const filter = params.filter || ""; + const library = params.library || ""; + const limit = Number(params.limit) || undefined; + + logger.debug( + `Retrieving component library${filter ? ` with filter: ${filter}` : ""}${library ? ` from library: ${library}` : ""}`, + ); + + const result = await callKicadScript("get_component_library", { + filter, + library, + limit, + }); + + if (!result.success) { + logger.error(`Failed to retrieve component library: ${result.errorDetails}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: "Failed to retrieve component library", + details: result.errorDetails, + }), + mimeType: "application/json", + }, + ], + }; + } + + logger.debug( + `Successfully retrieved ${result.components?.length || 0} components from library`, + ); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(result), + mimeType: "application/json", + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Library List Resource + // ------------------------------------------------------ + server.resource("library_list", "kicad://libraries", async (uri) => { + logger.debug("Retrieving library list"); + const result = await callKicadScript("get_library_list", {}); + + if (!result.success) { + logger.error(`Failed to retrieve library list: ${result.errorDetails}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: "Failed to retrieve library list", + details: result.errorDetails, + }), + mimeType: "application/json", + }, + ], + }; + } + + logger.debug(`Successfully retrieved ${result.libraries?.length || 0} libraries`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(result), + mimeType: "application/json", + }, + ], + }; + }); + + // ------------------------------------------------------ + // Library Component Details Resource + // ------------------------------------------------------ + server.resource( + "library_component_details", + new ResourceTemplate("kicad://library/component/{componentId}/{library?}", { + list: undefined, + }), + async (uri, params) => { + const { componentId, library } = params; + logger.debug( + `Retrieving details for component: ${componentId}${library ? ` from library: ${library}` : ""}`, + ); + + const result = await callKicadScript("get_component_details", { + componentId, + library, + }); + + if (!result.success) { + logger.error(`Failed to retrieve component details: ${result.errorDetails}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: `Failed to retrieve details for component ${componentId}`, + details: result.errorDetails, + }), + mimeType: "application/json", + }, + ], + }; + } + + logger.debug(`Successfully retrieved details for component: ${componentId}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(result), + mimeType: "application/json", + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Component Footprint Resource + // ------------------------------------------------------ + server.resource( + "component_footprint", + new ResourceTemplate("kicad://footprint/{componentId}/{footprint?}", { + list: undefined, + }), + async (uri, params) => { + const { componentId, footprint } = params; + logger.debug( + `Retrieving footprint for component: ${componentId}${footprint ? ` (${footprint})` : ""}`, + ); + + const result = await callKicadScript("get_component_footprint", { + componentId, + footprint, + }); + + if (!result.success) { + logger.error(`Failed to retrieve component footprint: ${result.errorDetails}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: `Failed to retrieve footprint for component ${componentId}`, + details: result.errorDetails, + }), + mimeType: "application/json", + }, + ], + }; + } + + logger.debug(`Successfully retrieved footprint for component: ${componentId}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(result), + mimeType: "application/json", + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Component Symbol Resource + // ------------------------------------------------------ + server.resource( + "component_symbol", + new ResourceTemplate("kicad://symbol/{componentId}", { + list: undefined, + }), + async (uri, params) => { + const { componentId } = params; + logger.debug(`Retrieving symbol for component: ${componentId}`); + + const result = await callKicadScript("get_component_symbol", { + componentId, + }); + + if (!result.success) { + logger.error(`Failed to retrieve component symbol: ${result.errorDetails}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: `Failed to retrieve symbol for component ${componentId}`, + details: result.errorDetails, + }), + mimeType: "application/json", + }, + ], + }; + } + + logger.debug(`Successfully retrieved symbol for component: ${componentId}`); + + // If the result includes SVG data, return it as SVG + if (result.svgData) { + return { + contents: [ + { + uri: uri.href, + text: result.svgData, + mimeType: "image/svg+xml", + }, + ], + }; + } + + // Otherwise return the JSON result + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(result), + mimeType: "application/json", + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Component 3D Model Resource + // ------------------------------------------------------ + server.resource( + "component_3d_model", + new ResourceTemplate("kicad://3d-model/{componentId}/{footprint?}", { + list: undefined, + }), + async (uri, params) => { + const { componentId, footprint } = params; + logger.debug( + `Retrieving 3D model for component: ${componentId}${footprint ? ` (${footprint})` : ""}`, + ); + + const result = await callKicadScript("get_component_3d_model", { + componentId, + footprint, + }); + + if (!result.success) { + logger.error(`Failed to retrieve component 3D model: ${result.errorDetails}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: `Failed to retrieve 3D model for component ${componentId}`, + details: result.errorDetails, + }), + mimeType: "application/json", + }, + ], + }; + } + + logger.debug(`Successfully retrieved 3D model for component: ${componentId}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(result), + mimeType: "application/json", + }, + ], + }; + }, + ); + + logger.info("Library resources registered"); +} diff --git a/src/resources/project.ts b/src/resources/project.ts index e2226ce..db3c3d2 100644 --- a/src/resources/project.ts +++ b/src/resources/project.ts @@ -1,260 +1,267 @@ -/** - * Project resources for KiCAD MCP server - * - * These resources provide information about the KiCAD project - * to the LLM, enabling better context-aware assistance. - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { logger } from '../logger.js'; - -// Command function type for KiCAD script calls -type CommandFunction = (command: string, params: Record) => Promise; - -/** - * Register project resources with the MCP server - * - * @param server MCP server instance - * @param callKicadScript Function to call KiCAD script commands - */ -export function registerProjectResources(server: McpServer, callKicadScript: CommandFunction): void { - logger.info('Registering project resources'); - - // ------------------------------------------------------ - // Project Information Resource - // ------------------------------------------------------ - server.resource( - "project_info", - "kicad://project/info", - async (uri) => { - logger.debug('Retrieving project information'); - const result = await callKicadScript("get_project_info", {}); - - if (!result.success) { - logger.error(`Failed to retrieve project information: ${result.errorDetails}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: "Failed to retrieve project information", - details: result.errorDetails - }), - mimeType: "application/json" - }] - }; - } - - logger.debug('Successfully retrieved project information'); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify(result), - mimeType: "application/json" - }] - }; - } - ); - - // ------------------------------------------------------ - // Project Properties Resource - // ------------------------------------------------------ - server.resource( - "project_properties", - "kicad://project/properties", - async (uri) => { - logger.debug('Retrieving project properties'); - const result = await callKicadScript("get_project_properties", {}); - - if (!result.success) { - logger.error(`Failed to retrieve project properties: ${result.errorDetails}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: "Failed to retrieve project properties", - details: result.errorDetails - }), - mimeType: "application/json" - }] - }; - } - - logger.debug('Successfully retrieved project properties'); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify(result), - mimeType: "application/json" - }] - }; - } - ); - - // ------------------------------------------------------ - // Project Files Resource - // ------------------------------------------------------ - server.resource( - "project_files", - "kicad://project/files", - async (uri) => { - logger.debug('Retrieving project files'); - const result = await callKicadScript("get_project_files", {}); - - if (!result.success) { - logger.error(`Failed to retrieve project files: ${result.errorDetails}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: "Failed to retrieve project files", - details: result.errorDetails - }), - mimeType: "application/json" - }] - }; - } - - logger.debug(`Successfully retrieved ${result.files?.length || 0} project files`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify(result), - mimeType: "application/json" - }] - }; - } - ); - - // ------------------------------------------------------ - // Project Status Resource - // ------------------------------------------------------ - server.resource( - "project_status", - "kicad://project/status", - async (uri) => { - logger.debug('Retrieving project status'); - const result = await callKicadScript("get_project_status", {}); - - if (!result.success) { - logger.error(`Failed to retrieve project status: ${result.errorDetails}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: "Failed to retrieve project status", - details: result.errorDetails - }), - mimeType: "application/json" - }] - }; - } - - logger.debug('Successfully retrieved project status'); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify(result), - mimeType: "application/json" - }] - }; - } - ); - - // ------------------------------------------------------ - // Project Summary Resource - // ------------------------------------------------------ - server.resource( - "project_summary", - "kicad://project/summary", - async (uri) => { - logger.debug('Generating project summary'); - - // Get project info - const infoResult = await callKicadScript("get_project_info", {}); - if (!infoResult.success) { - logger.error(`Failed to retrieve project information: ${infoResult.errorDetails}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: "Failed to generate project summary", - details: infoResult.errorDetails - }), - mimeType: "application/json" - }] - }; - } - - // Get board info - const boardResult = await callKicadScript("get_board_info", {}); - if (!boardResult.success) { - logger.error(`Failed to retrieve board information: ${boardResult.errorDetails}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: "Failed to generate project summary", - details: boardResult.errorDetails - }), - mimeType: "application/json" - }] - }; - } - - // Get component list - const componentsResult = await callKicadScript("get_component_list", {}); - if (!componentsResult.success) { - logger.error(`Failed to retrieve component list: ${componentsResult.errorDetails}`); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - error: "Failed to generate project summary", - details: componentsResult.errorDetails - }), - mimeType: "application/json" - }] - }; - } - - // Combine all information into a summary - const summary = { - project: infoResult.project, - board: { - size: boardResult.size, - layers: boardResult.layers?.length || 0, - title: boardResult.title - }, - components: { - count: componentsResult.components?.length || 0, - types: countComponentTypes(componentsResult.components || []) - } - }; - - logger.debug('Successfully generated project summary'); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify(summary), - mimeType: "application/json" - }] - }; - } - ); - - logger.info('Project resources registered'); -} - -/** - * Helper function to count component types - */ -function countComponentTypes(components: any[]): Record { - const typeCounts: Record = {}; - - for (const component of components) { - const type = component.value?.split(' ')[0] || 'Unknown'; - typeCounts[type] = (typeCounts[type] || 0) + 1; - } - - return typeCounts; -} +/** + * Project resources for KiCAD MCP server + * + * These resources provide information about the KiCAD project + * to the LLM, enabling better context-aware assistance. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { logger } from "../logger.js"; + +// Command function type for KiCAD script calls +type CommandFunction = (command: string, params: Record) => Promise; + +/** + * Register project resources with the MCP server + * + * @param server MCP server instance + * @param callKicadScript Function to call KiCAD script commands + */ +export function registerProjectResources( + server: McpServer, + callKicadScript: CommandFunction, +): void { + logger.info("Registering project resources"); + + // ------------------------------------------------------ + // Project Information Resource + // ------------------------------------------------------ + server.resource("project_info", "kicad://project/info", async (uri) => { + logger.debug("Retrieving project information"); + const result = await callKicadScript("get_project_info", {}); + + if (!result.success) { + logger.error(`Failed to retrieve project information: ${result.errorDetails}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: "Failed to retrieve project information", + details: result.errorDetails, + }), + mimeType: "application/json", + }, + ], + }; + } + + logger.debug("Successfully retrieved project information"); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(result), + mimeType: "application/json", + }, + ], + }; + }); + + // ------------------------------------------------------ + // Project Properties Resource + // ------------------------------------------------------ + server.resource("project_properties", "kicad://project/properties", async (uri) => { + logger.debug("Retrieving project properties"); + const result = await callKicadScript("get_project_properties", {}); + + if (!result.success) { + logger.error(`Failed to retrieve project properties: ${result.errorDetails}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: "Failed to retrieve project properties", + details: result.errorDetails, + }), + mimeType: "application/json", + }, + ], + }; + } + + logger.debug("Successfully retrieved project properties"); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(result), + mimeType: "application/json", + }, + ], + }; + }); + + // ------------------------------------------------------ + // Project Files Resource + // ------------------------------------------------------ + server.resource("project_files", "kicad://project/files", async (uri) => { + logger.debug("Retrieving project files"); + const result = await callKicadScript("get_project_files", {}); + + if (!result.success) { + logger.error(`Failed to retrieve project files: ${result.errorDetails}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: "Failed to retrieve project files", + details: result.errorDetails, + }), + mimeType: "application/json", + }, + ], + }; + } + + logger.debug(`Successfully retrieved ${result.files?.length || 0} project files`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(result), + mimeType: "application/json", + }, + ], + }; + }); + + // ------------------------------------------------------ + // Project Status Resource + // ------------------------------------------------------ + server.resource("project_status", "kicad://project/status", async (uri) => { + logger.debug("Retrieving project status"); + const result = await callKicadScript("get_project_status", {}); + + if (!result.success) { + logger.error(`Failed to retrieve project status: ${result.errorDetails}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: "Failed to retrieve project status", + details: result.errorDetails, + }), + mimeType: "application/json", + }, + ], + }; + } + + logger.debug("Successfully retrieved project status"); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(result), + mimeType: "application/json", + }, + ], + }; + }); + + // ------------------------------------------------------ + // Project Summary Resource + // ------------------------------------------------------ + server.resource("project_summary", "kicad://project/summary", async (uri) => { + logger.debug("Generating project summary"); + + // Get project info + const infoResult = await callKicadScript("get_project_info", {}); + if (!infoResult.success) { + logger.error(`Failed to retrieve project information: ${infoResult.errorDetails}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: "Failed to generate project summary", + details: infoResult.errorDetails, + }), + mimeType: "application/json", + }, + ], + }; + } + + // Get board info + const boardResult = await callKicadScript("get_board_info", {}); + if (!boardResult.success) { + logger.error(`Failed to retrieve board information: ${boardResult.errorDetails}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: "Failed to generate project summary", + details: boardResult.errorDetails, + }), + mimeType: "application/json", + }, + ], + }; + } + + // Get component list + const componentsResult = await callKicadScript("get_component_list", {}); + if (!componentsResult.success) { + logger.error(`Failed to retrieve component list: ${componentsResult.errorDetails}`); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ + error: "Failed to generate project summary", + details: componentsResult.errorDetails, + }), + mimeType: "application/json", + }, + ], + }; + } + + // Combine all information into a summary + const summary = { + project: infoResult.project, + board: { + size: boardResult.size, + layers: boardResult.layers?.length || 0, + title: boardResult.title, + }, + components: { + count: componentsResult.components?.length || 0, + types: countComponentTypes(componentsResult.components || []), + }, + }; + + logger.debug("Successfully generated project summary"); + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(summary), + mimeType: "application/json", + }, + ], + }; + }); + + logger.info("Project resources registered"); +} + +/** + * Helper function to count component types + */ +function countComponentTypes(components: any[]): Record { + const typeCounts: Record = {}; + + for (const component of components) { + const type = component.value?.split(" ")[0] || "Unknown"; + typeCounts[type] = (typeCounts[type] || 0) + 1; + } + + return typeCounts; +} diff --git a/src/server.ts b/src/server.ts index 4a24c4f..6abd742 100644 --- a/src/server.ts +++ b/src/server.ts @@ -54,18 +54,8 @@ function findPythonExecutable(scriptPath: string): string { // Check for virtual environment const venvPaths = [ - join( - projectRoot, - "venv", - isWindows ? "Scripts" : "bin", - isWindows ? "python.exe" : "python", - ), - join( - projectRoot, - ".venv", - isWindows ? "Scripts" : "bin", - isWindows ? "python.exe" : "python", - ), + join(projectRoot, "venv", isWindows ? "Scripts" : "bin", isWindows ? "python.exe" : "python"), + join(projectRoot, ".venv", isWindows ? "Scripts" : "bin", isWindows ? "python.exe" : "python"), ]; for (const venvPath of venvPaths) { @@ -77,9 +67,7 @@ function findPythonExecutable(scriptPath: string): string { // Allow override via KICAD_PYTHON environment variable (any platform) if (process.env.KICAD_PYTHON) { - logger.info( - `Using KICAD_PYTHON environment variable: ${process.env.KICAD_PYTHON}`, - ); + logger.info(`Using KICAD_PYTHON environment variable: ${process.env.KICAD_PYTHON}`); return process.env.KICAD_PYTHON; } @@ -123,9 +111,7 @@ function findPythonExecutable(scriptPath: string): string { for (const path of homebrewPaths) { if (existsSync(path)) { - logger.info( - `Found Homebrew Python at: ${path} (ensure pcbnew is importable)`, - ); + logger.info(`Found Homebrew Python at: ${path} (ensure pcbnew is importable)`); return path; } } @@ -196,19 +182,14 @@ export class KiCADMcpServer { * @param kicadScriptPath Path to the Python KiCAD interface script * @param logLevel Log level for the server */ - constructor( - kicadScriptPath: string, - logLevel: "error" | "warn" | "info" | "debug" = "info", - ) { + constructor(kicadScriptPath: string, logLevel: "error" | "warn" | "info" | "debug" = "info") { // Set up the logger logger.setLogLevel(logLevel); // Check if KiCAD script exists this.kicadScriptPath = kicadScriptPath; if (!existsSync(this.kicadScriptPath)) { - throw new Error( - `KiCAD interface script not found: ${this.kicadScriptPath}`, - ); + throw new Error(`KiCAD interface script not found: ${this.kicadScriptPath}`); } // Initialize the MCP server @@ -267,9 +248,7 @@ export class KiCADMcpServer { registerFootprintPrompts(this.server); logger.info("All KiCAD tools, resources, and prompts registered"); - logger.info( - "Router pattern enabled: 4 router tools + direct tools for discovery", - ); + logger.info("Router pattern enabled: 4 router tools + direct tools for discovery"); } /** @@ -277,15 +256,12 @@ export class KiCADMcpServer { */ private async validatePrerequisites(pythonExe: string): Promise { const isWindows = process.platform === "win32"; - const isLinux = - process.platform !== "win32" && process.platform !== "darwin"; + const isLinux = process.platform !== "win32" && process.platform !== "darwin"; const errors: string[] = []; // Check if Python executable exists (for absolute paths) or is executable (for commands) const isAbsolutePath = - pythonExe.startsWith("/") || - pythonExe.startsWith("C:") || - pythonExe.startsWith("\\"); + pythonExe.startsWith("/") || pythonExe.startsWith("C:") || pythonExe.startsWith("\\"); if (isAbsolutePath) { // Absolute path: use existsSync @@ -293,16 +269,10 @@ export class KiCADMcpServer { errors.push(`Python executable not found: ${pythonExe}`); if (isWindows) { - errors.push( - "Windows: Install KiCAD 9.0+ from https://www.kicad.org/download/windows/", - ); - errors.push( - "Or run: .\\setup-windows.ps1 for automatic configuration", - ); + errors.push("Windows: Install KiCAD 9.0+ from https://www.kicad.org/download/windows/"); + errors.push("Or run: .\\setup-windows.ps1 for automatic configuration"); } else if (isLinux) { - errors.push( - "Linux: Install KiCAD 9.0+ or set KICAD_PYTHON environment variable", - ); + errors.push("Linux: Install KiCAD 9.0+ or set KICAD_PYTHON environment variable"); errors.push("Set KICAD_PYTHON to specify a custom Python path"); } } @@ -334,20 +304,14 @@ export class KiCADMcpServer { } catch (error: any) { errors.push(`Python executable not found in PATH: ${pythonExe}`); errors.push(`Error: ${error.message}`); - errors.push( - "Set KICAD_PYTHON environment variable to specify full path", - ); + errors.push("Set KICAD_PYTHON environment variable to specify full path"); if (isLinux) { errors.push(""); errors.push("Linux troubleshooting:"); errors.push("1. Check if python3 is installed: which python3"); - errors.push( - "2. Install KiCAD: sudo apt install kicad (Ubuntu/Debian)", - ); - errors.push( - "3. Set KICAD_PYTHON=/usr/bin/python3 in your MCP config", - ); + errors.push("2. Install KiCAD: sudo apt install kicad (Ubuntu/Debian)"); + errors.push("3. Set KICAD_PYTHON=/usr/bin/python3 in your MCP config"); } } } @@ -358,11 +322,7 @@ export class KiCADMcpServer { } // Check if dist/index.js exists (if running from compiled code) - const distPath = join( - dirname(dirname(this.kicadScriptPath)), - "dist", - "index.js", - ); + const distPath = join(dirname(dirname(this.kicadScriptPath)), "dist", "index.js"); if (!existsSync(distPath)) { errors.push("Project not built. Run: npm run build"); } @@ -458,9 +418,7 @@ export class KiCADMcpServer { logger.info("Starting KiCAD MCP server..."); // Start the Python process for KiCAD scripting - logger.info( - `Starting Python process with script: ${this.kicadScriptPath}`, - ); + logger.info(`Starting Python process with script: ${this.kicadScriptPath}`); const pythonExe = findPythonExecutable(this.kicadScriptPath); logger.info(`Using Python executable: ${pythonExe}`); @@ -468,25 +426,20 @@ export class KiCADMcpServer { // Validate prerequisites const isValid = await this.validatePrerequisites(pythonExe); if (!isValid) { - throw new Error( - "Prerequisites validation failed. See logs above for details.", - ); + throw new Error("Prerequisites validation failed. See logs above for details."); } this.pythonProcess = spawn(pythonExe, [this.kicadScriptPath], { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, PYTHONPATH: - process.env.PYTHONPATH || - "C:/Program Files/KiCad/9.0/lib/python3/dist-packages", + process.env.PYTHONPATH || "C:/Program Files/KiCad/9.0/lib/python3/dist-packages", }, }); // Listen for process exit this.pythonProcess.on("exit", (code, signal) => { - logger.warn( - `Python process exited with code ${code} and signal ${signal}`, - ); + logger.warn(`Python process exited with code ${code} and signal ${signal}`); this.pythonProcess = null; }); @@ -563,17 +516,10 @@ export class KiCADMcpServer { // Determine timeout based on command type // DRC and export operations need longer timeouts for large boards let commandTimeout = 30000; // Default 30 seconds - const longRunningCommands = [ - "run_drc", - "export_gerber", - "export_pdf", - "export_3d", - ]; + const longRunningCommands = ["run_drc", "export_gerber", "export_pdf", "export_3d"]; if (longRunningCommands.includes(command)) { commandTimeout = 600000; // 10 minutes for long operations - logger.info( - `Using extended timeout (${commandTimeout / 1000}s) for command: ${command}`, - ); + logger.info(`Using extended timeout (${commandTimeout / 1000}s) for command: ${command}`); } // Add request to queue with timeout info @@ -678,12 +624,8 @@ export class KiCADMcpServer { // Set a timeout (use command-specific timeout or default) const timeoutDuration = request.timeout || 30000; const timeoutHandle = setTimeout(() => { - logger.error( - `Command timeout after ${timeoutDuration / 1000}s: ${request.command}`, - ); - logger.error( - `Buffer contents: ${this.responseBuffer.substring(0, 200)}...`, - ); + logger.error(`Command timeout after ${timeoutDuration / 1000}s: ${request.command}`); + logger.error(`Buffer contents: ${this.responseBuffer.substring(0, 200)}...`); // Clear state this.responseBuffer = ""; @@ -691,11 +633,7 @@ export class KiCADMcpServer { this.processingRequest = false; // Reject the promise - reject( - new Error( - `Command timeout after ${timeoutDuration / 1000}s: ${request.command}`, - ), - ); + reject(new Error(`Command timeout after ${timeoutDuration / 1000}s: ${request.command}`)); // Process next request setTimeout(() => this.processNextRequest(), 0); diff --git a/src/tools/board.ts b/src/tools/board.ts index 975781d..65e6631 100644 --- a/src/tools/board.ts +++ b/src/tools/board.ts @@ -1,384 +1,431 @@ -/** - * Board management tools for KiCAD MCP server - * - * These tools handle board setup, layer management, and board properties - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import { logger } from '../logger.js'; - -// Command function type for KiCAD script calls -type CommandFunction = (command: string, params: Record) => Promise; - -/** - * Register board management tools with the MCP server - * - * @param server MCP server instance - * @param callKicadScript Function to call KiCAD script commands - */ -export function registerBoardTools(server: McpServer, callKicadScript: CommandFunction): void { - logger.info('Registering board management tools'); - - // ------------------------------------------------------ - // Set Board Size Tool - // ------------------------------------------------------ - server.tool( - "set_board_size", - { - width: z.number().describe("Board width"), - height: z.number().describe("Board height"), - unit: z.enum(["mm", "inch"]).describe("Unit of measurement") - }, - async ({ width, height, unit }) => { - logger.debug(`Setting board size to ${width}x${height} ${unit}`); - const result = await callKicadScript("set_board_size", { - width, - height, - unit - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Add Layer Tool - // ------------------------------------------------------ - server.tool( - "add_layer", - { - name: z.string().describe("Layer name"), - type: z.enum([ - "copper", "technical", "user", "signal" - ]).describe("Layer type"), - position: z.enum([ - "top", "bottom", "inner" - ]).describe("Layer position"), - number: z.number().optional().describe("Layer number (for inner layers)") - }, - async ({ name, type, position, number }) => { - logger.debug(`Adding ${type} layer: ${name}`); - const result = await callKicadScript("add_layer", { - name, - type, - position, - number - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Set Active Layer Tool - // ------------------------------------------------------ - server.tool( - "set_active_layer", - { - layer: z.string().describe("Layer name to set as active") - }, - async ({ layer }) => { - logger.debug(`Setting active layer to: ${layer}`); - const result = await callKicadScript("set_active_layer", { layer }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Get Board Info Tool - // ------------------------------------------------------ - server.tool( - "get_board_info", - {}, - async () => { - logger.debug('Getting board information'); - const result = await callKicadScript("get_board_info", {}); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Get Layer List Tool - // ------------------------------------------------------ - server.tool( - "get_layer_list", - {}, - async () => { - logger.debug('Getting layer list'); - const result = await callKicadScript("get_layer_list", {}); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Add Board Outline Tool - // ------------------------------------------------------ - server.tool( - "add_board_outline", - { - shape: z.enum(["rectangle", "circle", "polygon", "rounded_rectangle"]).describe("Shape of the outline"), - params: z.object({ - // For rectangle / rounded_rectangle - width: z.number().optional().describe("Width of rectangle"), - height: z.number().optional().describe("Height of rectangle"), - cornerRadius: z.number().optional().describe("Corner radius for rounded_rectangle (mm)"), - // For circle - radius: z.number().optional().describe("Radius of circle"), - // For polygon - points: z.array( - z.object({ - x: z.number().describe("X coordinate"), - y: z.number().describe("Y coordinate") - }) - ).optional().describe("Points of polygon"), - // Position: top-left corner for rectangles/rounded_rectangle, center for circle - x: z.number().describe("X coordinate of top-left corner for rectangles (default: 0)"), - y: z.number().describe("Y coordinate of top-left corner for rectangles (default: 0)"), - unit: z.enum(["mm", "inch"]).describe("Unit of measurement") - }).describe("Parameters for the outline shape") - }, - async ({ shape, params }) => { - logger.debug(`Adding ${shape} board outline`); - // Pass x/y as-is to Python; outline.py treats them as top-left corner - // and computes the center internally (center = x + width/2, y + height/2). - const result = await callKicadScript("add_board_outline", { - shape, - ...params - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Add Mounting Hole Tool - // ------------------------------------------------------ - server.tool( - "add_mounting_hole", - { - position: z.object({ - x: z.number().describe("X coordinate"), - y: z.number().describe("Y coordinate"), - unit: z.enum(["mm", "inch"]).describe("Unit of measurement") - }).describe("Position of the mounting hole"), - diameter: z.number().describe("Diameter of the hole"), - padDiameter: z.number().optional().describe("Optional diameter of the pad around the hole") - }, - async ({ position, diameter, padDiameter }) => { - logger.debug(`Adding mounting hole at (${position.x},${position.y}) ${position.unit}`); - const result = await callKicadScript("add_mounting_hole", { - position, - diameter, - padDiameter - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Add Text Tool - // ------------------------------------------------------ - server.tool( - "add_board_text", - { - text: z.string().describe("Text content"), - position: z.object({ - x: z.number().describe("X coordinate"), - y: z.number().describe("Y coordinate"), - unit: z.enum(["mm", "inch"]).describe("Unit of measurement") - }).describe("Position of the text"), - layer: z.string().describe("Layer to place the text on"), - size: z.number().describe("Text size"), - thickness: z.number().optional().describe("Line thickness"), - rotation: z.number().optional().describe("Rotation angle in degrees"), - style: z.enum(["normal", "italic", "bold"]).optional().describe("Text style") - }, - async ({ text, position, layer, size, thickness, rotation, style }) => { - logger.debug(`Adding text "${text}" at (${position.x},${position.y}) ${position.unit}`); - const result = await callKicadScript("add_board_text", { - text, - position, - layer, - size, - thickness, - rotation, - style - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Add Zone Tool - // ------------------------------------------------------ - server.tool( - "add_zone", - { - layer: z.string().describe("Layer for the zone"), - net: z.string().describe("Net name for the zone"), - points: z.array( - z.object({ - x: z.number().describe("X coordinate"), - y: z.number().describe("Y coordinate") - }) - ).describe("Points defining the zone outline"), - unit: z.enum(["mm", "inch"]).describe("Unit of measurement"), - clearance: z.number().optional().describe("Clearance value"), - minWidth: z.number().optional().describe("Minimum width"), - padConnection: z.enum(["thermal", "solid", "none"]).optional().describe("Pad connection type") - }, - async ({ layer, net, points, unit, clearance, minWidth, padConnection }) => { - logger.debug(`Adding zone on layer ${layer} for net ${net}`); - const result = await callKicadScript("add_zone", { - layer, - net, - points, - unit, - clearance, - minWidth, - padConnection - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Get Board Extents Tool - // ------------------------------------------------------ - server.tool( - "get_board_extents", - { - unit: z.enum(["mm", "inch"]).optional().describe("Unit of measurement for the result") - }, - async ({ unit }) => { - logger.debug('Getting board extents'); - const result = await callKicadScript("get_board_extents", { unit }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Get Board 2D View Tool - // ------------------------------------------------------ - server.tool( - "get_board_2d_view", - { - layers: z.array(z.string()).optional().describe("Optional array of layer names to include"), - width: z.number().optional().describe("Optional width of the image in pixels"), - height: z.number().optional().describe("Optional height of the image in pixels"), - format: z.enum(["png", "jpg", "svg"]).optional().describe("Image format") - }, - async ({ layers, width, height, format }) => { - logger.debug('Getting 2D board view'); - const result = await callKicadScript("get_board_2d_view", { - layers, - width, - height, - format - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - logger.info('Board management tools registered'); - - // Import SVG logo onto PCB layer (silkscreen) - server.tool( - "import_svg_logo", - "Imports an SVG file as filled graphic polygons onto a KiCAD PCB layer (default F.SilkS / front silkscreen). Curves are linearised automatically. Ideal for placing a company or project logo on the board.", - { - pcbPath: z.string().describe("Path to the .kicad_pcb file"), - svgPath: z.string().describe("Path to the SVG logo file"), - x: z.number().describe("X position of the logo top-left corner in mm"), - y: z.number().describe("Y position of the logo top-left corner in mm"), - width: z.number().describe("Target width of the logo in mm (height is scaled to preserve aspect ratio)"), - layer: z.string().optional().describe("PCB layer name, e.g. F.SilkS or B.SilkS (default: F.SilkS)"), - strokeWidth: z.number().optional().describe("Outline stroke width in mm (0 = no outline, default 0)"), - filled: z.boolean().optional().describe("Fill polygons with solid colour (default true)"), - }, - async (args: { pcbPath: string; svgPath: string; x: number; y: number; width: number; layer?: string; strokeWidth?: number; filled?: boolean }) => { - const result = await callKicadScript("import_svg_logo", args); - if (result.success) { - return { - content: [{ - type: "text", - text: [ - result.message, - `Polygons: ${result.polygon_count}`, - `Size: ${result.logo_width_mm?.toFixed(2)} × ${result.logo_height_mm?.toFixed(2)} mm`, - `Layer: ${result.layer}`, - ].join("\n"), - }], - }; - } else { - return { - content: [{ type: "text", text: `SVG import failed: ${result.message || "Unknown error"}` }], - }; - } - }, - ); -} +/** + * Board management tools for KiCAD MCP server + * + * These tools handle board setup, layer management, and board properties + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { logger } from "../logger.js"; + +// Command function type for KiCAD script calls +type CommandFunction = (command: string, params: Record) => Promise; + +/** + * Register board management tools with the MCP server + * + * @param server MCP server instance + * @param callKicadScript Function to call KiCAD script commands + */ +export function registerBoardTools(server: McpServer, callKicadScript: CommandFunction): void { + logger.info("Registering board management tools"); + + // ------------------------------------------------------ + // Set Board Size Tool + // ------------------------------------------------------ + server.tool( + "set_board_size", + { + width: z.number().describe("Board width"), + height: z.number().describe("Board height"), + unit: z.enum(["mm", "inch"]).describe("Unit of measurement"), + }, + async ({ width, height, unit }) => { + logger.debug(`Setting board size to ${width}x${height} ${unit}`); + const result = await callKicadScript("set_board_size", { + width, + height, + unit, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Add Layer Tool + // ------------------------------------------------------ + server.tool( + "add_layer", + { + name: z.string().describe("Layer name"), + type: z.enum(["copper", "technical", "user", "signal"]).describe("Layer type"), + position: z.enum(["top", "bottom", "inner"]).describe("Layer position"), + number: z.number().optional().describe("Layer number (for inner layers)"), + }, + async ({ name, type, position, number }) => { + logger.debug(`Adding ${type} layer: ${name}`); + const result = await callKicadScript("add_layer", { + name, + type, + position, + number, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Set Active Layer Tool + // ------------------------------------------------------ + server.tool( + "set_active_layer", + { + layer: z.string().describe("Layer name to set as active"), + }, + async ({ layer }) => { + logger.debug(`Setting active layer to: ${layer}`); + const result = await callKicadScript("set_active_layer", { layer }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Get Board Info Tool + // ------------------------------------------------------ + server.tool("get_board_info", {}, async () => { + logger.debug("Getting board information"); + const result = await callKicadScript("get_board_info", {}); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }); + + // ------------------------------------------------------ + // Get Layer List Tool + // ------------------------------------------------------ + server.tool("get_layer_list", {}, async () => { + logger.debug("Getting layer list"); + const result = await callKicadScript("get_layer_list", {}); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }); + + // ------------------------------------------------------ + // Add Board Outline Tool + // ------------------------------------------------------ + server.tool( + "add_board_outline", + { + shape: z + .enum(["rectangle", "circle", "polygon", "rounded_rectangle"]) + .describe("Shape of the outline"), + params: z + .object({ + // For rectangle / rounded_rectangle + width: z.number().optional().describe("Width of rectangle"), + height: z.number().optional().describe("Height of rectangle"), + cornerRadius: z.number().optional().describe("Corner radius for rounded_rectangle (mm)"), + // For circle + radius: z.number().optional().describe("Radius of circle"), + // For polygon + points: z + .array( + z.object({ + x: z.number().describe("X coordinate"), + y: z.number().describe("Y coordinate"), + }), + ) + .optional() + .describe("Points of polygon"), + // Position: top-left corner for rectangles/rounded_rectangle, center for circle + x: z.number().describe("X coordinate of top-left corner for rectangles (default: 0)"), + y: z.number().describe("Y coordinate of top-left corner for rectangles (default: 0)"), + unit: z.enum(["mm", "inch"]).describe("Unit of measurement"), + }) + .describe("Parameters for the outline shape"), + }, + async ({ shape, params }) => { + logger.debug(`Adding ${shape} board outline`); + // Pass x/y as-is to Python; outline.py treats them as top-left corner + // and computes the center internally (center = x + width/2, y + height/2). + const result = await callKicadScript("add_board_outline", { + shape, + ...params, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Add Mounting Hole Tool + // ------------------------------------------------------ + server.tool( + "add_mounting_hole", + { + position: z + .object({ + x: z.number().describe("X coordinate"), + y: z.number().describe("Y coordinate"), + unit: z.enum(["mm", "inch"]).describe("Unit of measurement"), + }) + .describe("Position of the mounting hole"), + diameter: z.number().describe("Diameter of the hole"), + padDiameter: z.number().optional().describe("Optional diameter of the pad around the hole"), + }, + async ({ position, diameter, padDiameter }) => { + logger.debug(`Adding mounting hole at (${position.x},${position.y}) ${position.unit}`); + const result = await callKicadScript("add_mounting_hole", { + position, + diameter, + padDiameter, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Add Text Tool + // ------------------------------------------------------ + server.tool( + "add_board_text", + { + text: z.string().describe("Text content"), + position: z + .object({ + x: z.number().describe("X coordinate"), + y: z.number().describe("Y coordinate"), + unit: z.enum(["mm", "inch"]).describe("Unit of measurement"), + }) + .describe("Position of the text"), + layer: z.string().describe("Layer to place the text on"), + size: z.number().describe("Text size"), + thickness: z.number().optional().describe("Line thickness"), + rotation: z.number().optional().describe("Rotation angle in degrees"), + style: z.enum(["normal", "italic", "bold"]).optional().describe("Text style"), + }, + async ({ text, position, layer, size, thickness, rotation, style }) => { + logger.debug(`Adding text "${text}" at (${position.x},${position.y}) ${position.unit}`); + const result = await callKicadScript("add_board_text", { + text, + position, + layer, + size, + thickness, + rotation, + style, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Add Zone Tool + // ------------------------------------------------------ + server.tool( + "add_zone", + { + layer: z.string().describe("Layer for the zone"), + net: z.string().describe("Net name for the zone"), + points: z + .array( + z.object({ + x: z.number().describe("X coordinate"), + y: z.number().describe("Y coordinate"), + }), + ) + .describe("Points defining the zone outline"), + unit: z.enum(["mm", "inch"]).describe("Unit of measurement"), + clearance: z.number().optional().describe("Clearance value"), + minWidth: z.number().optional().describe("Minimum width"), + padConnection: z + .enum(["thermal", "solid", "none"]) + .optional() + .describe("Pad connection type"), + }, + async ({ layer, net, points, unit, clearance, minWidth, padConnection }) => { + logger.debug(`Adding zone on layer ${layer} for net ${net}`); + const result = await callKicadScript("add_zone", { + layer, + net, + points, + unit, + clearance, + minWidth, + padConnection, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Get Board Extents Tool + // ------------------------------------------------------ + server.tool( + "get_board_extents", + { + unit: z.enum(["mm", "inch"]).optional().describe("Unit of measurement for the result"), + }, + async ({ unit }) => { + logger.debug("Getting board extents"); + const result = await callKicadScript("get_board_extents", { unit }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Get Board 2D View Tool + // ------------------------------------------------------ + server.tool( + "get_board_2d_view", + { + layers: z.array(z.string()).optional().describe("Optional array of layer names to include"), + width: z.number().optional().describe("Optional width of the image in pixels"), + height: z.number().optional().describe("Optional height of the image in pixels"), + format: z.enum(["png", "jpg", "svg"]).optional().describe("Image format"), + }, + async ({ layers, width, height, format }) => { + logger.debug("Getting 2D board view"); + const result = await callKicadScript("get_board_2d_view", { + layers, + width, + height, + format, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + logger.info("Board management tools registered"); + + // Import SVG logo onto PCB layer (silkscreen) + server.tool( + "import_svg_logo", + "Imports an SVG file as filled graphic polygons onto a KiCAD PCB layer (default F.SilkS / front silkscreen). Curves are linearised automatically. Ideal for placing a company or project logo on the board.", + { + pcbPath: z.string().describe("Path to the .kicad_pcb file"), + svgPath: z.string().describe("Path to the SVG logo file"), + x: z.number().describe("X position of the logo top-left corner in mm"), + y: z.number().describe("Y position of the logo top-left corner in mm"), + width: z + .number() + .describe("Target width of the logo in mm (height is scaled to preserve aspect ratio)"), + layer: z + .string() + .optional() + .describe("PCB layer name, e.g. F.SilkS or B.SilkS (default: F.SilkS)"), + strokeWidth: z + .number() + .optional() + .describe("Outline stroke width in mm (0 = no outline, default 0)"), + filled: z.boolean().optional().describe("Fill polygons with solid colour (default true)"), + }, + async (args: { + pcbPath: string; + svgPath: string; + x: number; + y: number; + width: number; + layer?: string; + strokeWidth?: number; + filled?: boolean; + }) => { + const result = await callKicadScript("import_svg_logo", args); + if (result.success) { + return { + content: [ + { + type: "text", + text: [ + result.message, + `Polygons: ${result.polygon_count}`, + `Size: ${result.logo_width_mm?.toFixed(2)} × ${result.logo_height_mm?.toFixed(2)} mm`, + `Layer: ${result.layer}`, + ].join("\n"), + }, + ], + }; + } else { + return { + content: [ + { type: "text", text: `SVG import failed: ${result.message || "Unknown error"}` }, + ], + }; + } + }, + ); +} diff --git a/src/tools/component.ts b/src/tools/component.ts index e4da70f..6e7a699 100644 --- a/src/tools/component.ts +++ b/src/tools/component.ts @@ -7,10 +7,7 @@ import { z } from "zod"; import { logger } from "../logger.js"; // Command function type for KiCAD script calls -type CommandFunction = ( - command: string, - params: Record, -) => Promise; +type CommandFunction = (command: string, params: Record) => Promise; /** * Register component management tools with the MCP server @@ -18,10 +15,7 @@ type CommandFunction = ( * @param server MCP server instance * @param callKicadScript Function to call KiCAD script commands */ -export function registerComponentTools( - server: McpServer, - callKicadScript: CommandFunction, -): void { +export function registerComponentTools(server: McpServer, callKicadScript: CommandFunction): void { logger.info("Registering component management tools"); // ------------------------------------------------------ @@ -40,38 +34,19 @@ export function registerComponentTools( unit: z.enum(["mm", "inch"]).describe("Unit of measurement"), }) .describe("Position coordinates and unit"), - reference: z - .string() - .optional() - .describe("Optional desired reference (e.g., 'R5')"), - value: z - .string() - .optional() - .describe("Optional component value (e.g., '10k')"), - footprint: z - .string() - .optional() - .describe("Optional specific footprint name"), + reference: z.string().optional().describe("Optional desired reference (e.g., 'R5')"), + value: z.string().optional().describe("Optional component value (e.g., '10k')"), + footprint: z.string().optional().describe("Optional specific footprint name"), rotation: z.number().optional().describe("Optional rotation in degrees"), - layer: z - .string() - .optional() - .describe("Optional layer (e.g., 'F.Cu', 'B.SilkS')"), + layer: z.string().optional().describe("Optional layer (e.g., 'F.Cu', 'B.SilkS')"), boardPath: z .string() .optional() - .describe("Path to the .kicad_pcb file – required when using project-local footprint libraries"), + .describe( + "Path to the .kicad_pcb file – required when using project-local footprint libraries", + ), }, - async ({ - componentId, - position, - reference, - value, - footprint, - rotation, - layer, - boardPath, - }) => { + async ({ componentId, position, reference, value, footprint, rotation, layer, boardPath }) => { logger.debug( `Placing component: ${componentId} at ${position.x},${position.y} ${position.unit}`, ); @@ -103,9 +78,7 @@ export function registerComponentTools( server.tool( "move_component", { - reference: z - .string() - .describe("Reference designator of the component (e.g., 'R5')"), + reference: z.string().describe("Reference designator of the component (e.g., 'R5')"), position: z .object({ x: z.number().describe("X coordinate"), @@ -113,10 +86,7 @@ export function registerComponentTools( unit: z.enum(["mm", "inch"]).describe("Unit of measurement"), }) .describe("New position coordinates and unit"), - rotation: z - .number() - .optional() - .describe("Optional new rotation in degrees"), + rotation: z.number().optional().describe("Optional new rotation in degrees"), layer: z .string() .optional() @@ -150,12 +120,8 @@ export function registerComponentTools( server.tool( "rotate_component", { - reference: z - .string() - .describe("Reference designator of the component (e.g., 'R5')"), - angle: z - .number() - .describe("Rotation angle in degrees (absolute, not relative)"), + reference: z.string().describe("Reference designator of the component (e.g., 'R5')"), + angle: z.number().describe("Rotation angle in degrees (absolute, not relative)"), }, async ({ reference, angle }) => { logger.debug(`Rotating component: ${reference} to ${angle} degrees`); @@ -183,9 +149,7 @@ export function registerComponentTools( { reference: z .string() - .describe( - "Reference designator of the component to delete (e.g., 'R5')", - ), + .describe("Reference designator of the component to delete (e.g., 'R5')"), }, async ({ reference }) => { logger.debug(`Deleting component: ${reference}`); @@ -208,13 +172,8 @@ export function registerComponentTools( server.tool( "edit_component", { - reference: z - .string() - .describe("Reference designator of the component (e.g., 'R5')"), - newReference: z - .string() - .optional() - .describe("Optional new reference designator"), + reference: z.string().describe("Reference designator of the component (e.g., 'R5')"), + newReference: z.string().optional().describe("Optional new reference designator"), value: z.string().optional().describe("Optional new component value"), footprint: z.string().optional().describe("Optional new footprint"), }, @@ -244,10 +203,7 @@ export function registerComponentTools( server.tool( "find_component", { - reference: z - .string() - .optional() - .describe("Reference designator to search for"), + reference: z.string().optional().describe("Reference designator to search for"), value: z.string().optional().describe("Component value to search for"), }, async ({ reference, value }) => { @@ -276,9 +232,7 @@ export function registerComponentTools( server.tool( "get_component_properties", { - reference: z - .string() - .describe("Reference designator of the component (e.g., 'R5')"), + reference: z.string().describe("Reference designator of the component (e.g., 'R5')"), }, async ({ reference }) => { logger.debug(`Getting properties for component: ${reference}`); @@ -303,9 +257,7 @@ export function registerComponentTools( server.tool( "add_component_annotation", { - reference: z - .string() - .describe("Reference designator of the component (e.g., 'R5')"), + reference: z.string().describe("Reference designator of the component (e.g., 'R5')"), annotation: z.string().describe("Annotation or comment text to add"), visible: z .boolean() @@ -337,15 +289,11 @@ export function registerComponentTools( server.tool( "group_components", { - references: z - .array(z.string()) - .describe("Reference designators of components to group"), + references: z.array(z.string()).describe("Reference designators of components to group"), groupName: z.string().describe("Name for the component group"), }, async ({ references, groupName }) => { - logger.debug( - `Grouping components: ${references.join(", ")} as ${groupName}`, - ); + logger.debug(`Grouping components: ${references.join(", ")} as ${groupName}`); const result = await callKicadScript("group_components", { references, groupName, @@ -368,9 +316,7 @@ export function registerComponentTools( server.tool( "replace_component", { - reference: z - .string() - .describe("Reference designator of the component to replace"), + reference: z.string().describe("Reference designator of the component to replace"), newComponentId: z.string().describe("ID of the new component to use"), newFootprint: z.string().optional().describe("Optional new footprint"), newValue: z.string().optional().describe("Optional new component value"), @@ -401,13 +347,8 @@ export function registerComponentTools( server.tool( "get_component_pads", { - reference: z - .string() - .describe("Reference designator of the component (e.g., 'U1')"), - unit: z - .enum(["mm", "inch"]) - .optional() - .describe("Unit for coordinates (default: mm)"), + reference: z.string().describe("Reference designator of the component (e.g., 'U1')"), + unit: z.enum(["mm", "inch"]).optional().describe("Unit for coordinates (default: mm)"), }, async ({ reference, unit }) => { logger.debug(`Getting pads for component: ${reference}`); @@ -433,10 +374,7 @@ export function registerComponentTools( server.tool( "get_component_list", { - layer: z - .string() - .optional() - .describe("Filter by layer (e.g., 'F.Cu', 'B.Cu')"), + layer: z.string().optional().describe("Filter by layer (e.g., 'F.Cu', 'B.Cu')"), boundingBox: z .object({ x1: z.number(), @@ -447,10 +385,7 @@ export function registerComponentTools( }) .optional() .describe("Filter by bounding box region"), - unit: z - .enum(["mm", "inch"]) - .optional() - .describe("Unit for coordinates (default: mm)"), + unit: z.enum(["mm", "inch"]).optional().describe("Unit for coordinates (default: mm)"), }, async ({ layer, boundingBox, unit }) => { logger.debug("Getting component list"); @@ -477,14 +412,9 @@ export function registerComponentTools( server.tool( "get_pad_position", { - reference: z - .string() - .describe("Component reference designator (e.g., 'U1')"), + reference: z.string().describe("Component reference designator (e.g., 'U1')"), pad: z.string().describe("Pad number or name (e.g., '1', 'A1')"), - unit: z - .enum(["mm", "inch"]) - .optional() - .describe("Unit for coordinates (default: mm)"), + unit: z.enum(["mm", "inch"]).optional().describe("Unit for coordinates (default: mm)"), }, async ({ reference, pad, unit }) => { logger.debug(`Getting pad position for ${reference} pad ${pad}`); @@ -523,10 +453,7 @@ export function registerComponentTools( columns: z.number().describe("Number of columns"), rowSpacing: z.number().describe("Spacing between rows"), columnSpacing: z.number().describe("Spacing between columns"), - startReference: z - .string() - .optional() - .describe("Starting reference (e.g., 'R1')"), + startReference: z.string().optional().describe("Starting reference (e.g., 'R1')"), footprint: z.string().optional().describe("Footprint name"), value: z.string().optional().describe("Component value"), rotation: z.number().optional().describe("Rotation in degrees"), @@ -543,9 +470,7 @@ export function registerComponentTools( value, rotation, }) => { - logger.debug( - `Placing component array: ${rows}x${columns} of ${componentId}`, - ); + logger.debug(`Placing component array: ${rows}x${columns} of ${componentId}`); const result = await callKicadScript("place_component_array", { componentId, startPosition, @@ -576,20 +501,10 @@ export function registerComponentTools( server.tool( "align_components", { - references: z - .array(z.string()) - .describe("Array of component references to align"), - alignmentType: z - .enum(["horizontal", "vertical", "grid"]) - .describe("Type of alignment"), - spacing: z - .number() - .optional() - .describe("Spacing between components in mm"), - referenceComponent: z - .string() - .optional() - .describe("Reference component for alignment"), + references: z.array(z.string()).describe("Array of component references to align"), + alignmentType: z.enum(["horizontal", "vertical", "grid"]).describe("Type of alignment"), + spacing: z.number().optional().describe("Spacing between components in mm"), + referenceComponent: z.string().optional().describe("Reference component for alignment"), }, async ({ references, alignmentType, spacing, referenceComponent }) => { logger.debug(`Aligning components: ${references.join(", ")}`); @@ -626,10 +541,7 @@ export function registerComponentTools( }) .describe("Offset from original position"), newReference: z.string().optional().describe("New reference designator"), - count: z - .number() - .optional() - .describe("Number of duplicates (default: 1)"), + count: z.number().optional().describe("Number of duplicates (default: 1)"), }, async ({ reference, offset, newReference, count }) => { logger.debug(`Duplicating component: ${reference}`); diff --git a/src/tools/datasheet.ts b/src/tools/datasheet.ts index 588b66c..94dffc6 100644 --- a/src/tools/datasheet.ts +++ b/src/tools/datasheet.ts @@ -8,10 +8,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -export function registerDatasheetTools( - server: McpServer, - callKicadScript: Function, -) { +export function registerDatasheetTools(server: McpServer, callKicadScript: Function) { // ── enrich_datasheets ────────────────────────────────────────────────────── server.tool( "enrich_datasheets", @@ -30,16 +27,12 @@ No API key or internet lookup required – the URL is constructed directly. Use dry_run=true to preview changes without writing.`, { - schematic_path: z - .string() - .describe("Path to the .kicad_sch file to enrich"), + schematic_path: z.string().describe("Path to the .kicad_sch file to enrich"), dry_run: z .boolean() .optional() .default(false) - .describe( - "If true, show what would be changed without writing to disk (default: false)", - ), + .describe("If true, show what would be changed without writing to disk (default: false)"), }, async (args: { schematic_path: string; dry_run?: boolean }) => { const result = await callKicadScript("enrich_datasheets", args); @@ -65,9 +58,7 @@ Use dry_run=true to preview changes without writing.`, } if (result.updated === 0 && !args.dry_run) { - lines.push( - "\nNo changes needed – all LCSC components already have a Datasheet URL.", - ); + lines.push("\nNo changes needed – all LCSC components already have a Datasheet URL."); } return { content: [{ type: "text", text: lines.join("\n") }] }; @@ -96,9 +87,7 @@ Example: get_datasheet_url("C179739") { lcsc: z .string() - .describe( - 'LCSC part number, with or without "C" prefix (e.g. "C179739" or "179739")', - ), + .describe('LCSC part number, with or without "C" prefix (e.g. "C179739" or "179739")'), }, async (args: { lcsc: string }) => { const result = await callKicadScript("get_datasheet_url", { lcsc: args.lcsc }); diff --git a/src/tools/design-rules.ts b/src/tools/design-rules.ts index 2f028d4..9b28cc9 100644 --- a/src/tools/design-rules.ts +++ b/src/tools/design-rules.ts @@ -1,261 +1,314 @@ -/** - * Design rules tools for KiCAD MCP server - * - * These tools handle design rule checking and configuration - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import { logger } from '../logger.js'; - -// Command function type for KiCAD script calls -type CommandFunction = (command: string, params: Record) => Promise; - -/** - * Register design rule tools with the MCP server - * - * @param server MCP server instance - * @param callKicadScript Function to call KiCAD script commands - */ -export function registerDesignRuleTools(server: McpServer, callKicadScript: CommandFunction): void { - logger.info('Registering design rule tools'); - - // ------------------------------------------------------ - // Set Design Rules Tool - // ------------------------------------------------------ - server.tool( - "set_design_rules", - { - clearance: z.number().optional().describe("Minimum clearance between copper items (mm)"), - trackWidth: z.number().optional().describe("Default track width (mm)"), - viaDiameter: z.number().optional().describe("Default via diameter (mm)"), - viaDrill: z.number().optional().describe("Default via drill size (mm)"), - microViaDiameter: z.number().optional().describe("Default micro via diameter (mm)"), - microViaDrill: z.number().optional().describe("Default micro via drill size (mm)"), - minTrackWidth: z.number().optional().describe("Minimum track width (mm)"), - minViaDiameter: z.number().optional().describe("Minimum via diameter (mm)"), - minViaDrill: z.number().optional().describe("Minimum via drill size (mm)"), - minMicroViaDiameter: z.number().optional().describe("Minimum micro via diameter (mm)"), - minMicroViaDrill: z.number().optional().describe("Minimum micro via drill size (mm)"), - minHoleDiameter: z.number().optional().describe("Minimum hole diameter (mm)"), - requireCourtyard: z.boolean().optional().describe("Whether to require courtyards for all footprints"), - courtyardClearance: z.number().optional().describe("Minimum clearance between courtyards (mm)") - }, - async (params) => { - logger.debug('Setting design rules'); - const result = await callKicadScript("set_design_rules", params); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Get Design Rules Tool - // ------------------------------------------------------ - server.tool( - "get_design_rules", - {}, - async () => { - logger.debug('Getting design rules'); - const result = await callKicadScript("get_design_rules", {}); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Run DRC Tool - // ------------------------------------------------------ - server.tool( - "run_drc", - { - reportPath: z.string().optional().describe("Optional path to save the DRC report") - }, - async ({ reportPath }) => { - logger.debug('Running DRC check'); - const result = await callKicadScript("run_drc", { reportPath }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Add Net Class Tool - // ------------------------------------------------------ - server.tool( - "add_net_class", - { - name: z.string().describe("Name of the net class"), - description: z.string().optional().describe("Optional description of the net class"), - clearance: z.number().describe("Clearance for this net class (mm)"), - trackWidth: z.number().describe("Track width for this net class (mm)"), - viaDiameter: z.number().describe("Via diameter for this net class (mm)"), - viaDrill: z.number().describe("Via drill size for this net class (mm)"), - uvia_diameter: z.number().optional().describe("Micro via diameter for this net class (mm)"), - uvia_drill: z.number().optional().describe("Micro via drill size for this net class (mm)"), - diff_pair_width: z.number().optional().describe("Differential pair width for this net class (mm)"), - diff_pair_gap: z.number().optional().describe("Differential pair gap for this net class (mm)"), - nets: z.array(z.string()).optional().describe("Array of net names to assign to this class") - }, - async ({ name, description, clearance, trackWidth, viaDiameter, viaDrill, uvia_diameter, uvia_drill, diff_pair_width, diff_pair_gap, nets }) => { - logger.debug(`Adding net class: ${name}`); - const result = await callKicadScript("add_net_class", { - name, - description, - clearance, - trackWidth, - viaDiameter, - viaDrill, - uvia_diameter, - uvia_drill, - diff_pair_width, - diff_pair_gap, - nets - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Assign Net to Class Tool - // ------------------------------------------------------ - server.tool( - "assign_net_to_class", - { - net: z.string().describe("Name of the net"), - netClass: z.string().describe("Name of the net class") - }, - async ({ net, netClass }) => { - logger.debug(`Assigning net ${net} to class ${netClass}`); - const result = await callKicadScript("assign_net_to_class", { - net, - netClass - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Set Layer Constraints Tool - // ------------------------------------------------------ - server.tool( - "set_layer_constraints", - { - layer: z.string().describe("Layer name (e.g., 'F.Cu')"), - minTrackWidth: z.number().optional().describe("Minimum track width for this layer (mm)"), - minClearance: z.number().optional().describe("Minimum clearance for this layer (mm)"), - minViaDiameter: z.number().optional().describe("Minimum via diameter for this layer (mm)"), - minViaDrill: z.number().optional().describe("Minimum via drill size for this layer (mm)") - }, - async ({ layer, minTrackWidth, minClearance, minViaDiameter, minViaDrill }) => { - logger.debug(`Setting constraints for layer: ${layer}`); - const result = await callKicadScript("set_layer_constraints", { - layer, - minTrackWidth, - minClearance, - minViaDiameter, - minViaDrill - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Check Clearance Tool - // ------------------------------------------------------ - server.tool( - "check_clearance", - { - item1: z.object({ - type: z.enum(["track", "via", "pad", "zone", "component"]).describe("Type of the first item"), - id: z.string().optional().describe("ID of the first item (if applicable)"), - reference: z.string().optional().describe("Reference designator (for component)"), - position: z.object({ - x: z.number().optional(), - y: z.number().optional(), - unit: z.enum(["mm", "inch"]).optional() - }).optional().describe("Position to check (if ID not provided)") - }).describe("First item to check"), - item2: z.object({ - type: z.enum(["track", "via", "pad", "zone", "component"]).describe("Type of the second item"), - id: z.string().optional().describe("ID of the second item (if applicable)"), - reference: z.string().optional().describe("Reference designator (for component)"), - position: z.object({ - x: z.number().optional(), - y: z.number().optional(), - unit: z.enum(["mm", "inch"]).optional() - }).optional().describe("Position to check (if ID not provided)") - }).describe("Second item to check") - }, - async ({ item1, item2 }) => { - logger.debug(`Checking clearance between ${item1.type} and ${item2.type}`); - const result = await callKicadScript("check_clearance", { - item1, - item2 - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Get DRC Violations Tool - // ------------------------------------------------------ - server.tool( - "get_drc_violations", - { - severity: z.enum(["error", "warning", "all"]).optional().describe("Filter violations by severity") - }, - async ({ severity }) => { - logger.debug('Getting DRC violations'); - const result = await callKicadScript("get_drc_violations", { severity }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - logger.info('Design rule tools registered'); -} +/** + * Design rules tools for KiCAD MCP server + * + * These tools handle design rule checking and configuration + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { logger } from "../logger.js"; + +// Command function type for KiCAD script calls +type CommandFunction = (command: string, params: Record) => Promise; + +/** + * Register design rule tools with the MCP server + * + * @param server MCP server instance + * @param callKicadScript Function to call KiCAD script commands + */ +export function registerDesignRuleTools(server: McpServer, callKicadScript: CommandFunction): void { + logger.info("Registering design rule tools"); + + // ------------------------------------------------------ + // Set Design Rules Tool + // ------------------------------------------------------ + server.tool( + "set_design_rules", + { + clearance: z.number().optional().describe("Minimum clearance between copper items (mm)"), + trackWidth: z.number().optional().describe("Default track width (mm)"), + viaDiameter: z.number().optional().describe("Default via diameter (mm)"), + viaDrill: z.number().optional().describe("Default via drill size (mm)"), + microViaDiameter: z.number().optional().describe("Default micro via diameter (mm)"), + microViaDrill: z.number().optional().describe("Default micro via drill size (mm)"), + minTrackWidth: z.number().optional().describe("Minimum track width (mm)"), + minViaDiameter: z.number().optional().describe("Minimum via diameter (mm)"), + minViaDrill: z.number().optional().describe("Minimum via drill size (mm)"), + minMicroViaDiameter: z.number().optional().describe("Minimum micro via diameter (mm)"), + minMicroViaDrill: z.number().optional().describe("Minimum micro via drill size (mm)"), + minHoleDiameter: z.number().optional().describe("Minimum hole diameter (mm)"), + requireCourtyard: z + .boolean() + .optional() + .describe("Whether to require courtyards for all footprints"), + courtyardClearance: z + .number() + .optional() + .describe("Minimum clearance between courtyards (mm)"), + }, + async (params) => { + logger.debug("Setting design rules"); + const result = await callKicadScript("set_design_rules", params); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Get Design Rules Tool + // ------------------------------------------------------ + server.tool("get_design_rules", {}, async () => { + logger.debug("Getting design rules"); + const result = await callKicadScript("get_design_rules", {}); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }); + + // ------------------------------------------------------ + // Run DRC Tool + // ------------------------------------------------------ + server.tool( + "run_drc", + { + reportPath: z.string().optional().describe("Optional path to save the DRC report"), + }, + async ({ reportPath }) => { + logger.debug("Running DRC check"); + const result = await callKicadScript("run_drc", { reportPath }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Add Net Class Tool + // ------------------------------------------------------ + server.tool( + "add_net_class", + { + name: z.string().describe("Name of the net class"), + description: z.string().optional().describe("Optional description of the net class"), + clearance: z.number().describe("Clearance for this net class (mm)"), + trackWidth: z.number().describe("Track width for this net class (mm)"), + viaDiameter: z.number().describe("Via diameter for this net class (mm)"), + viaDrill: z.number().describe("Via drill size for this net class (mm)"), + uvia_diameter: z.number().optional().describe("Micro via diameter for this net class (mm)"), + uvia_drill: z.number().optional().describe("Micro via drill size for this net class (mm)"), + diff_pair_width: z + .number() + .optional() + .describe("Differential pair width for this net class (mm)"), + diff_pair_gap: z + .number() + .optional() + .describe("Differential pair gap for this net class (mm)"), + nets: z.array(z.string()).optional().describe("Array of net names to assign to this class"), + }, + async ({ + name, + description, + clearance, + trackWidth, + viaDiameter, + viaDrill, + uvia_diameter, + uvia_drill, + diff_pair_width, + diff_pair_gap, + nets, + }) => { + logger.debug(`Adding net class: ${name}`); + const result = await callKicadScript("add_net_class", { + name, + description, + clearance, + trackWidth, + viaDiameter, + viaDrill, + uvia_diameter, + uvia_drill, + diff_pair_width, + diff_pair_gap, + nets, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Assign Net to Class Tool + // ------------------------------------------------------ + server.tool( + "assign_net_to_class", + { + net: z.string().describe("Name of the net"), + netClass: z.string().describe("Name of the net class"), + }, + async ({ net, netClass }) => { + logger.debug(`Assigning net ${net} to class ${netClass}`); + const result = await callKicadScript("assign_net_to_class", { + net, + netClass, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Set Layer Constraints Tool + // ------------------------------------------------------ + server.tool( + "set_layer_constraints", + { + layer: z.string().describe("Layer name (e.g., 'F.Cu')"), + minTrackWidth: z.number().optional().describe("Minimum track width for this layer (mm)"), + minClearance: z.number().optional().describe("Minimum clearance for this layer (mm)"), + minViaDiameter: z.number().optional().describe("Minimum via diameter for this layer (mm)"), + minViaDrill: z.number().optional().describe("Minimum via drill size for this layer (mm)"), + }, + async ({ layer, minTrackWidth, minClearance, minViaDiameter, minViaDrill }) => { + logger.debug(`Setting constraints for layer: ${layer}`); + const result = await callKicadScript("set_layer_constraints", { + layer, + minTrackWidth, + minClearance, + minViaDiameter, + minViaDrill, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Check Clearance Tool + // ------------------------------------------------------ + server.tool( + "check_clearance", + { + item1: z + .object({ + type: z + .enum(["track", "via", "pad", "zone", "component"]) + .describe("Type of the first item"), + id: z.string().optional().describe("ID of the first item (if applicable)"), + reference: z.string().optional().describe("Reference designator (for component)"), + position: z + .object({ + x: z.number().optional(), + y: z.number().optional(), + unit: z.enum(["mm", "inch"]).optional(), + }) + .optional() + .describe("Position to check (if ID not provided)"), + }) + .describe("First item to check"), + item2: z + .object({ + type: z + .enum(["track", "via", "pad", "zone", "component"]) + .describe("Type of the second item"), + id: z.string().optional().describe("ID of the second item (if applicable)"), + reference: z.string().optional().describe("Reference designator (for component)"), + position: z + .object({ + x: z.number().optional(), + y: z.number().optional(), + unit: z.enum(["mm", "inch"]).optional(), + }) + .optional() + .describe("Position to check (if ID not provided)"), + }) + .describe("Second item to check"), + }, + async ({ item1, item2 }) => { + logger.debug(`Checking clearance between ${item1.type} and ${item2.type}`); + const result = await callKicadScript("check_clearance", { + item1, + item2, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Get DRC Violations Tool + // ------------------------------------------------------ + server.tool( + "get_drc_violations", + { + severity: z + .enum(["error", "warning", "all"]) + .optional() + .describe("Filter violations by severity"), + }, + async ({ severity }) => { + logger.debug("Getting DRC violations"); + const result = await callKicadScript("get_drc_violations", { severity }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + logger.info("Design rule tools registered"); +} diff --git a/src/tools/export.ts b/src/tools/export.ts index e51beff..0c6f524 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -1,260 +1,317 @@ -/** - * Export tools for KiCAD MCP server - * - * These tools handle exporting PCB data to various formats - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import { logger } from '../logger.js'; - -// Command function type for KiCAD script calls -type CommandFunction = (command: string, params: Record) => Promise; - -/** - * Register export tools with the MCP server - * - * @param server MCP server instance - * @param callKicadScript Function to call KiCAD script commands - */ -export function registerExportTools(server: McpServer, callKicadScript: CommandFunction): void { - logger.info('Registering export tools'); - - // ------------------------------------------------------ - // Export Gerber Tool - // ------------------------------------------------------ - server.tool( - "export_gerber", - { - outputDir: z.string().describe("Directory to save Gerber files"), - layers: z.array(z.string()).optional().describe("Optional array of layer names to export (default: all)"), - useProtelExtensions: z.boolean().optional().describe("Whether to use Protel filename extensions"), - generateDrillFiles: z.boolean().optional().describe("Whether to generate drill files"), - generateMapFile: z.boolean().optional().describe("Whether to generate a map file"), - useAuxOrigin: z.boolean().optional().describe("Whether to use auxiliary axis as origin") - }, - async ({ outputDir, layers, useProtelExtensions, generateDrillFiles, generateMapFile, useAuxOrigin }) => { - logger.debug(`Exporting Gerber files to: ${outputDir}`); - const result = await callKicadScript("export_gerber", { - outputDir, - layers, - useProtelExtensions, - generateDrillFiles, - generateMapFile, - useAuxOrigin - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Export PDF Tool - // ------------------------------------------------------ - server.tool( - "export_pdf", - { - outputPath: z.string().describe("Path to save the PDF file"), - layers: z.array(z.string()).optional().describe("Optional array of layer names to include (default: all)"), - blackAndWhite: z.boolean().optional().describe("Whether to export in black and white"), - frameReference: z.boolean().optional().describe("Whether to include frame reference"), - pageSize: z.enum(["A4", "A3", "A2", "A1", "A0", "Letter", "Legal", "Tabloid"]).optional().describe("Page size") - }, - async ({ outputPath, layers, blackAndWhite, frameReference, pageSize }) => { - logger.debug(`Exporting PDF to: ${outputPath}`); - const result = await callKicadScript("export_pdf", { - outputPath, - layers, - blackAndWhite, - frameReference, - pageSize - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Export SVG Tool - // ------------------------------------------------------ - server.tool( - "export_svg", - { - outputPath: z.string().describe("Path to save the SVG file"), - layers: z.array(z.string()).optional().describe("Optional array of layer names to include (default: all)"), - blackAndWhite: z.boolean().optional().describe("Whether to export in black and white"), - includeComponents: z.boolean().optional().describe("Whether to include component outlines") - }, - async ({ outputPath, layers, blackAndWhite, includeComponents }) => { - logger.debug(`Exporting SVG to: ${outputPath}`); - const result = await callKicadScript("export_svg", { - outputPath, - layers, - blackAndWhite, - includeComponents - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Export 3D Model Tool - // ------------------------------------------------------ - server.tool( - "export_3d", - { - outputPath: z.string().describe("Path to save the 3D model file"), - format: z.enum(["STEP", "STL", "VRML", "OBJ"]).describe("3D model format"), - includeComponents: z.boolean().optional().describe("Whether to include 3D component models"), - includeCopper: z.boolean().optional().describe("Whether to include copper layers"), - includeSolderMask: z.boolean().optional().describe("Whether to include solder mask"), - includeSilkscreen: z.boolean().optional().describe("Whether to include silkscreen") - }, - async ({ outputPath, format, includeComponents, includeCopper, includeSolderMask, includeSilkscreen }) => { - logger.debug(`Exporting 3D model to: ${outputPath}`); - const result = await callKicadScript("export_3d", { - outputPath, - format, - includeComponents, - includeCopper, - includeSolderMask, - includeSilkscreen - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Export BOM Tool - // ------------------------------------------------------ - server.tool( - "export_bom", - { - outputPath: z.string().describe("Path to save the BOM file"), - format: z.enum(["CSV", "XML", "HTML", "JSON"]).describe("BOM file format"), - groupByValue: z.boolean().optional().describe("Whether to group components by value"), - includeAttributes: z.array(z.string()).optional().describe("Optional array of additional attributes to include") - }, - async ({ outputPath, format, groupByValue, includeAttributes }) => { - logger.debug(`Exporting BOM to: ${outputPath}`); - const result = await callKicadScript("export_bom", { - outputPath, - format, - groupByValue, - includeAttributes - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Export Netlist Tool - // ------------------------------------------------------ - server.tool( - "export_netlist", - { - outputPath: z.string().describe("Path to save the netlist file"), - format: z.enum(["KiCad", "Spice", "Cadstar", "OrcadPCB2"]).optional().describe("Netlist format (default: KiCad)") - }, - async ({ outputPath, format }) => { - logger.debug(`Exporting netlist to: ${outputPath}`); - const result = await callKicadScript("export_netlist", { - outputPath, - format - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Export Position File Tool - // ------------------------------------------------------ - server.tool( - "export_position_file", - { - outputPath: z.string().describe("Path to save the position file"), - format: z.enum(["CSV", "ASCII"]).optional().describe("File format (default: CSV)"), - units: z.enum(["mm", "inch"]).optional().describe("Units to use (default: mm)"), - side: z.enum(["top", "bottom", "both"]).optional().describe("Which board side to include (default: both)") - }, - async ({ outputPath, format, units, side }) => { - logger.debug(`Exporting position file to: ${outputPath}`); - const result = await callKicadScript("export_position_file", { - outputPath, - format, - units, - side - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Export VRML Tool - // ------------------------------------------------------ - server.tool( - "export_vrml", - { - outputPath: z.string().describe("Path to save the VRML file"), - includeComponents: z.boolean().optional().describe("Whether to include 3D component models"), - useRelativePaths: z.boolean().optional().describe("Whether to use relative paths for 3D models") - }, - async ({ outputPath, includeComponents, useRelativePaths }) => { - logger.debug(`Exporting VRML to: ${outputPath}`); - const result = await callKicadScript("export_vrml", { - outputPath, - includeComponents, - useRelativePaths - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - logger.info('Export tools registered'); -} +/** + * Export tools for KiCAD MCP server + * + * These tools handle exporting PCB data to various formats + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { logger } from "../logger.js"; + +// Command function type for KiCAD script calls +type CommandFunction = (command: string, params: Record) => Promise; + +/** + * Register export tools with the MCP server + * + * @param server MCP server instance + * @param callKicadScript Function to call KiCAD script commands + */ +export function registerExportTools(server: McpServer, callKicadScript: CommandFunction): void { + logger.info("Registering export tools"); + + // ------------------------------------------------------ + // Export Gerber Tool + // ------------------------------------------------------ + server.tool( + "export_gerber", + { + outputDir: z.string().describe("Directory to save Gerber files"), + layers: z + .array(z.string()) + .optional() + .describe("Optional array of layer names to export (default: all)"), + useProtelExtensions: z + .boolean() + .optional() + .describe("Whether to use Protel filename extensions"), + generateDrillFiles: z.boolean().optional().describe("Whether to generate drill files"), + generateMapFile: z.boolean().optional().describe("Whether to generate a map file"), + useAuxOrigin: z.boolean().optional().describe("Whether to use auxiliary axis as origin"), + }, + async ({ + outputDir, + layers, + useProtelExtensions, + generateDrillFiles, + generateMapFile, + useAuxOrigin, + }) => { + logger.debug(`Exporting Gerber files to: ${outputDir}`); + const result = await callKicadScript("export_gerber", { + outputDir, + layers, + useProtelExtensions, + generateDrillFiles, + generateMapFile, + useAuxOrigin, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export PDF Tool + // ------------------------------------------------------ + server.tool( + "export_pdf", + { + outputPath: z.string().describe("Path to save the PDF file"), + layers: z + .array(z.string()) + .optional() + .describe("Optional array of layer names to include (default: all)"), + blackAndWhite: z.boolean().optional().describe("Whether to export in black and white"), + frameReference: z.boolean().optional().describe("Whether to include frame reference"), + pageSize: z + .enum(["A4", "A3", "A2", "A1", "A0", "Letter", "Legal", "Tabloid"]) + .optional() + .describe("Page size"), + }, + async ({ outputPath, layers, blackAndWhite, frameReference, pageSize }) => { + logger.debug(`Exporting PDF to: ${outputPath}`); + const result = await callKicadScript("export_pdf", { + outputPath, + layers, + blackAndWhite, + frameReference, + pageSize, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export SVG Tool + // ------------------------------------------------------ + server.tool( + "export_svg", + { + outputPath: z.string().describe("Path to save the SVG file"), + layers: z + .array(z.string()) + .optional() + .describe("Optional array of layer names to include (default: all)"), + blackAndWhite: z.boolean().optional().describe("Whether to export in black and white"), + includeComponents: z.boolean().optional().describe("Whether to include component outlines"), + }, + async ({ outputPath, layers, blackAndWhite, includeComponents }) => { + logger.debug(`Exporting SVG to: ${outputPath}`); + const result = await callKicadScript("export_svg", { + outputPath, + layers, + blackAndWhite, + includeComponents, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export 3D Model Tool + // ------------------------------------------------------ + server.tool( + "export_3d", + { + outputPath: z.string().describe("Path to save the 3D model file"), + format: z.enum(["STEP", "STL", "VRML", "OBJ"]).describe("3D model format"), + includeComponents: z.boolean().optional().describe("Whether to include 3D component models"), + includeCopper: z.boolean().optional().describe("Whether to include copper layers"), + includeSolderMask: z.boolean().optional().describe("Whether to include solder mask"), + includeSilkscreen: z.boolean().optional().describe("Whether to include silkscreen"), + }, + async ({ + outputPath, + format, + includeComponents, + includeCopper, + includeSolderMask, + includeSilkscreen, + }) => { + logger.debug(`Exporting 3D model to: ${outputPath}`); + const result = await callKicadScript("export_3d", { + outputPath, + format, + includeComponents, + includeCopper, + includeSolderMask, + includeSilkscreen, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export BOM Tool + // ------------------------------------------------------ + server.tool( + "export_bom", + { + outputPath: z.string().describe("Path to save the BOM file"), + format: z.enum(["CSV", "XML", "HTML", "JSON"]).describe("BOM file format"), + groupByValue: z.boolean().optional().describe("Whether to group components by value"), + includeAttributes: z + .array(z.string()) + .optional() + .describe("Optional array of additional attributes to include"), + }, + async ({ outputPath, format, groupByValue, includeAttributes }) => { + logger.debug(`Exporting BOM to: ${outputPath}`); + const result = await callKicadScript("export_bom", { + outputPath, + format, + groupByValue, + includeAttributes, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export Netlist Tool + // ------------------------------------------------------ + server.tool( + "export_netlist", + { + outputPath: z.string().describe("Path to save the netlist file"), + format: z + .enum(["KiCad", "Spice", "Cadstar", "OrcadPCB2"]) + .optional() + .describe("Netlist format (default: KiCad)"), + }, + async ({ outputPath, format }) => { + logger.debug(`Exporting netlist to: ${outputPath}`); + const result = await callKicadScript("export_netlist", { + outputPath, + format, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export Position File Tool + // ------------------------------------------------------ + server.tool( + "export_position_file", + { + outputPath: z.string().describe("Path to save the position file"), + format: z.enum(["CSV", "ASCII"]).optional().describe("File format (default: CSV)"), + units: z.enum(["mm", "inch"]).optional().describe("Units to use (default: mm)"), + side: z + .enum(["top", "bottom", "both"]) + .optional() + .describe("Which board side to include (default: both)"), + }, + async ({ outputPath, format, units, side }) => { + logger.debug(`Exporting position file to: ${outputPath}`); + const result = await callKicadScript("export_position_file", { + outputPath, + format, + units, + side, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Export VRML Tool + // ------------------------------------------------------ + server.tool( + "export_vrml", + { + outputPath: z.string().describe("Path to save the VRML file"), + includeComponents: z.boolean().optional().describe("Whether to include 3D component models"), + useRelativePaths: z + .boolean() + .optional() + .describe("Whether to use relative paths for 3D models"), + }, + async ({ outputPath, includeComponents, useRelativePaths }) => { + logger.debug(`Exporting VRML to: ${outputPath}`); + const result = await callKicadScript("export_vrml", { + outputPath, + includeComponents, + useRelativePaths, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + logger.info("Export tools registered"); +} diff --git a/src/tools/footprint.ts b/src/tools/footprint.ts index c7d4afd..563904b 100644 --- a/src/tools/footprint.ts +++ b/src/tools/footprint.ts @@ -62,10 +62,7 @@ const RectSchema = z.object({ // ---- tool registration --------------------------------------------------- // -export function registerFootprintTools( - server: McpServer, - callKicadScript: Function, -) { +export function registerFootprintTools(server: McpServer, callKicadScript: Function) { // ── create_footprint ──────────────────────────────────────────────────── // server.tool( "create_footprint", @@ -80,10 +77,7 @@ export function registerFootprintTools( ), name: z.string().describe("Footprint name, e.g. 'R_0603_Custom'"), description: z.string().optional().describe("Human-readable description"), - tags: z - .string() - .optional() - .describe("Space-separated tag string, e.g. 'resistor SMD 0603'"), + tags: z.string().optional().describe("Space-separated tag string, e.g. 'resistor SMD 0603'"), pads: z .array(PadSchema) .optional() @@ -91,9 +85,7 @@ export function registerFootprintTools( courtyard: RectSchema.optional().describe( "Courtyard rectangle on F.CrtYd (recommended: 0.25 mm clearance around pads)", ), - silkscreen: RectSchema.optional().describe( - "Silkscreen rectangle on F.SilkS", - ), + silkscreen: RectSchema.optional().describe("Silkscreen rectangle on F.SilkS"), fabLayer: RectSchema.optional().describe( "Fab-layer rectangle on F.Fab (shows component body)", ), @@ -139,9 +131,7 @@ export function registerFootprintTools( footprintPath: z .string() .describe("Full path to the .kicad_mod file, e.g. C:/MyLib.pretty/R_Custom.kicad_mod"), - padNumber: z - .union([z.string(), z.number()]) - .describe("Pad number to edit, e.g. '1' or 2"), + padNumber: z.union([z.string(), z.number()]).describe("Pad number to edit, e.g. '1' or 2"), size: PadSize.optional().describe("New pad size in mm"), at: PadPosition.optional().describe("New pad position in mm"), drill: z @@ -151,10 +141,7 @@ export function registerFootprintTools( ]) .optional() .describe("New drill size (for THT pads)"), - shape: z - .enum(["rect", "circle", "oval", "roundrect"]) - .optional() - .describe("New pad shape"), + shape: z.enum(["rect", "circle", "oval", "roundrect"]).optional().describe("New pad shape"), }, async (args: { footprintPath: string; @@ -177,9 +164,7 @@ export function registerFootprintTools( "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'.", { - libraryPath: z - .string() - .describe("Full path to the .pretty directory to register"), + libraryPath: z.string().describe("Full path to the .pretty directory to register"), libraryName: z .string() .optional() diff --git a/src/tools/freerouting.ts b/src/tools/freerouting.ts index f17d7ce..c1924ec 100644 --- a/src/tools/freerouting.ts +++ b/src/tools/freerouting.ts @@ -7,33 +7,21 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -export function registerFreeroutingTools( - server: McpServer, - callKicadScript: Function, -) { +export function registerFreeroutingTools(server: McpServer, callKicadScript: Function) { // Full autoroute: export DSN -> run Freerouting -> import SES server.tool( "autoroute", "Run Freerouting autorouter on the current PCB. Exports to Specctra DSN, runs Freerouting CLI, and imports the routed SES result. Requires Java 11+ and freerouting.jar (see check_freerouting).", { - boardPath: z - .string() - .optional() - .describe("Path to .kicad_pcb file (default: current board)"), + boardPath: z.string().optional().describe("Path to .kicad_pcb file (default: current board)"), freeroutingJar: z .string() .optional() .describe( "Path to freerouting.jar (default: ~/.kicad-mcp/freerouting.jar or FREEROUTING_JAR env)", ), - maxPasses: z - .number() - .optional() - .describe("Maximum routing passes (default: 20)"), - timeout: z - .number() - .optional() - .describe("Timeout in seconds (default: 300)"), + maxPasses: z.number().optional().describe("Maximum routing passes (default: 20)"), + timeout: z.number().optional().describe("Timeout in seconds (default: 300)"), }, async (args: any) => { const result = await callKicadScript("autoroute", args); @@ -53,10 +41,7 @@ export function registerFreeroutingTools( "export_dsn", "Export the current PCB to Specctra DSN format. Useful for manual Freerouting workflow or external autorouters.", { - boardPath: z - .string() - .optional() - .describe("Path to .kicad_pcb file (default: current board)"), + boardPath: z.string().optional().describe("Path to .kicad_pcb file (default: current board)"), outputPath: z .string() .optional() @@ -81,10 +66,7 @@ export function registerFreeroutingTools( "Import a Specctra SES (session) file into the current PCB. Use after running Freerouting externally.", { sesPath: z.string().describe("Path to the .ses file to import"), - boardPath: z - .string() - .optional() - .describe("Path to .kicad_pcb file (default: current board)"), + boardPath: z.string().optional().describe("Path to .kicad_pcb file (default: current board)"), }, async (args: any) => { const result = await callKicadScript("import_ses", args); @@ -104,10 +86,7 @@ export function registerFreeroutingTools( "check_freerouting", "Check if Java and Freerouting JAR are available on the system. Run this before autoroute to verify prerequisites.", { - freeroutingJar: z - .string() - .optional() - .describe("Path to freerouting.jar to check"), + freeroutingJar: z.string().optional().describe("Path to freerouting.jar to check"), }, async (args: any) => { const result = await callKicadScript("check_freerouting", args); diff --git a/src/tools/jlcpcb-api.ts b/src/tools/jlcpcb-api.ts index 87f6eeb..286bc73 100644 --- a/src/tools/jlcpcb-api.ts +++ b/src/tools/jlcpcb-api.ts @@ -1,245 +1,295 @@ -/** - * JLCPCB API tools for KiCAD MCP server - * Provides access to JLCPCB's complete parts catalog via their API - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; - -export function registerJLCPCBApiTools(server: McpServer, callKicadScript: Function) { - // Download JLCPCB parts database - server.tool( - "download_jlcpcb_database", - `Download the complete JLCPCB parts catalog to local database. - -This is a one-time setup that downloads ~2.5M+ parts from JLCSearch API. -No API credentials required - uses public JLCSearch API. - -The download takes 5-10 minutes and creates a local SQLite database -for fast offline searching.`, - { - force: z.boolean().optional().default(false) - .describe("Force re-download even if database exists") - }, - async (args: { force?: boolean }) => { - const result = await callKicadScript("download_jlcpcb_database", args); - if (result.success) { - return { - content: [{ - type: "text", - text: `✓ Successfully downloaded JLCPCB parts database\n\n` + - `Total parts: ${result.total_parts}\n` + - `Basic parts: ${result.basic_parts}\n` + - `Extended parts: ${result.extended_parts}\n` + - `Database size: ${result.db_size_mb} MB\n` + - `Database path: ${result.db_path}` - }] - }; - } - return { - content: [{ - type: "text", - text: `✗ Failed to download JLCPCB database: ${result.message || 'Unknown error'}\n\n` + - `Make sure JLCPCB_API_KEY and JLCPCB_API_SECRET environment variables are set.` - }] - }; - } - ); - - // Search JLCPCB parts - server.tool( - "search_jlcpcb_parts", - `Search JLCPCB parts catalog by specifications. - -Searches the local JLCPCB database (must be downloaded first with download_jlcpcb_database). -Provides real pricing, stock info, and library type (Basic parts = free assembly). - -Use this to find components with exact specifications and cost optimization.`, - { - query: z.string().optional() - .describe("Free-text search (e.g., '10k resistor 0603', 'ESP32', 'STM32F103')"), - category: z.string().optional() - .describe("Filter by category (e.g., 'Resistors', 'Capacitors', 'Microcontrollers')"), - package: z.string().optional() - .describe("Filter by package type (e.g., '0603', 'SOT-23', 'QFN-32')"), - library_type: z.enum(["Basic", "Extended", "Preferred", "All"]).optional().default("All") - .describe("Filter by library type (Basic = free assembly at JLCPCB)"), - manufacturer: z.string().optional() - .describe("Filter by manufacturer name"), - in_stock: z.boolean().optional().default(true) - .describe("Only show parts with available stock"), - limit: z.number().optional().default(20) - .describe("Maximum number of results to return") - }, - async (args: any) => { - const result = await callKicadScript("search_jlcpcb_parts", args); - if (result.success && result.parts) { - if (result.parts.length === 0) { - return { - content: [{ - type: "text", - text: `No JLCPCB parts found matching your criteria.\n\n` + - `Try broadening your search or check if the database is populated.` - }] - }; - } - - const partsList = result.parts.map((p: any) => { - const priceInfo = p.price_breaks && p.price_breaks.length > 0 - ? ` - $${p.price_breaks[0].price}/ea` - : ''; - const stockInfo = p.stock > 0 ? ` (${p.stock} in stock)` : ' (out of stock)'; - return `${p.lcsc}: ${p.mfr_part} - ${p.description} [${p.library_type}]${priceInfo}${stockInfo}`; - }).join('\n'); - - return { - content: [{ - type: "text", - text: `Found ${result.count} JLCPCB parts:\n\n${partsList}\n\n` + - `💡 Basic parts have free assembly. Extended parts charge $3 setup fee per unique part.` - }] - }; - } - return { - content: [{ - type: "text", - text: `Failed to search JLCPCB parts: ${result.message || 'Unknown error'}\n\n` + - `Make sure you've downloaded the database first using download_jlcpcb_database.` - }] - }; - } - ); - - // Get JLCPCB part details - server.tool( - "get_jlcpcb_part", - "Get detailed information about a specific JLCPCB part by LCSC number", - { - lcsc_number: z.string() - .describe("LCSC part number (e.g., 'C25804', 'C2286')") - }, - async (args: { lcsc_number: string }) => { - const result = await callKicadScript("get_jlcpcb_part", args); - if (result.success && result.part) { - const p = result.part; - const priceTable = p.price_breaks && p.price_breaks.length > 0 - ? '\n\nPrice Breaks:\n' + p.price_breaks.map((pb: any) => - ` ${pb.qty}+: $${pb.price}/ea` - ).join('\n') - : ''; - - const footprints = result.footprints && result.footprints.length > 0 - ? '\n\nSuggested KiCAD Footprints:\n' + result.footprints.map((f: string) => - ` - ${f}` - ).join('\n') - : ''; - - return { - content: [{ - type: "text", - text: `LCSC: ${p.lcsc}\n` + - `MFR Part: ${p.mfr_part}\n` + - `Manufacturer: ${p.manufacturer}\n` + - `Category: ${p.category} / ${p.subcategory}\n` + - `Package: ${p.package}\n` + - `Description: ${p.description}\n` + - `Library Type: ${p.library_type} ${p.library_type === 'Basic' ? '(Free assembly!)' : ''}\n` + - `Stock: ${p.stock}\n` + - (p.datasheet ? `Datasheet: ${p.datasheet}\n` : '') + - priceTable + - footprints - }] - }; - } - return { - content: [{ - type: "text", - text: `Part not found: ${args.lcsc_number}\n\n` + - `Make sure you've downloaded the JLCPCB database first.` - }] - }; - } - ); - - // Get JLCPCB database statistics - server.tool( - "get_jlcpcb_database_stats", - "Get statistics about the local JLCPCB parts database", - {}, - async () => { - const result = await callKicadScript("get_jlcpcb_database_stats", {}); - if (result.success) { - const stats = result.stats; - return { - content: [{ - type: "text", - text: `JLCPCB Database Statistics:\n\n` + - `Total parts: ${stats.total_parts.toLocaleString()}\n` + - `Basic parts: ${stats.basic_parts.toLocaleString()} (free assembly)\n` + - `Extended parts: ${stats.extended_parts.toLocaleString()} ($3 setup fee each)\n` + - `In stock: ${stats.in_stock.toLocaleString()}\n` + - `Database path: ${stats.db_path}` - }] - }; - } - return { - content: [{ - type: "text", - text: `JLCPCB database not found or empty.\n\n` + - `Run download_jlcpcb_database first to populate the database.` - }] - }; - } - ); - - // Suggest alternative parts - server.tool( - "suggest_jlcpcb_alternatives", - `Suggest alternative JLCPCB parts for a given component. - -Finds similar parts that may be cheaper, have more stock, or are Basic library type. -Useful for cost optimization and finding alternatives when parts are out of stock.`, - { - lcsc_number: z.string() - .describe("Reference LCSC part number to find alternatives for"), - limit: z.number().optional().default(5) - .describe("Maximum number of alternatives to return") - }, - async (args: { lcsc_number: string; limit?: number }) => { - const result = await callKicadScript("suggest_jlcpcb_alternatives", args); - if (result.success && result.alternatives) { - if (result.alternatives.length === 0) { - return { - content: [{ - type: "text", - text: `No alternatives found for ${args.lcsc_number}` - }] - }; - } - - const altsList = result.alternatives.map((p: any, i: number) => { - const priceInfo = p.price_breaks && p.price_breaks.length > 0 - ? ` - $${p.price_breaks[0].price}/ea` - : ''; - const savings = result.reference_price && p.price_breaks && p.price_breaks.length > 0 - ? ` (${((1 - p.price_breaks[0].price / result.reference_price) * 100).toFixed(0)}% cheaper)` - : ''; - return `${i + 1}. ${p.lcsc}: ${p.mfr_part} [${p.library_type}]${priceInfo}${savings}\n ${p.description}\n Stock: ${p.stock}`; - }).join('\n\n'); - - return { - content: [{ - type: "text", - text: `Alternative parts for ${args.lcsc_number}:\n\n${altsList}` - }] - }; - } - return { - content: [{ - type: "text", - text: `Failed to find alternatives: ${result.message || 'Unknown error'}` - }] - }; - } - ); -} +/** + * JLCPCB API tools for KiCAD MCP server + * Provides access to JLCPCB's complete parts catalog via their API + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +export function registerJLCPCBApiTools(server: McpServer, callKicadScript: Function) { + // Download JLCPCB parts database + server.tool( + "download_jlcpcb_database", + `Download the complete JLCPCB parts catalog to local database. + +This is a one-time setup that downloads ~2.5M+ parts from JLCSearch API. +No API credentials required - uses public JLCSearch API. + +The download takes 5-10 minutes and creates a local SQLite database +for fast offline searching.`, + { + force: z + .boolean() + .optional() + .default(false) + .describe("Force re-download even if database exists"), + }, + async (args: { force?: boolean }) => { + const result = await callKicadScript("download_jlcpcb_database", args); + if (result.success) { + return { + content: [ + { + type: "text", + text: + `✓ Successfully downloaded JLCPCB parts database\n\n` + + `Total parts: ${result.total_parts}\n` + + `Basic parts: ${result.basic_parts}\n` + + `Extended parts: ${result.extended_parts}\n` + + `Database size: ${result.db_size_mb} MB\n` + + `Database path: ${result.db_path}`, + }, + ], + }; + } + return { + content: [ + { + type: "text", + text: + `✗ Failed to download JLCPCB database: ${result.message || "Unknown error"}\n\n` + + `Make sure JLCPCB_API_KEY and JLCPCB_API_SECRET environment variables are set.`, + }, + ], + }; + }, + ); + + // Search JLCPCB parts + server.tool( + "search_jlcpcb_parts", + `Search JLCPCB parts catalog by specifications. + +Searches the local JLCPCB database (must be downloaded first with download_jlcpcb_database). +Provides real pricing, stock info, and library type (Basic parts = free assembly). + +Use this to find components with exact specifications and cost optimization.`, + { + query: z + .string() + .optional() + .describe("Free-text search (e.g., '10k resistor 0603', 'ESP32', 'STM32F103')"), + category: z + .string() + .optional() + .describe("Filter by category (e.g., 'Resistors', 'Capacitors', 'Microcontrollers')"), + package: z + .string() + .optional() + .describe("Filter by package type (e.g., '0603', 'SOT-23', 'QFN-32')"), + library_type: z + .enum(["Basic", "Extended", "Preferred", "All"]) + .optional() + .default("All") + .describe("Filter by library type (Basic = free assembly at JLCPCB)"), + manufacturer: z.string().optional().describe("Filter by manufacturer name"), + in_stock: z + .boolean() + .optional() + .default(true) + .describe("Only show parts with available stock"), + limit: z.number().optional().default(20).describe("Maximum number of results to return"), + }, + async (args: any) => { + const result = await callKicadScript("search_jlcpcb_parts", args); + if (result.success && result.parts) { + if (result.parts.length === 0) { + return { + content: [ + { + type: "text", + text: + `No JLCPCB parts found matching your criteria.\n\n` + + `Try broadening your search or check if the database is populated.`, + }, + ], + }; + } + + const partsList = result.parts + .map((p: any) => { + const priceInfo = + p.price_breaks && p.price_breaks.length > 0 + ? ` - $${p.price_breaks[0].price}/ea` + : ""; + const stockInfo = p.stock > 0 ? ` (${p.stock} in stock)` : " (out of stock)"; + return `${p.lcsc}: ${p.mfr_part} - ${p.description} [${p.library_type}]${priceInfo}${stockInfo}`; + }) + .join("\n"); + + return { + content: [ + { + type: "text", + text: + `Found ${result.count} JLCPCB parts:\n\n${partsList}\n\n` + + `💡 Basic parts have free assembly. Extended parts charge $3 setup fee per unique part.`, + }, + ], + }; + } + return { + content: [ + { + type: "text", + text: + `Failed to search JLCPCB parts: ${result.message || "Unknown error"}\n\n` + + `Make sure you've downloaded the database first using download_jlcpcb_database.`, + }, + ], + }; + }, + ); + + // Get JLCPCB part details + server.tool( + "get_jlcpcb_part", + "Get detailed information about a specific JLCPCB part by LCSC number", + { + lcsc_number: z.string().describe("LCSC part number (e.g., 'C25804', 'C2286')"), + }, + async (args: { lcsc_number: string }) => { + const result = await callKicadScript("get_jlcpcb_part", args); + if (result.success && result.part) { + const p = result.part; + const priceTable = + p.price_breaks && p.price_breaks.length > 0 + ? "\n\nPrice Breaks:\n" + + p.price_breaks.map((pb: any) => ` ${pb.qty}+: $${pb.price}/ea`).join("\n") + : ""; + + const footprints = + result.footprints && result.footprints.length > 0 + ? "\n\nSuggested KiCAD Footprints:\n" + + result.footprints.map((f: string) => ` - ${f}`).join("\n") + : ""; + + return { + content: [ + { + type: "text", + text: + `LCSC: ${p.lcsc}\n` + + `MFR Part: ${p.mfr_part}\n` + + `Manufacturer: ${p.manufacturer}\n` + + `Category: ${p.category} / ${p.subcategory}\n` + + `Package: ${p.package}\n` + + `Description: ${p.description}\n` + + `Library Type: ${p.library_type} ${p.library_type === "Basic" ? "(Free assembly!)" : ""}\n` + + `Stock: ${p.stock}\n` + + (p.datasheet ? `Datasheet: ${p.datasheet}\n` : "") + + priceTable + + footprints, + }, + ], + }; + } + return { + content: [ + { + type: "text", + text: + `Part not found: ${args.lcsc_number}\n\n` + + `Make sure you've downloaded the JLCPCB database first.`, + }, + ], + }; + }, + ); + + // Get JLCPCB database statistics + server.tool( + "get_jlcpcb_database_stats", + "Get statistics about the local JLCPCB parts database", + {}, + async () => { + const result = await callKicadScript("get_jlcpcb_database_stats", {}); + if (result.success) { + const stats = result.stats; + return { + content: [ + { + type: "text", + text: + `JLCPCB Database Statistics:\n\n` + + `Total parts: ${stats.total_parts.toLocaleString()}\n` + + `Basic parts: ${stats.basic_parts.toLocaleString()} (free assembly)\n` + + `Extended parts: ${stats.extended_parts.toLocaleString()} ($3 setup fee each)\n` + + `In stock: ${stats.in_stock.toLocaleString()}\n` + + `Database path: ${stats.db_path}`, + }, + ], + }; + } + return { + content: [ + { + type: "text", + text: + `JLCPCB database not found or empty.\n\n` + + `Run download_jlcpcb_database first to populate the database.`, + }, + ], + }; + }, + ); + + // Suggest alternative parts + server.tool( + "suggest_jlcpcb_alternatives", + `Suggest alternative JLCPCB parts for a given component. + +Finds similar parts that may be cheaper, have more stock, or are Basic library type. +Useful for cost optimization and finding alternatives when parts are out of stock.`, + { + lcsc_number: z.string().describe("Reference LCSC part number to find alternatives for"), + limit: z.number().optional().default(5).describe("Maximum number of alternatives to return"), + }, + async (args: { lcsc_number: string; limit?: number }) => { + const result = await callKicadScript("suggest_jlcpcb_alternatives", args); + if (result.success && result.alternatives) { + if (result.alternatives.length === 0) { + return { + content: [ + { + type: "text", + text: `No alternatives found for ${args.lcsc_number}`, + }, + ], + }; + } + + const altsList = result.alternatives + .map((p: any, i: number) => { + const priceInfo = + p.price_breaks && p.price_breaks.length > 0 + ? ` - $${p.price_breaks[0].price}/ea` + : ""; + const savings = + result.reference_price && p.price_breaks && p.price_breaks.length > 0 + ? ` (${((1 - p.price_breaks[0].price / result.reference_price) * 100).toFixed(0)}% cheaper)` + : ""; + return `${i + 1}. ${p.lcsc}: ${p.mfr_part} [${p.library_type}]${priceInfo}${savings}\n ${p.description}\n Stock: ${p.stock}`; + }) + .join("\n\n"); + + return { + content: [ + { + type: "text", + text: `Alternative parts for ${args.lcsc_number}:\n\n${altsList}`, + }, + ], + }; + } + return { + content: [ + { + type: "text", + text: `Failed to find alternatives: ${result.message || "Unknown error"}`, + }, + ], + }; + }, + ); +} diff --git a/src/tools/library-symbol.ts b/src/tools/library-symbol.ts index 8222a63..7b3cb14 100644 --- a/src/tools/library-symbol.ts +++ b/src/tools/library-symbol.ts @@ -3,8 +3,8 @@ * Provides search/browse access to local KiCad symbol libraries */ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; export function registerSymbolLibraryTools(server: McpServer, callKicadScript: Function) { // List available symbol libraries @@ -19,20 +19,20 @@ export function registerSymbolLibraryTools(server: McpServer, callKicadScript: F content: [ { type: "text", - text: `Found ${result.count} symbol libraries:\n${result.libraries.join('\n')}` - } - ] + text: `Found ${result.count} symbol libraries:\n${result.libraries.join("\n")}`, + }, + ], }; } return { content: [ { type: "text", - text: `Failed to list symbol libraries: ${result.message || 'Unknown error'}` - } - ] + text: `Failed to list symbol libraries: ${result.message || "Unknown error"}`, + }, + ], }; - } + }, ); // Search for symbols across all libraries @@ -45,12 +45,12 @@ Use this to find components already in your local libraries (e.g., JLCPCB-KiCad- Returns symbol references that can be used directly in schematics.`, { - query: z.string() - .describe("Search query (e.g., 'ESP32', 'STM32F103', 'C8734' for LCSC ID)"), - library: z.string().optional() + query: z.string().describe("Search query (e.g., 'ESP32', 'STM32F103', 'C8734' for LCSC ID)"), + library: z + .string() + .optional() .describe("Optional: filter to specific library name pattern (e.g., 'JLCPCB')"), - limit: z.number().optional().default(20) - .describe("Maximum number of results to return") + limit: z.number().optional().default(20).describe("Maximum number of results to return"), }, async (args: { query: string; library?: string; limit?: number }) => { const result = await callKicadScript("search_symbols", args); @@ -60,38 +60,40 @@ Returns symbol references that can be used directly in schematics.`, content: [ { type: "text", - text: `No symbols found matching "${args.query}"${args.library ? ` in libraries matching "${args.library}"` : ''}` - } - ] + text: `No symbols found matching "${args.query}"${args.library ? ` in libraries matching "${args.library}"` : ""}`, + }, + ], }; } - const symbolList = result.symbols.map((s: any) => { - const parts = [`${s.full_ref}`]; - if (s.lcsc_id) parts.push(`LCSC: ${s.lcsc_id}`); - if (s.description) parts.push(s.description); - else if (s.value) parts.push(s.value); - return parts.join(' | '); - }).join('\n'); + const symbolList = result.symbols + .map((s: any) => { + const parts = [`${s.full_ref}`]; + if (s.lcsc_id) parts.push(`LCSC: ${s.lcsc_id}`); + if (s.description) parts.push(s.description); + else if (s.value) parts.push(s.value); + return parts.join(" | "); + }) + .join("\n"); return { content: [ { type: "text", - text: `Found ${result.count} symbols matching "${args.query}":\n\n${symbolList}` - } - ] + text: `Found ${result.count} symbols matching "${args.query}":\n\n${symbolList}`, + }, + ], }; } return { content: [ { type: "text", - text: `Failed to search symbols: ${result.message || 'Unknown error'}` - } - ] + text: `Failed to search symbols: ${result.message || "Unknown error"}`, + }, + ], }; - } + }, ); // List symbols in a specific library @@ -99,36 +101,37 @@ Returns symbol references that can be used directly in schematics.`, "list_library_symbols", "List all symbols in a specific KiCAD symbol library", { - library: z.string() - .describe("Library name (e.g., 'Device', 'PCM_JLCPCB-MCUs')") + library: z.string().describe("Library name (e.g., 'Device', 'PCM_JLCPCB-MCUs')"), }, async (args: { library: string }) => { const result = await callKicadScript("list_library_symbols", args); if (result.success && result.symbols) { - const symbolList = result.symbols.map((s: any) => { - const parts = [` - ${s.name}`]; - if (s.lcsc_id) parts.push(`(LCSC: ${s.lcsc_id})`); - return parts.join(' '); - }).join('\n'); + const symbolList = result.symbols + .map((s: any) => { + const parts = [` - ${s.name}`]; + if (s.lcsc_id) parts.push(`(LCSC: ${s.lcsc_id})`); + return parts.join(" "); + }) + .join("\n"); return { content: [ { type: "text", - text: `Library "${args.library}" contains ${result.count} symbols:\n${symbolList}` - } - ] + text: `Library "${args.library}" contains ${result.count} symbols:\n${symbolList}`, + }, + ], }; } return { content: [ { type: "text", - text: `Failed to list symbols in library ${args.library}: ${result.message || 'Unknown error'}` - } - ] + text: `Failed to list symbols in library ${args.library}: ${result.message || "Unknown error"}`, + }, + ], }; - } + }, ); // Get detailed information about a specific symbol @@ -136,8 +139,9 @@ Returns symbol references that can be used directly in schematics.`, "get_symbol_info", "Get detailed information about a specific symbol", { - symbol: z.string() - .describe("Symbol specification (e.g., 'Device:R' or 'PCM_JLCPCB-MCUs:STM32F103C8T6')") + symbol: z + .string() + .describe("Symbol specification (e.g., 'Device:R' or 'PCM_JLCPCB-MCUs:STM32F103C8T6')"), }, async (args: { symbol: string }) => { const result = await callKicadScript("get_symbol_info", args); @@ -145,34 +149,36 @@ Returns symbol references that can be used directly in schematics.`, const info = result.symbol_info; const details = [ `Symbol: ${info.full_ref}`, - info.value ? `Value: ${info.value}` : '', - info.description ? `Description: ${info.description}` : '', - info.lcsc_id ? `LCSC: ${info.lcsc_id}` : '', - info.manufacturer ? `Manufacturer: ${info.manufacturer}` : '', - info.mpn ? `MPN: ${info.mpn}` : '', - info.footprint ? `Footprint: ${info.footprint}` : '', - info.category ? `Category: ${info.category}` : '', - info.lib_class ? `Class: ${info.lib_class}` : '', - info.datasheet ? `Datasheet: ${info.datasheet}` : '', - ].filter(line => line).join('\n'); + info.value ? `Value: ${info.value}` : "", + info.description ? `Description: ${info.description}` : "", + info.lcsc_id ? `LCSC: ${info.lcsc_id}` : "", + info.manufacturer ? `Manufacturer: ${info.manufacturer}` : "", + info.mpn ? `MPN: ${info.mpn}` : "", + info.footprint ? `Footprint: ${info.footprint}` : "", + info.category ? `Category: ${info.category}` : "", + info.lib_class ? `Class: ${info.lib_class}` : "", + info.datasheet ? `Datasheet: ${info.datasheet}` : "", + ] + .filter((line) => line) + .join("\n"); return { content: [ { type: "text", - text: details - } - ] + text: details, + }, + ], }; } return { content: [ { type: "text", - text: `Failed to get symbol info: ${result.message || 'Unknown error'}` - } - ] + text: `Failed to get symbol info: ${result.message || "Unknown error"}`, + }, + ], }; - } + }, ); } diff --git a/src/tools/library.ts b/src/tools/library.ts index 2adeeac..386aba0 100644 --- a/src/tools/library.ts +++ b/src/tools/library.ts @@ -6,10 +6,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -export function registerLibraryTools( - server: McpServer, - callKicadScript: Function, -) { +export function registerLibraryTools(server: McpServer, callKicadScript: Function) { // List available footprint libraries server.tool( "list_libraries", @@ -48,18 +45,9 @@ export function registerLibraryTools( "search_footprints", "Search for footprints matching a pattern across all libraries", { - search_term: z - .string() - .describe("Search term or pattern to match footprint names"), - library: z - .string() - .optional() - .describe("Optional specific library to search in"), - limit: z - .number() - .optional() - .default(50) - .describe("Maximum number of results to return"), + search_term: z.string().describe("Search term or pattern to match footprint names"), + library: z.string().optional().describe("Optional specific library to search in"), + limit: z.number().optional().default(50).describe("Maximum number of results to return"), }, async (args: { search_term: string; library?: string; limit?: number }) => { const result = await callKicadScript("search_footprints", { @@ -99,25 +87,14 @@ export function registerLibraryTools( "list_library_footprints", "List all footprints in a specific KiCAD library", { - library_name: z - .string() - .describe("Name of the library to list footprints from"), - filter: z - .string() - .optional() - .describe("Optional filter pattern for footprint names"), - limit: z - .number() - .optional() - .default(100) - .describe("Maximum number of footprints to list"), + library_name: z.string().describe("Name of the library to list footprints from"), + filter: z.string().optional().describe("Optional filter pattern for footprint names"), + limit: z.number().optional().default(100).describe("Maximum number of footprints to list"), }, async (args: { library_name: string; filter?: string; limit?: number }) => { const result = await callKicadScript("list_library_footprints", args); if (result.success && result.footprints) { - const footprintList = result.footprints - .map((fp: string) => ` - ${fp}`) - .join("\n"); + const footprintList = result.footprints.map((fp: string) => ` - ${fp}`).join("\n"); return { content: [ { @@ -143,12 +120,8 @@ export function registerLibraryTools( "get_footprint_info", "Get detailed information about a specific footprint", { - library_name: z - .string() - .describe("Name of the library containing the footprint"), - footprint_name: z - .string() - .describe("Name of the footprint to get information about"), + library_name: z.string().describe("Name of the library containing the footprint"), + footprint_name: z.string().describe("Name of the footprint to get information about"), }, async (args: { library_name: string; footprint_name: string }) => { const result = await callKicadScript("get_footprint_info", args); @@ -156,15 +129,16 @@ export function registerLibraryTools( const info = result.info; // pads is a list of {number, type, shape} objects - const padsArray: Array<{ number: string; type: string; shape: string }> = - Array.isArray(info.pads) ? info.pads : []; + const padsArray: Array<{ number: string; type: string; shape: string }> = Array.isArray( + info.pads, + ) + ? info.pads + : []; const padsSummary = padsArray.length ? `${padsArray.length} pads: ${padsArray.map((p) => p.number).join(", ")}` : ""; const padsDetail = padsArray.length - ? padsArray - .map((p) => ` pad ${p.number}: ${p.type} ${p.shape}`) - .join("\n") + ? padsArray.map((p) => ` pad ${p.number}: ${p.type} ${p.shape}`).join("\n") : ""; const details = [ @@ -178,9 +152,7 @@ export function registerLibraryTools( info.courtyard ? `Courtyard size: ${info.courtyard.width}mm x ${info.courtyard.height}mm` : "", - info.attributes - ? `Attributes: ${JSON.stringify(info.attributes)}` - : "", + info.attributes ? `Attributes: ${JSON.stringify(info.attributes)}` : "", ] .filter((line) => line) .join("\n"); diff --git a/src/tools/project.ts b/src/tools/project.ts index 1e59179..edbbc93 100644 --- a/src/tools/project.ts +++ b/src/tools/project.ts @@ -1,99 +1,116 @@ -/** - * Project management tools for KiCAD MCP server - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; - -export function registerProjectTools(server: McpServer, callKicadScript: Function) { - // Create project tool - server.tool( - "create_project", - "Create a new KiCAD project", - { - path: z.string().describe("Project directory path"), - name: z.string().describe("Project name"), - }, - async (args: { path: string; name: string }) => { - const result = await callKicadScript("create_project", args); - return { - content: [{ - type: "text", - text: JSON.stringify(result, null, 2) - }] - }; - } - ); - - // Open project tool - server.tool( - "open_project", - "Open an existing KiCAD project", - { - filename: z.string().describe("Path to .kicad_pro or .kicad_pcb file"), - }, - async (args: { filename: string }) => { - const result = await callKicadScript("open_project", args); - return { - content: [{ - type: "text", - text: JSON.stringify(result, null, 2) - }] - }; - } - ); - - // Save project tool - server.tool( - "save_project", - "Save the current KiCAD project", - { - path: z.string().optional().describe("Optional new path to save to"), - }, - async (args: { path?: string }) => { - const result = await callKicadScript("save_project", args); - return { - content: [{ - type: "text", - text: JSON.stringify(result, null, 2) - }] - }; - } - ); - - // Get project info tool - server.tool( - "get_project_info", - "Get information about the current KiCAD project", - {}, - async () => { - const result = await callKicadScript("get_project_info", {}); - return { - content: [{ - type: "text", - text: JSON.stringify(result, null, 2) - }] - }; - } - ); - - // Snapshot project tool — saves a named checkpoint as PDF/image - server.tool( - "snapshot_project", - "Save a named checkpoint snapshot of the current project state (renders board to PDF and records step label). Call after completing each major step — e.g. after Step 1 (schematic_ok) and Step 2 (layout_ok). Required by the demo workflow before waiting for user confirmation.", - { - step: z.string().describe("Step number or identifier, e.g. '1' or '2'"), - label: z.string().describe("Short label for this checkpoint, e.g. 'schematic_ok' or 'layout_ok'"), - prompt: z.string().optional().describe("Full prompt text to save as PROMPT_step{step}_{timestamp}.md alongside the snapshot"), - }, - async (args: { step: string; label: string; prompt?: string }) => { - const result = await callKicadScript("snapshot_project", args); - return { - content: [{ - type: "text", - text: JSON.stringify(result, null, 2) - }] - }; - } - ); -} +/** + * Project management tools for KiCAD MCP server + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +export function registerProjectTools(server: McpServer, callKicadScript: Function) { + // Create project tool + server.tool( + "create_project", + "Create a new KiCAD project", + { + path: z.string().describe("Project directory path"), + name: z.string().describe("Project name"), + }, + async (args: { path: string; name: string }) => { + const result = await callKicadScript("create_project", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + // Open project tool + server.tool( + "open_project", + "Open an existing KiCAD project", + { + filename: z.string().describe("Path to .kicad_pro or .kicad_pcb file"), + }, + async (args: { filename: string }) => { + const result = await callKicadScript("open_project", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + // Save project tool + server.tool( + "save_project", + "Save the current KiCAD project", + { + path: z.string().optional().describe("Optional new path to save to"), + }, + async (args: { path?: string }) => { + const result = await callKicadScript("save_project", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + // Get project info tool + server.tool( + "get_project_info", + "Get information about the current KiCAD project", + {}, + async () => { + const result = await callKicadScript("get_project_info", {}); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + // Snapshot project tool — saves a named checkpoint as PDF/image + server.tool( + "snapshot_project", + "Save a named checkpoint snapshot of the current project state (renders board to PDF and records step label). Call after completing each major step — e.g. after Step 1 (schematic_ok) and Step 2 (layout_ok). Required by the demo workflow before waiting for user confirmation.", + { + step: z.string().describe("Step number or identifier, e.g. '1' or '2'"), + label: z + .string() + .describe("Short label for this checkpoint, e.g. 'schematic_ok' or 'layout_ok'"), + prompt: z + .string() + .optional() + .describe( + "Full prompt text to save as PROMPT_step{step}_{timestamp}.md alongside the snapshot", + ), + }, + async (args: { step: string; label: string; prompt?: string }) => { + const result = await callKicadScript("snapshot_project", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); +} diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 4799052..1ef1103 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -1,308 +1,295 @@ -/** - * Tool Registry for KiCAD MCP Server - * - * Centralizes all tool definitions and provides lookup/search functionality - */ - -import { z } from 'zod'; - -export interface ToolDefinition { - name: string; - description: string; - inputSchema: z.ZodObject | z.ZodType; - // Handler will be registered separately in the existing tool files -} - -export interface ToolCategory { - name: string; - description: string; - tools: string[]; // Tool names in this category -} - -/** - * Tool category definitions - * Each category groups related tools for better organization - */ -export const toolCategories: ToolCategory[] = [ - { - name: "board", - description: "Board configuration: layers, mounting holes, zones, visualization", - tools: [ - "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" - ] - }, - { - name: "component", - description: "Advanced component operations: edit, delete, search, group, annotate", - tools: [ - "rotate_component", - "delete_component", - "edit_component", - "find_component", - "get_component_properties", - "add_component_annotation", - "group_components", - "replace_component" - ] - }, - { - name: "export", - description: "File export for fabrication and documentation: Gerber, PDF, BOM, 3D models", - tools: [ - "export_gerber", - "export_pdf", - "export_svg", - "export_3d", - "export_bom", - "export_netlist", - "export_position_file", - "export_vrml" - ] - }, - { - name: "drc", - description: "Design rule checking and electrical validation: DRC, net classes, clearances", - tools: [ - "set_design_rules", - "get_design_rules", - "run_drc", - "add_net_class", - "assign_net_to_class", - "set_layer_constraints", - "check_clearance", - "get_drc_violations" - ] - }, - { - name: "schematic", - description: "Schematic operations: create, inspect, add/edit/delete components, wire connections, netlists, annotation", - tools: [ - "create_schematic", - "add_schematic_component", - "list_schematic_components", - "move_schematic_component", - "rotate_schematic_component", - "annotate_schematic", - "add_schematic_wire", - "delete_schematic_wire", - "add_schematic_junction", - "add_schematic_net_label", - "delete_schematic_net_label", - "connect_to_net", - "connect_passthrough", - "get_net_connections", - "list_schematic_nets", - "list_schematic_wires", - "list_schematic_labels", - "get_wire_connections", - "generate_netlist", - "sync_schematic_to_board", - "get_schematic_view", - "export_schematic_svg", - "export_schematic_pdf" - ] - }, - { - name: "library", - description: "Footprint library access: search, browse, get footprint information", - tools: [ - "list_libraries", - "search_footprints", - "list_library_footprints", - "get_footprint_info" - ] - }, - { - name: "routing", - description: "Advanced routing operations: vias, copper pours", - tools: [ - "add_via", - "add_copper_pour" - ] - }, - { - name: "autoroute", - description: "Freerouting autorouter: automatic PCB routing via Specctra DSN/SES", - tools: [ - "autoroute", - "export_dsn", - "import_ses", - "check_freerouting" - ] - } -]; - -/** - * Direct tools that are always visible (not routed) - * These are the most frequently used tools - */ -export const directToolNames = [ - // Project lifecycle - "create_project", - "open_project", - "save_project", - "snapshot_project", - "get_project_info", - - // Core PCB operations - "place_component", - "move_component", - "add_net", - "route_trace", - "get_board_info", - "set_board_size", - - // Board setup - "add_board_outline", - - // Schematic essentials (always visible so AI uses them correctly) - "add_schematic_component", - "list_schematic_components", - "annotate_schematic", - "connect_passthrough", - "connect_to_net", - "add_schematic_net_label", - - // Schematic <-> PCB sync (F8 equivalent) - "sync_schematic_to_board", - - // UI management - "check_kicad_ui" -]; - -// Build lookup maps at module load time -const categoryMap = new Map(); -const toolCategoryMap = new Map(); - -export function initializeRegistry() { - // Build category map - for (const category of toolCategories) { - categoryMap.set(category.name, category); - - // Build tool -> category map - for (const toolName of category.tools) { - toolCategoryMap.set(toolName, category.name); - } - } -} - -/** - * Get a category by name - */ -export function getCategory(name: string): ToolCategory | undefined { - return categoryMap.get(name); -} - -/** - * Get the category name for a tool - */ -export function getToolCategory(toolName: string): string | undefined { - return toolCategoryMap.get(toolName); -} - -/** - * Get all categories - */ -export function getAllCategories(): ToolCategory[] { - return toolCategories; -} - -/** - * Get all routed tool names (excludes direct tools) - */ -export function getRoutedToolNames(): string[] { - const allRoutedTools: string[] = []; - for (const category of toolCategories) { - allRoutedTools.push(...category.tools); - } - return allRoutedTools; -} - -/** - * Check if a tool is a direct tool - */ -export function isDirectTool(toolName: string): boolean { - return directToolNames.includes(toolName); -} - -/** - * Check if a tool is a routed tool - */ -export function isRoutedTool(toolName: string): boolean { - return toolCategoryMap.has(toolName); -} - -/** - * Search for tools by keyword - * Searches tool names, descriptions, and category names - */ -export interface SearchResult { - category: string; - tool: string; - description: string; -} - -export function searchTools(query: string): SearchResult[] { - const q = query.toLowerCase(); - const matches: SearchResult[] = []; - - // Search direct tools first - for (const toolName of directToolNames) { - if (toolName.toLowerCase().includes(q)) { - matches.push({ - category: "direct", - tool: toolName, - description: `${toolName} (direct tool — call directly, no execute_tool needed)` - }); - } - } - - // Search routed tools by name and category - for (const category of toolCategories) { - const categoryMatch = - category.name.toLowerCase().includes(q) || - category.description.toLowerCase().includes(q); - - for (const toolName of category.tools) { - if (toolName.toLowerCase().includes(q) || categoryMatch) { - matches.push({ - category: category.name, - tool: toolName, - description: `${toolName} (${category.name})` - }); - } - } - } - - return matches.slice(0, 20); // Limit results -} - -/** - * Get statistics about the tool registry - */ -export function getRegistryStats() { - const routedToolCount = getRoutedToolNames().length; - const directToolCount = directToolNames.length; - - return { - total_categories: toolCategories.length, - total_routed_tools: routedToolCount, - total_direct_tools: directToolCount, - total_tools: routedToolCount + directToolCount, - categories: toolCategories.map(c => ({ - name: c.name, - tool_count: c.tools.length - })) - }; -} - -// Initialize on module load -initializeRegistry(); +/** + * Tool Registry for KiCAD MCP Server + * + * Centralizes all tool definitions and provides lookup/search functionality + */ + +import { z } from "zod"; + +export interface ToolDefinition { + name: string; + description: string; + inputSchema: z.ZodObject | z.ZodType; + // Handler will be registered separately in the existing tool files +} + +export interface ToolCategory { + name: string; + description: string; + tools: string[]; // Tool names in this category +} + +/** + * Tool category definitions + * Each category groups related tools for better organization + */ +export const toolCategories: ToolCategory[] = [ + { + name: "board", + description: "Board configuration: layers, mounting holes, zones, visualization", + tools: [ + "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", + ], + }, + { + name: "component", + description: "Advanced component operations: edit, delete, search, group, annotate", + tools: [ + "rotate_component", + "delete_component", + "edit_component", + "find_component", + "get_component_properties", + "add_component_annotation", + "group_components", + "replace_component", + ], + }, + { + name: "export", + description: "File export for fabrication and documentation: Gerber, PDF, BOM, 3D models", + tools: [ + "export_gerber", + "export_pdf", + "export_svg", + "export_3d", + "export_bom", + "export_netlist", + "export_position_file", + "export_vrml", + ], + }, + { + name: "drc", + description: "Design rule checking and electrical validation: DRC, net classes, clearances", + tools: [ + "set_design_rules", + "get_design_rules", + "run_drc", + "add_net_class", + "assign_net_to_class", + "set_layer_constraints", + "check_clearance", + "get_drc_violations", + ], + }, + { + name: "schematic", + description: + "Schematic operations: create, inspect, add/edit/delete components, wire connections, netlists, annotation", + tools: [ + "create_schematic", + "add_schematic_component", + "list_schematic_components", + "move_schematic_component", + "rotate_schematic_component", + "annotate_schematic", + "add_schematic_wire", + "delete_schematic_wire", + "add_schematic_junction", + "add_schematic_net_label", + "delete_schematic_net_label", + "connect_to_net", + "connect_passthrough", + "get_net_connections", + "list_schematic_nets", + "list_schematic_wires", + "list_schematic_labels", + "get_wire_connections", + "generate_netlist", + "sync_schematic_to_board", + "get_schematic_view", + "export_schematic_svg", + "export_schematic_pdf", + ], + }, + { + name: "library", + description: "Footprint library access: search, browse, get footprint information", + tools: ["list_libraries", "search_footprints", "list_library_footprints", "get_footprint_info"], + }, + { + name: "routing", + description: "Advanced routing operations: vias, copper pours", + tools: ["add_via", "add_copper_pour"], + }, + { + name: "autoroute", + description: "Freerouting autorouter: automatic PCB routing via Specctra DSN/SES", + tools: ["autoroute", "export_dsn", "import_ses", "check_freerouting"], + }, +]; + +/** + * Direct tools that are always visible (not routed) + * These are the most frequently used tools + */ +export const directToolNames = [ + // Project lifecycle + "create_project", + "open_project", + "save_project", + "snapshot_project", + "get_project_info", + + // Core PCB operations + "place_component", + "move_component", + "add_net", + "route_trace", + "get_board_info", + "set_board_size", + + // Board setup + "add_board_outline", + + // Schematic essentials (always visible so AI uses them correctly) + "add_schematic_component", + "list_schematic_components", + "annotate_schematic", + "connect_passthrough", + "connect_to_net", + "add_schematic_net_label", + + // Schematic <-> PCB sync (F8 equivalent) + "sync_schematic_to_board", + + // UI management + "check_kicad_ui", +]; + +// Build lookup maps at module load time +const categoryMap = new Map(); +const toolCategoryMap = new Map(); + +export function initializeRegistry() { + // Build category map + for (const category of toolCategories) { + categoryMap.set(category.name, category); + + // Build tool -> category map + for (const toolName of category.tools) { + toolCategoryMap.set(toolName, category.name); + } + } +} + +/** + * Get a category by name + */ +export function getCategory(name: string): ToolCategory | undefined { + return categoryMap.get(name); +} + +/** + * Get the category name for a tool + */ +export function getToolCategory(toolName: string): string | undefined { + return toolCategoryMap.get(toolName); +} + +/** + * Get all categories + */ +export function getAllCategories(): ToolCategory[] { + return toolCategories; +} + +/** + * Get all routed tool names (excludes direct tools) + */ +export function getRoutedToolNames(): string[] { + const allRoutedTools: string[] = []; + for (const category of toolCategories) { + allRoutedTools.push(...category.tools); + } + return allRoutedTools; +} + +/** + * Check if a tool is a direct tool + */ +export function isDirectTool(toolName: string): boolean { + return directToolNames.includes(toolName); +} + +/** + * Check if a tool is a routed tool + */ +export function isRoutedTool(toolName: string): boolean { + return toolCategoryMap.has(toolName); +} + +/** + * Search for tools by keyword + * Searches tool names, descriptions, and category names + */ +export interface SearchResult { + category: string; + tool: string; + description: string; +} + +export function searchTools(query: string): SearchResult[] { + const q = query.toLowerCase(); + const matches: SearchResult[] = []; + + // Search direct tools first + for (const toolName of directToolNames) { + if (toolName.toLowerCase().includes(q)) { + matches.push({ + category: "direct", + tool: toolName, + description: `${toolName} (direct tool — call directly, no execute_tool needed)`, + }); + } + } + + // Search routed tools by name and category + for (const category of toolCategories) { + const categoryMatch = + category.name.toLowerCase().includes(q) || category.description.toLowerCase().includes(q); + + for (const toolName of category.tools) { + if (toolName.toLowerCase().includes(q) || categoryMatch) { + matches.push({ + category: category.name, + tool: toolName, + description: `${toolName} (${category.name})`, + }); + } + } + } + + return matches.slice(0, 20); // Limit results +} + +/** + * Get statistics about the tool registry + */ +export function getRegistryStats() { + const routedToolCount = getRoutedToolNames().length; + const directToolCount = directToolNames.length; + + return { + total_categories: toolCategories.length, + total_routed_tools: routedToolCount, + total_direct_tools: directToolCount, + total_tools: routedToolCount + directToolCount, + categories: toolCategories.map((c) => ({ + name: c.name, + tool_count: c.tools.length, + })), + }; +} + +// Initialize on module load +initializeRegistry(); diff --git a/src/tools/router.ts b/src/tools/router.ts index 8a1d060..437aa51 100644 --- a/src/tools/router.ts +++ b/src/tools/router.ts @@ -1,251 +1,296 @@ -/** - * Router Tools for KiCAD MCP Server - * - * Provides discovery and execution of routed tools - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import { logger } from '../logger.js'; -import { - getAllCategories, - getCategory, - getToolCategory, - searchTools as registrySearchTools, - getRegistryStats -} from './registry.js'; - -// Command function type for KiCAD script calls -type CommandFunction = (command: string, params: Record) => Promise; - -// Map to store tool execution handlers -// This will be populated by registerToolHandler() -const toolHandlers = new Map Promise>(); - -/** - * Register a tool handler for execution via execute_tool - * This should be called by each tool registration function - */ -export function registerToolHandler( - toolName: string, - handler: (params: any) => Promise -): void { - toolHandlers.set(toolName, handler); - logger.debug(`Registered handler for routed tool: ${toolName}`); -} - -/** - * Register all router tools with the MCP server - */ -export function registerRouterTools(server: McpServer, callKicadScript: CommandFunction): void { - logger.info('Registering router tools'); - - // ============================================================================ - // list_tool_categories - // ============================================================================ - server.tool( - "list_tool_categories", - { - // No parameters - }, - async () => { - logger.debug('Listing tool categories'); - - const stats = getRegistryStats(); - const categories = getAllCategories(); - - const result = { - total_categories: stats.total_categories, - total_routed_tools: stats.total_routed_tools, - total_direct_tools: stats.total_direct_tools, - note: "Use get_category_tools to see tools in each category. Direct tools are always available.", - categories: categories.map(c => ({ - name: c.name, - description: c.description, - tool_count: c.tools.length - })) - }; - - return { - content: [{ - type: "text", - text: JSON.stringify(result, null, 2) - }] - }; - } - ); - - // ============================================================================ - // get_category_tools - // ============================================================================ - server.tool( - "get_category_tools", - { - category: z.string().describe("Category name from list_tool_categories") - }, - async ({ category }) => { - logger.debug(`Getting tools for category: ${category}`); - - const categoryData = getCategory(category); - - if (!categoryData) { - const availableCategories = getAllCategories().map(c => c.name); - return { - content: [{ - type: "text", - text: JSON.stringify({ - error: `Unknown category: ${category}`, - available_categories: availableCategories - }, null, 2) - }] - }; - } - - // Return tool names and basic info - // Full schema is available via tool introspection once tool is called - const result = { - category: categoryData.name, - description: categoryData.description, - tool_count: categoryData.tools.length, - tools: categoryData.tools.map(toolName => ({ - name: toolName, - description: `Use execute_tool with tool_name="${toolName}" to run this tool` - })), - note: "Use execute_tool to run any of these tools with appropriate parameters" - }; - - return { - content: [{ - type: "text", - text: JSON.stringify(result, null, 2) - }] - }; - } - ); - - // ============================================================================ - // execute_tool - // ============================================================================ - server.tool( - "execute_tool", - { - tool_name: z.string().describe("Tool name from get_category_tools"), - params: z.record(z.unknown()).optional().describe("Tool parameters (optional)") - }, - async ({ tool_name, params }) => { - logger.info(`Executing routed tool: ${tool_name}`); - - // Check if tool exists in registry - const category = getToolCategory(tool_name); - - if (!category) { - return { - content: [{ - type: "text", - text: JSON.stringify({ - error: `Unknown tool: ${tool_name}`, - hint: "Use list_tool_categories and get_category_tools to find available tools" - }, null, 2) - }] - }; - } - - // Get the handler - const handler = toolHandlers.get(tool_name); - - if (!handler) { - // Tool is in registry but handler not registered yet - // This means the tool exists but hasn't been migrated to router pattern yet - // Fall back to calling KiCAD script directly - logger.warn(`Tool ${tool_name} in registry but no handler registered, falling back to direct call`); - - try { - const result = await callKicadScript(tool_name, params || {}); - return { - content: [{ - type: "text", - text: JSON.stringify({ - tool: tool_name, - category: category, - result: result - }, null, 2) - }] - }; - } catch (error) { - return { - content: [{ - type: "text", - text: JSON.stringify({ - error: `Tool execution failed: ${(error as Error).message}`, - tool: tool_name, - category: category - }, null, 2) - }] - }; - } - } - - // Execute the tool via its handler - try { - const result = await handler(params || {}); - - // The handler already returns MCP-formatted response - // Just add metadata - return { - content: [{ - type: "text", - text: JSON.stringify({ - tool: tool_name, - category: category, - ...result - }, null, 2) - }] - }; - } catch (error) { - return { - content: [{ - type: "text", - text: JSON.stringify({ - error: `Tool execution failed: ${(error as Error).message}`, - tool: tool_name, - category: category - }, null, 2) - }] - }; - } - } - ); - - // ============================================================================ - // search_tools - // ============================================================================ - server.tool( - "search_tools", - { - query: z.string().describe("Search term (e.g., 'gerber', 'zone', 'export', 'drc')") - }, - async ({ query }) => { - logger.debug(`Searching tools for: ${query}`); - - const matches = registrySearchTools(query); - - const result = { - query: query, - count: matches.length, - matches: matches, - note: matches.length > 0 - ? "Use execute_tool with the tool name to run it" - : "No tools found matching your query. Try list_tool_categories to browse all categories." - }; - - return { - content: [{ - type: "text", - text: JSON.stringify(result, null, 2) - }] - }; - } - ); - - logger.info('Router tools registered successfully'); -} +/** + * Router Tools for KiCAD MCP Server + * + * Provides discovery and execution of routed tools + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { logger } from "../logger.js"; +import { + getAllCategories, + getCategory, + getToolCategory, + searchTools as registrySearchTools, + getRegistryStats, +} from "./registry.js"; + +// Command function type for KiCAD script calls +type CommandFunction = (command: string, params: Record) => Promise; + +// Map to store tool execution handlers +// This will be populated by registerToolHandler() +const toolHandlers = new Map Promise>(); + +/** + * Register a tool handler for execution via execute_tool + * This should be called by each tool registration function + */ +export function registerToolHandler( + toolName: string, + handler: (params: any) => Promise, +): void { + toolHandlers.set(toolName, handler); + logger.debug(`Registered handler for routed tool: ${toolName}`); +} + +/** + * Register all router tools with the MCP server + */ +export function registerRouterTools(server: McpServer, callKicadScript: CommandFunction): void { + logger.info("Registering router tools"); + + // ============================================================================ + // list_tool_categories + // ============================================================================ + server.tool( + "list_tool_categories", + { + // No parameters + }, + async () => { + logger.debug("Listing tool categories"); + + const stats = getRegistryStats(); + const categories = getAllCategories(); + + const result = { + total_categories: stats.total_categories, + total_routed_tools: stats.total_routed_tools, + total_direct_tools: stats.total_direct_tools, + note: "Use get_category_tools to see tools in each category. Direct tools are always available.", + categories: categories.map((c) => ({ + name: c.name, + description: c.description, + tool_count: c.tools.length, + })), + }; + + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + // ============================================================================ + // get_category_tools + // ============================================================================ + server.tool( + "get_category_tools", + { + category: z.string().describe("Category name from list_tool_categories"), + }, + async ({ category }) => { + logger.debug(`Getting tools for category: ${category}`); + + const categoryData = getCategory(category); + + if (!categoryData) { + const availableCategories = getAllCategories().map((c) => c.name); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + error: `Unknown category: ${category}`, + available_categories: availableCategories, + }, + null, + 2, + ), + }, + ], + }; + } + + // Return tool names and basic info + // Full schema is available via tool introspection once tool is called + const result = { + category: categoryData.name, + description: categoryData.description, + tool_count: categoryData.tools.length, + tools: categoryData.tools.map((toolName) => ({ + name: toolName, + description: `Use execute_tool with tool_name="${toolName}" to run this tool`, + })), + note: "Use execute_tool to run any of these tools with appropriate parameters", + }; + + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + // ============================================================================ + // execute_tool + // ============================================================================ + server.tool( + "execute_tool", + { + tool_name: z.string().describe("Tool name from get_category_tools"), + params: z.record(z.unknown()).optional().describe("Tool parameters (optional)"), + }, + async ({ tool_name, params }) => { + logger.info(`Executing routed tool: ${tool_name}`); + + // Check if tool exists in registry + const category = getToolCategory(tool_name); + + if (!category) { + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + error: `Unknown tool: ${tool_name}`, + hint: "Use list_tool_categories and get_category_tools to find available tools", + }, + null, + 2, + ), + }, + ], + }; + } + + // Get the handler + const handler = toolHandlers.get(tool_name); + + if (!handler) { + // Tool is in registry but handler not registered yet + // This means the tool exists but hasn't been migrated to router pattern yet + // Fall back to calling KiCAD script directly + logger.warn( + `Tool ${tool_name} in registry but no handler registered, falling back to direct call`, + ); + + try { + const result = await callKicadScript(tool_name, params || {}); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + tool: tool_name, + category: category, + result: result, + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + error: `Tool execution failed: ${(error as Error).message}`, + tool: tool_name, + category: category, + }, + null, + 2, + ), + }, + ], + }; + } + } + + // Execute the tool via its handler + try { + const result = await handler(params || {}); + + // The handler already returns MCP-formatted response + // Just add metadata + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + tool: tool_name, + category: category, + ...result, + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + error: `Tool execution failed: ${(error as Error).message}`, + tool: tool_name, + category: category, + }, + null, + 2, + ), + }, + ], + }; + } + }, + ); + + // ============================================================================ + // search_tools + // ============================================================================ + server.tool( + "search_tools", + { + query: z.string().describe("Search term (e.g., 'gerber', 'zone', 'export', 'drc')"), + }, + async ({ query }) => { + logger.debug(`Searching tools for: ${query}`); + + const matches = registrySearchTools(query); + + const result = { + query: query, + count: matches.length, + matches: matches, + note: + matches.length > 0 + ? "Use execute_tool with the tool name to run it" + : "No tools found matching your query. Try list_tool_categories to browse all categories.", + }; + + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + logger.info("Router tools registered successfully"); +} diff --git a/src/tools/routing.ts b/src/tools/routing.ts index 309bd2d..3ead958 100644 --- a/src/tools/routing.ts +++ b/src/tools/routing.ts @@ -5,10 +5,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -export function registerRoutingTools( - server: McpServer, - callKicadScript: Function, -) { +export function registerRoutingTools(server: McpServer, callKicadScript: Function) { // Add net tool server.tool( "add_net", @@ -79,10 +76,7 @@ export function registerRoutingTools( }) .describe("Via position"), net: z.string().describe("Net name"), - viaType: z - .string() - .optional() - .describe("Via type (through, blind, buried)"), + viaType: z.string().optional().describe("Via type (through, blind, buried)"), }, async (args: any) => { const result = await callKicadScript("add_via", args); @@ -130,10 +124,7 @@ export function registerRoutingTools( "delete_trace", "Delete traces from the PCB. Can delete by UUID, position, or bulk-delete all traces on a net.", { - traceUuid: z - .string() - .optional() - .describe("UUID of a specific trace to delete"), + traceUuid: z.string().optional().describe("UUID of a specific trace to delete"), position: z .object({ x: z.number(), @@ -142,18 +133,9 @@ export function registerRoutingTools( }) .optional() .describe("Delete trace nearest to this position"), - net: z - .string() - .optional() - .describe("Delete all traces on this net (bulk delete)"), - layer: z - .string() - .optional() - .describe("Filter by layer when using net-based deletion"), - includeVias: z - .boolean() - .optional() - .describe("Include vias in net-based deletion"), + net: z.string().optional().describe("Delete all traces on this net (bulk delete)"), + layer: z.string().optional().describe("Filter by layer when using net-based deletion"), + includeVias: z.boolean().optional().describe("Include vias in net-based deletion"), }, async (args: any) => { const result = await callKicadScript("delete_trace", args); @@ -209,10 +191,7 @@ export function registerRoutingTools( .boolean() .optional() .describe("Include statistics (track count, total length, etc.)"), - unit: z - .enum(["mm", "inch"]) - .optional() - .describe("Unit for length measurements"), + unit: z.enum(["mm", "inch"]).optional().describe("Unit for length measurements"), }, async (args: any) => { const result = await callKicadScript("get_nets_list", args); @@ -334,9 +313,13 @@ export function registerRoutingTools( "PREFERRED tool for pad-to-pad routing. Looks up pad positions automatically, detects the net from the pad, and — critically — if the two pads are on different copper layers (e.g. J1 on F.Cu and J2 on B.Cu) automatically inserts a via at the midpoint so the connection is complete. Always use this instead of route_trace when routing between named component pads.", { fromRef: z.string().describe("Reference of the source component (e.g. 'U2')"), - fromPad: z.union([z.string(), z.number()]).describe("Pad number on the source component (e.g. '6' or 6)"), + fromPad: z + .union([z.string(), z.number()]) + .describe("Pad number on the source component (e.g. '6' or 6)"), toRef: z.string().describe("Reference of the target component (e.g. 'U1')"), - toPad: z.union([z.string(), z.number()]).describe("Pad number on the target component (e.g. '15' or 15)"), + toPad: z + .union([z.string(), z.number()]) + .describe("Pad number on the target component (e.g. '15' or 15)"), layer: z.string().optional().describe("PCB layer (default: F.Cu)"), width: z.number().optional().describe("Trace width in mm (default: board default)"), net: z.string().optional().describe("Net name override (default: auto-detected from pad)"), @@ -362,10 +345,7 @@ export function registerRoutingTools( .describe( "References of the target components in same order as sourceRefs (e.g. ['U2', 'R2', 'C2'])", ), - includeVias: z - .boolean() - .optional() - .describe("Also copy vias (default: true)"), + includeVias: z.boolean().optional().describe("Also copy vias (default: true)"), traceWidth: z .number() .optional() diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index 1fcf905..ad75abd 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -5,10 +5,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -export function registerSchematicTools( - server: McpServer, - callKicadScript: Function, -) { +export function registerSchematicTools(server: McpServer, callKicadScript: Function) { // Create schematic tool server.tool( "create_schematic", @@ -38,9 +35,7 @@ export function registerSchematicTools( schematicPath: z.string().describe("Path to the schematic file"), symbol: z .string() - .describe( - "Symbol library:name reference (e.g., Device:R, EDA-MCP:ESP32-C3)", - ), + .describe("Symbol library:name reference (e.g., Device:R, EDA-MCP:ESP32-C3)"), reference: z.string().describe("Component reference (e.g., R1, U1)"), value: z.string().optional().describe("Component value"), footprint: z @@ -82,10 +77,7 @@ export function registerSchematicTools( }, }; - const result = await callKicadScript( - "add_schematic_component", - transformed, - ); + const result = await callKicadScript("add_schematic_component", transformed); if (result.success) { return { content: [ @@ -122,9 +114,7 @@ To remove a footprint from a PCB, use delete_component instead.`, schematicPath: z.string().describe("Path to the .kicad_sch file"), reference: z .string() - .describe( - "Reference designator of the component to remove (e.g. R1, U3)", - ), + .describe("Reference designator of the component to remove (e.g. R1, U3)"), }, async (args: { schematicPath: string; reference: string }) => { const result = await callKicadScript("delete_schematic_component", args); @@ -162,14 +152,27 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp { schematicPath: z.string().describe("Path to the .kicad_sch file"), reference: z.string().describe("Current reference designator of the component (e.g. R1, U3)"), - footprint: z.string().optional().describe("New KiCAD footprint string (e.g. Resistor_SMD:R_0603_1608Metric)"), + footprint: z + .string() + .optional() + .describe("New KiCAD footprint string (e.g. Resistor_SMD:R_0603_1608Metric)"), value: z.string().optional().describe("New value string (e.g. 10k, 100nF)"), - newReference: z.string().optional().describe("Rename the reference designator (e.g. R1 → R10)"), - fieldPositions: z.record(z.object({ - x: z.number(), - y: z.number(), - angle: z.number().optional().default(0), - })).optional().describe("Reposition field labels: map of field name to {x, y, angle} (e.g. {\"Reference\": {\"x\": 12.5, \"y\": 17.0}})"), + newReference: z + .string() + .optional() + .describe("Rename the reference designator (e.g. R1 → R10)"), + fieldPositions: z + .record( + z.object({ + x: z.number(), + y: z.number(), + angle: z.number().optional().default(0), + }), + ) + .optional() + .describe( + 'Reposition field labels: map of field name to {x, y, angle} (e.g. {"Reference": {"x": 12.5, "y": 17.0}})', + ), }, async (args: { schematicPath: string; @@ -220,20 +223,24 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp : "unknown"; const fieldLines = Object.entries(result.fields ?? {}).map( ([name, f]: [string, any]) => - ` ${name}: "${f.value}" @ (${f.x}, ${f.y}, angle=${f.angle}°)` + ` ${name}: "${f.value}" @ (${f.x}, ${f.y}, angle=${f.angle}°)`, ); return { - content: [{ - type: "text", - text: `Component ${result.reference} at ${pos}\nFields:\n${fieldLines.join("\n")}`, - }], + content: [ + { + type: "text", + text: `Component ${result.reference} at ${pos}\nFields:\n${fieldLines.join("\n")}`, + }, + ], }; } return { - content: [{ - type: "text", - text: `Failed to get component: ${result.message || "Unknown error"}`, - }], + content: [ + { + type: "text", + text: `Failed to get component: ${result.message || "Unknown error"}`, + }, + ], }; }, ); @@ -251,13 +258,8 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp snapToPins: z .boolean() .optional() - .describe( - "Snap the first and last waypoints to the nearest pin (default: true)", - ), - snapTolerance: z - .number() - .optional() - .describe("Maximum snap distance in mm (default: 1.0)"), + .describe("Snap the first and last waypoints to the nearest pin (default: true)"), + snapTolerance: z.number().optional().describe("Maximum snap distance in mm (default: 1.0)"), }, async (args: { schematicPath: string; @@ -294,10 +296,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp "Place a junction dot at a wire intersection in the schematic. Required at T-branch and X-cross points so KiCAD recognises the electrical connection.", { schematicPath: z.string().describe("Path to the .kicad_sch file"), - position: z - .array(z.number()) - .length(2) - .describe("Junction position [x, y] in mm"), + position: z.array(z.number()).length(2).describe("Junction position [x, y] in mm"), }, async (args: { schematicPath: string; position: number[] }) => { const result = await callKicadScript("add_schematic_junction", args); @@ -329,19 +328,10 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp "Add a net label to the schematic", { schematicPath: z.string().describe("Path to the schematic file"), - netName: z - .string() - .describe("Name of the net (e.g., VCC, GND, SIGNAL_1)"), - position: z - .array(z.number()) - .length(2) - .describe("Position [x, y] for the label"), + netName: z.string().describe("Name of the net (e.g., VCC, GND, SIGNAL_1)"), + position: z.array(z.number()).length(2).describe("Position [x, y] for the label"), }, - async (args: { - schematicPath: string; - netName: string; - position: number[]; - }) => { + async (args: { schematicPath: string; netName: string; position: number[] }) => { const result = await callKicadScript("add_schematic_net_label", args); if (result.success) { return { @@ -451,9 +441,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp async (args: { schematicPath: string; x: number; y: number }) => { const result = await callKicadScript("get_wire_connections", args); if (result.success && result.pins) { - const pinList = result.pins - .map((p: any) => ` - ${p.component}/${p.pin}`) - .join("\n"); + const pinList = result.pins.map((p: any) => ` - ${p.component}/${p.pin}`).join("\n"); const wireList = (result.wires ?? []) .map((w: any) => ` - (${w.start.x},${w.start.y}) → (${w.end.x},${w.end.y})`) .join("\n"); @@ -484,9 +472,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp "Returns the exact x/y coordinates of every pin on a schematic component. Use this before add_schematic_net_label to place labels correctly on pin endpoints.", { schematicPath: z.string().describe("Path to the schematic file"), - reference: z - .string() - .describe("Component reference designator (e.g. U1, R1, J2)"), + reference: z.string().describe("Component reference designator (e.g. U1, R1, J2)"), }, async (args: { schematicPath: string; reference: string }) => { const result = await callKicadScript("get_schematic_pin_locations", args); @@ -541,23 +527,16 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp pinOffset?: number; }) => { const result = await callKicadScript("connect_passthrough", args); - if ( - result.success !== false || - (result.connected && result.connected.length > 0) - ) { + if (result.success !== false || (result.connected && result.connected.length > 0)) { const lines: string[] = []; if (result.connected?.length) lines.push( `Connected (${result.connected.length}): ${result.connected.slice(0, 5).join(", ")}${result.connected.length > 5 ? " ..." : ""}`, ); if (result.failed?.length) - lines.push( - `Failed (${result.failed.length}): ${result.failed.join(", ")}`, - ); + lines.push(`Failed (${result.failed.length}): ${result.failed.join(", ")}`); return { - content: [ - { type: "text", text: result.message + "\n" + lines.join("\n") }, - ], + content: [{ type: "text", text: result.message + "\n" + lines.join("\n") }], }; } else { return { @@ -581,7 +560,10 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp filter: z .object({ libId: z.string().optional().describe("Filter by library ID (e.g., 'Device:R')"), - referencePrefix: z.string().optional().describe("Filter by reference prefix (e.g., 'R', 'C', 'U')"), + referencePrefix: z + .string() + .optional() + .describe("Filter by reference prefix (e.g., 'R', 'C', 'U')"), }) .optional() .describe("Optional filters"), @@ -640,9 +622,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }; } const lines = nets.map((n: any) => { - const conns = (n.connections || []) - .map((c: any) => `${c.component}/${c.pin}`) - .join(", "); + const conns = (n.connections || []).map((c: any) => `${c.component}/${c.pin}`).join(", "); return ` ${n.name}: ${conns || "(no connections)"}`; }); return { @@ -680,8 +660,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }; } const lines = wires.map( - (w: any) => - ` (${w.start.x}, ${w.start.y}) → (${w.end.x}, ${w.end.y})`, + (w: any) => ` (${w.start.x}, ${w.start.y}) → (${w.end.x}, ${w.end.y})`, ); return { content: [ @@ -718,8 +697,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }; } const lines = labels.map( - (l: any) => - ` [${l.type}] ${l.name} at (${l.position.x}, ${l.position.y})`, + (l: any) => ` [${l.type}] ${l.name} at (${l.position.x}, ${l.position.y})`, ); return { content: [ @@ -789,10 +767,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp schematicPath: z.string().describe("Path to the .kicad_sch file"), reference: z.string().describe("Reference designator (e.g., R1, U1)"), angle: z.number().describe("Rotation angle in degrees (0, 90, 180, 270)"), - mirror: z - .enum(["x", "y"]) - .optional() - .describe("Optional mirror axis"), + mirror: z.enum(["x", "y"]).optional().describe("Optional mirror axis"), }, async (args: { schematicPath: string; @@ -836,14 +811,10 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp const annotated = result.annotated || []; if (annotated.length === 0) { return { - content: [ - { type: "text", text: "All components are already annotated." }, - ], + content: [{ type: "text", text: "All components are already annotated." }], }; } - const lines = annotated.map( - (a: any) => ` ${a.oldReference} → ${a.newReference}`, - ); + const lines = annotated.map((a: any) => ` ${a.oldReference} → ${a.newReference}`); return { content: [ { @@ -871,12 +842,8 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp "Remove a wire from the schematic by start and end coordinates.", { schematicPath: z.string().describe("Path to the .kicad_sch file"), - start: z - .object({ x: z.number(), y: z.number() }) - .describe("Wire start position"), - end: z - .object({ x: z.number(), y: z.number() }) - .describe("Wire end position"), + start: z.object({ x: z.number(), y: z.number() }).describe("Wire start position"), + end: z.object({ x: z.number(), y: z.number() }).describe("Wire end position"), }, async (args: { schematicPath: string; @@ -953,16 +920,9 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp { schematicPath: z.string().describe("Path to the .kicad_sch file"), outputPath: z.string().describe("Output SVG file path"), - blackAndWhite: z - .boolean() - .optional() - .describe("Export in black and white"), + blackAndWhite: z.boolean().optional().describe("Export in black and white"), }, - async (args: { - schematicPath: string; - outputPath: string; - blackAndWhite?: boolean; - }) => { + async (args: { schematicPath: string; outputPath: string; blackAndWhite?: boolean }) => { const result = await callKicadScript("export_schematic_svg", args); if (result.success) { return { @@ -993,16 +953,9 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp { schematicPath: z.string().describe("Path to the .kicad_sch file"), outputPath: z.string().describe("Output PDF file path"), - blackAndWhite: z - .boolean() - .optional() - .describe("Export in black and white"), + blackAndWhite: z.boolean().optional().describe("Export in black and white"), }, - async (args: { - schematicPath: string; - outputPath: string; - blackAndWhite?: boolean; - }) => { + async (args: { schematicPath: string; outputPath: string; blackAndWhite?: boolean }) => { const result = await callKicadScript("export_schematic_pdf", args); if (result.success) { return { @@ -1032,10 +985,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp "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.", { schematicPath: z.string().describe("Path to the .kicad_sch file"), - format: z - .enum(["png", "svg"]) - .optional() - .describe("Output format (default: png)"), + format: z.enum(["png", "svg"]).optional().describe("Output format (default: png)"), width: z.number().optional().describe("Image width in pixels (default: 1200)"), height: z.number().optional().describe("Image height in pixels (default: 900)"), }, @@ -1086,17 +1036,13 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp "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.", { - schematicPath: z - .string() - .describe("Path to the .kicad_sch schematic file"), + schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), }, async (args: { schematicPath: string }) => { const result = await callKicadScript("run_erc", args); if (result.success) { const violations: any[] = result.violations || []; - const lines: string[] = [ - `ERC result: ${violations.length} violation(s)`, - ]; + const lines: string[] = [`ERC result: ${violations.length} violation(s)`]; if (result.summary?.by_severity) { const s = result.summary.by_severity; lines.push( @@ -1183,12 +1129,8 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp "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.", { - schematicPath: z - .string() - .describe("Absolute path to the .kicad_sch schematic file"), - boardPath: z - .string() - .describe("Absolute path to the .kicad_pcb board file"), + schematicPath: z.string().describe("Absolute path to the .kicad_sch schematic file"), + boardPath: z.string().describe("Absolute path to the .kicad_pcb board file"), }, async (args: { schematicPath: string; boardPath: string }) => { const result = await callKicadScript("sync_schematic_to_board", args); @@ -1218,8 +1160,13 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, async (args: { schematicPath: string; - x1: number; y1: number; x2: number; y2: number; - format?: string; width?: number; height?: number; + x1: number; + y1: number; + x2: number; + y2: number; + format?: string; + width?: number; + height?: number; }) => { const result = await callKicadScript("get_schematic_view_region", args); if (result.success && result.imageData) { @@ -1227,11 +1174,13 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp return { content: [{ type: "text", text: result.imageData }] }; } return { - content: [{ - type: "image", - data: result.imageData, - mimeType: "image/png", - }], + content: [ + { + type: "image", + data: result.imageData, + mimeType: "image/png", + }, + ], }; } return { @@ -1240,14 +1189,18 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, ); - // Find overlapping elements server.tool( "find_overlapping_elements", "Detect spatially overlapping symbols, wires, and labels in the schematic. Finds duplicate power symbols at the same position, collinear overlapping wires, and labels stacked on top of each other.", { schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), - tolerance: z.number().optional().describe("Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection. (default: 0.5)"), + tolerance: z + .number() + .optional() + .describe( + "Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection. (default: 0.5)", + ), }, async (args: { schematicPath: string; tolerance?: number }) => { const result = await callKicadScript("find_overlapping_elements", args); @@ -1259,7 +1212,9 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp if (syms.length) { lines.push(`\nOverlapping symbols (${syms.length}):`); syms.slice(0, 20).forEach((o: any) => { - lines.push(` ${o.element1.reference} ↔ ${o.element2.reference} (${o.distance}mm) [${o.type}]`); + lines.push( + ` ${o.element1.reference} ↔ ${o.element2.reference} (${o.distance}mm) [${o.type}]`, + ); }); } if (lbls.length) { @@ -1271,7 +1226,9 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp if (wires.length) { lines.push(`\nOverlapping wires (${wires.length}):`); wires.slice(0, 20).forEach((o: any) => { - lines.push(` wire @ (${o.wire1.start.x},${o.wire1.start.y})→(${o.wire1.end.x},${o.wire1.end.y}) overlaps with another`); + lines.push( + ` wire @ (${o.wire1.start.x},${o.wire1.start.y})→(${o.wire1.end.x},${o.wire1.end.y}) overlaps with another`, + ); }); } return { content: [{ type: "text", text: lines.join("\n") }] }; @@ -1293,20 +1250,21 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp x2: z.number().describe("Right X coordinate of the region in mm"), y2: z.number().describe("Bottom Y coordinate of the region in mm"), }, - async (args: { - schematicPath: string; - x1: number; y1: number; x2: number; y2: number; - }) => { + async (args: { schematicPath: string; x1: number; y1: number; x2: number; y2: number }) => { const result = await callKicadScript("get_elements_in_region", args); if (result.success) { const c = result.counts; - const lines = [`Region (${args.x1},${args.y1})→(${args.x2},${args.y2}): ${c.symbols} symbols, ${c.wires} wires, ${c.labels} labels`]; + const lines = [ + `Region (${args.x1},${args.y1})→(${args.x2},${args.y2}): ${c.symbols} symbols, ${c.wires} wires, ${c.labels} labels`, + ]; const syms: any[] = result.symbols || []; if (syms.length) { lines.push("\nSymbols:"); syms.forEach((s: any) => { const pinCount = s.pins ? Object.keys(s.pins).length : 0; - lines.push(` ${s.reference} (${s.libId}) @ (${s.position.x}, ${s.position.y}) [${pinCount} pins]`); + lines.push( + ` ${s.reference} (${s.libId}) @ (${s.position.x}, ${s.position.y}) [${pinCount} pins]`, + ); }); } const wires: any[] = result.wires || []; @@ -1346,7 +1304,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp const lines = [`Found ${collisions.length} wire(s) crossing symbols:`]; collisions.slice(0, 30).forEach((c: any, i: number) => { lines.push( - ` ${i + 1}. Wire (${c.wire.start.x},${c.wire.start.y})→(${c.wire.end.x},${c.wire.end.y}) crosses ${c.component.reference} (${c.component.libId})` + ` ${i + 1}. Wire (${c.wire.start.x},${c.wire.start.y})→(${c.wire.end.x},${c.wire.end.y}) crosses ${c.component.reference} (${c.component.libId})`, ); }); if (collisions.length > 30) lines.push(` ... and ${collisions.length - 30} more`); diff --git a/src/tools/symbol-creator.ts b/src/tools/symbol-creator.ts index 046021e..d634407 100644 --- a/src/tools/symbol-creator.ts +++ b/src/tools/symbol-creator.ts @@ -15,45 +15,67 @@ const PinSchema = z.object({ number: z.union([z.string(), z.number()]).describe("Pin number, e.g. '1', '2', 'A1'"), type: z .enum([ - "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", ]) .describe("Electrical pin type"), - at: z.object({ - x: z.number().describe("X position in mm"), - y: z.number().describe("Y position in mm"), - angle: z.number().describe( - "Direction the pin wire extends FROM the symbol body: 0=right, 90=up, 180=left, 270=down" - ), - }).describe("Pin endpoint position (where the wire connects)"), + at: z + .object({ + x: z.number().describe("X position in mm"), + y: z.number().describe("Y position in mm"), + angle: z + .number() + .describe( + "Direction the pin wire extends FROM the symbol body: 0=right, 90=up, 180=left, 270=down", + ), + }) + .describe("Pin endpoint position (where the wire connects)"), length: z.number().optional().describe("Pin length in mm (default 2.54)"), shape: z - .enum(["line", "inverted", "clock", "inverted_clock", "input_low", - "clock_low", "output_low", "falling_edge_clock", "non_logic"]) + .enum([ + "line", + "inverted", + "clock", + "inverted_clock", + "input_low", + "clock_low", + "output_low", + "falling_edge_clock", + "non_logic", + ]) .optional() .describe("Pin graphic shape (default: line)"), }); const RectSchema = z.object({ - x1: z.number(), y1: z.number(), - x2: z.number(), y2: z.number(), + x1: z.number(), + y1: z.number(), + x2: z.number(), + y2: z.number(), width: z.number().optional().describe("Stroke width in mm (default 0.254)"), - fill: z.enum(["none", "outline", "background"]).optional() + fill: z + .enum(["none", "outline", "background"]) + .optional() .describe("Fill type (default: background)"), }); const PolylineSchema = z.object({ - points: z.array(z.object({ x: z.number(), y: z.number() })) - .describe("List of XY points in mm"), + points: z.array(z.object({ x: z.number(), y: z.number() })).describe("List of XY points in mm"), width: z.number().optional().describe("Stroke width in mm (default 0.254)"), fill: z.enum(["none", "outline", "background"]).optional(), }); -export function registerSymbolCreatorTools( - server: McpServer, - callKicadScript: Function, -) { +export function registerSymbolCreatorTools(server: McpServer, callKicadScript: Function) { // ── create_symbol ────────────────────────────────────────────────────── // server.tool( "create_symbol", @@ -68,14 +90,14 @@ export function registerSymbolCreatorTools( "- Pins on bottom: at.y = body_bottom - length, angle=90 (wire goes up)\n" + "- Standard pin length: 2.54 mm, standard grid: 2.54 mm", { - libraryPath: z - .string() - .describe("Path to the .kicad_sym file (created if missing)"), + libraryPath: z.string().describe("Path to the .kicad_sym file (created if missing)"), name: z.string().describe("Symbol name, e.g. 'TMC2209', 'MyOpAmp'"), referencePrefix: z .string() .optional() - .describe("Schematic reference prefix: 'U' (IC), 'R' (resistor), 'J' (connector), etc. Default: 'U'"), + .describe( + "Schematic reference prefix: 'U' (IC), 'R' (resistor), 'J' (connector), etc. Default: 'U'", + ), description: z.string().optional().describe("Human-readable description"), keywords: z.string().optional().describe("Space-separated search keywords"), datasheet: z.string().optional().describe("Datasheet URL or '~'"), @@ -161,9 +183,7 @@ export function registerSymbolCreatorTools( "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'.", { - libraryPath: z - .string() - .describe("Full path to the .kicad_sym file"), + libraryPath: z.string().describe("Full path to the .kicad_sym file"), libraryName: z .string() .optional() diff --git a/src/tools/ui.ts b/src/tools/ui.ts index 8ce9d2a..561110b 100644 --- a/src/tools/ui.ts +++ b/src/tools/ui.ts @@ -1,48 +1,52 @@ -/** - * UI/Process management tools for KiCAD MCP server - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import { logger } from '../logger.js'; - -export function registerUITools(server: McpServer, callKicadScript: Function) { - // Check if KiCAD UI is running - server.tool( - "check_kicad_ui", - "Check if KiCAD UI is currently running", - {}, - async () => { - logger.info('Checking KiCAD UI status'); - const result = await callKicadScript("check_kicad_ui", {}); - return { - content: [{ - type: "text", - text: JSON.stringify(result, null, 2) - }] - }; - } - ); - - // Launch KiCAD UI - server.tool( - "launch_kicad_ui", - "Launch KiCAD UI, optionally with a project file", - { - projectPath: z.string().optional().describe("Optional path to .kicad_pcb file to open"), - autoLaunch: z.boolean().optional().describe("Whether to launch KiCAD if not running (default: true)") - }, - async (args: { projectPath?: string; autoLaunch?: boolean }) => { - logger.info(`Launching KiCAD UI${args.projectPath ? ' with project: ' + args.projectPath : ''}`); - const result = await callKicadScript("launch_kicad_ui", args); - return { - content: [{ - type: "text", - text: JSON.stringify(result, null, 2) - }] - }; - } - ); - - logger.info('UI management tools registered'); -} +/** + * UI/Process management tools for KiCAD MCP server + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { logger } from "../logger.js"; + +export function registerUITools(server: McpServer, callKicadScript: Function) { + // Check if KiCAD UI is running + server.tool("check_kicad_ui", "Check if KiCAD UI is currently running", {}, async () => { + logger.info("Checking KiCAD UI status"); + const result = await callKicadScript("check_kicad_ui", {}); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }); + + // Launch KiCAD UI + server.tool( + "launch_kicad_ui", + "Launch KiCAD UI, optionally with a project file", + { + projectPath: z.string().optional().describe("Optional path to .kicad_pcb file to open"), + autoLaunch: z + .boolean() + .optional() + .describe("Whether to launch KiCAD if not running (default: true)"), + }, + async (args: { projectPath?: string; autoLaunch?: boolean }) => { + logger.info( + `Launching KiCAD UI${args.projectPath ? " with project: " + args.projectPath : ""}`, + ); + const result = await callKicadScript("launch_kicad_ui", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + logger.info("UI management tools registered"); +} diff --git a/src/utils/resource-helpers.ts b/src/utils/resource-helpers.ts index fa795c0..7c5c308 100644 --- a/src/utils/resource-helpers.ts +++ b/src/utils/resource-helpers.ts @@ -1,61 +1,71 @@ -/** - * Resource helper utilities for MCP resources - */ - -/** - * Create a JSON response for MCP resources - * - * @param data Data to serialize as JSON - * @param uri Optional URI for the resource - * @returns MCP resource response object - */ -export function createJsonResponse(data: any, uri?: string) { - return { - contents: [{ - uri: uri || "data:application/json", - mimeType: "application/json", - text: JSON.stringify(data, null, 2) - }] - }; -} - -/** - * Create a binary response for MCP resources - * - * @param data Binary data (Buffer or base64 string) - * @param mimeType MIME type of the binary data - * @param uri Optional URI for the resource - * @returns MCP resource response object - */ -export function createBinaryResponse(data: Buffer | string, mimeType: string, uri?: string) { - const blob = typeof data === 'string' ? data : data.toString('base64'); - - return { - contents: [{ - uri: uri || `data:${mimeType}`, - mimeType: mimeType, - blob: blob - }] - }; -} - -/** - * Create an error response for MCP resources - * - * @param error Error message - * @param details Optional error details - * @param uri Optional URI for the resource - * @returns MCP resource error response - */ -export function createErrorResponse(error: string, details?: string, uri?: string) { - return { - contents: [{ - uri: uri || "data:application/json", - mimeType: "application/json", - text: JSON.stringify({ - error, - details - }, null, 2) - }] - }; -} +/** + * Resource helper utilities for MCP resources + */ + +/** + * Create a JSON response for MCP resources + * + * @param data Data to serialize as JSON + * @param uri Optional URI for the resource + * @returns MCP resource response object + */ +export function createJsonResponse(data: any, uri?: string) { + return { + contents: [ + { + uri: uri || "data:application/json", + mimeType: "application/json", + text: JSON.stringify(data, null, 2), + }, + ], + }; +} + +/** + * Create a binary response for MCP resources + * + * @param data Binary data (Buffer or base64 string) + * @param mimeType MIME type of the binary data + * @param uri Optional URI for the resource + * @returns MCP resource response object + */ +export function createBinaryResponse(data: Buffer | string, mimeType: string, uri?: string) { + const blob = typeof data === "string" ? data : data.toString("base64"); + + return { + contents: [ + { + uri: uri || `data:${mimeType}`, + mimeType: mimeType, + blob: blob, + }, + ], + }; +} + +/** + * Create an error response for MCP resources + * + * @param error Error message + * @param details Optional error details + * @param uri Optional URI for the resource + * @returns MCP resource error response + */ +export function createErrorResponse(error: string, details?: string, uri?: string) { + return { + contents: [ + { + uri: uri || "data:application/json", + mimeType: "application/json", + text: JSON.stringify( + { + error, + details, + }, + null, + 2, + ), + }, + ], + }; +} diff --git a/tsconfig.json b/tsconfig.json index 8ef3a49..31fdce7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,14 +1,14 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "esModuleInterop": true, - "strict": true, - "outDir": "dist", - "declaration": true, - "sourceMap": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "strict": true, + "outDir": "dist", + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From fc70a4785c56dd1bb7c125d0dcb0156a5b663225 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 29 Mar 2026 13:07:39 +0100 Subject: [PATCH 40/74] chore: add Flake8 linting config with baseline ignore rules Adds .flake8 config and registers the flake8 pre-commit hook scoped to python/ at rev 7.3.0. Baseline ignore rules (E501, F401, F541, F841, E722, E402, E741, F811, F821, F824, E231) suppress existing violations so the hook passes cleanly on the current codebase. Co-Authored-By: Claude Opus 4.6 --- .flake8 | 24 ++++++++++++++++++++++++ .pre-commit-config.yaml | 7 +++++++ 2 files changed, 31 insertions(+) create mode 100644 .flake8 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..00211c0 --- /dev/null +++ b/.flake8 @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e34355f..f0600a7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,3 +28,10 @@ repos: hooks: - id: prettier 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] From 3456a1df26df0949929108f5c79a652c2a838973 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 29 Mar 2026 13:10:03 +0100 Subject: [PATCH 41/74] chore: add MyPy type checking config with baseline ignore rules Adds [tool.mypy] section to pyproject.toml with permissive baseline settings and disables error codes that fire against the existing untyped codebase. Adds the mirrors-mypy pre-commit hook (v1.19.1) running over python/ at once. Co-Authored-By: Claude Opus 4.6 --- .pre-commit-config.yaml | 9 +++++++++ pyproject.toml | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f0600a7..226d6ae 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,3 +35,12 @@ repos: - id: flake8 files: ^python/ args: [--config=.flake8] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: "v1.19.1" + hooks: + - id: mypy + files: ^python/ + args: [--config-file=pyproject.toml] + pass_filenames: false + additional_dependencies: [] diff --git a/pyproject.toml b/pyproject.toml index 9ab3555..c0ee5c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,3 +10,27 @@ target-version = ["py310"] [tool.isort] profile = "black" line_length = 100 + +[tool.mypy] +python_version = "3.10" +warn_return_any = false +warn_unused_configs = true +ignore_missing_imports = true +check_untyped_defs = false +disallow_untyped_defs = false +disable_error_code = [ + "arg-type", + "attr-defined", + "union-attr", + "call-arg", + "assignment", + "var-annotated", + "annotation-unchecked", + "index", + "has-type", + "dict-item", + "misc", + "list-item", + "return-value", + "operator", +] From dbbbde17bad91c7cd2ef2f0a9503799e23442aca Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 29 Mar 2026 13:15:15 +0100 Subject: [PATCH 42/74] chore: add ESLint with TypeScript support and baseline config Install eslint, @eslint/js, and typescript-eslint as dev dependencies. Add eslint.config.js (flat config) with rules tuned to zero errors on the current codebase, add ESLint local hook to .pre-commit-config.yaml, and update the lint:ts npm script to use ESLint directly. Co-Authored-By: Claude Opus 4.6 --- .pre-commit-config.yaml | 9 + eslint.config.js | 19 + package-lock.json | 4358 ++++++++++++++++++++++++--------------- package.json | 7 +- 4 files changed, 2782 insertions(+), 1611 deletions(-) create mode 100644 eslint.config.js diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 226d6ae..6cd75df 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -44,3 +44,12 @@ repos: args: [--config-file=pyproject.toml] pass_filenames: false additional_dependencies: [] + + - repo: local + hooks: + - id: eslint + name: eslint + entry: npx eslint --fix + language: node + types: [ts] + files: ^src/ diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..73cb4f9 --- /dev/null +++ b/eslint.config.js @@ -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"], + }, +); diff --git a/package-lock.json b/package-lock.json index b3d4947..7e77ba1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,1609 +1,2749 @@ -{ - "name": "kicad-mcp", - "version": "2.1.0-alpha", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "kicad-mcp", - "version": "2.1.0-alpha", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/sdk": "^1.21.0", - "dotenv": "^17.0.0", - "express": "^5.1.0", - "zod": "^3.25.0" - }, - "devDependencies": { - "@cfworker/json-schema": "^4.1.1", - "@types/express": "^5.0.5", - "@types/glob": "^8.1.0", - "@types/node": "^20.19.0", - "nodemon": "^3.0.1", - "prettier": "^3.8.1", - "typescript": "^5.9.3" - } - }, - "node_modules/@cfworker/json-schema": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", - "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.21.0.tgz", - "integrity": "sha512-YFBsXJMFCyI1zP98u7gezMFKX4lgu/XpoZJk7ufI6UlFKXLj2hAMUuRlQX/nrmIPOmhRrG6tw2OQ2ZA/ZlXYpQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - } - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.5", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", - "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/express": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", - "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "^1" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", - "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/minimatch": "^5.1.2", - "@types/node": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/minimatch": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", - "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "20.19.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", - "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/qs": { - "version": "6.9.18", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", - "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/send": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", - "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "*" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.0", - "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventsource": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", - "integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz", - "integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.0", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", - "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": "^4.11 || 5 || ^5.0.0-beta.1" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ignore-by-default": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", - "dev": true, - "license": "ISC" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/nodemon": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", - "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.2", - "debug": "^4", - "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", - "pstree.remy": "^1.1.8", - "semver": "^7.5.3", - "simple-update-notifier": "^2.0.0", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.5" - }, - "bin": { - "nodemon": "bin/nodemon.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nodemon" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", - "license": "MIT", - "engines": { - "node": ">=16" - } - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pkce-challenge": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", - "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/pstree.remy": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", - "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", - "dev": true, - "license": "MIT" - }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/touch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", - "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", - "dev": true, - "license": "ISC", - "bin": { - "nodetouch": "bin/nodetouch.js" - } - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undefsafe": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", - "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", - "dev": true, - "license": "MIT" - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.24.5", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", - "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.24.1" - } - } - } -} +{ + "name": "kicad-mcp", + "version": "2.1.0-alpha", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kicad-mcp", + "version": "2.1.0-alpha", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.21.0", + "dotenv": "^17.0.0", + "express": "^5.1.0", + "zod": "^3.25.0" + }, + "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", + "prettier": "^3.8.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.57.2" + } + }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.3", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", + "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.21.0.tgz", + "integrity": "sha512-YFBsXJMFCyI1zP98u7gezMFKX4lgu/XpoZJk7ufI6UlFKXLj2hAMUuRlQX/nrmIPOmhRrG6tw2OQ2ZA/ZlXYpQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + } + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", + "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", + "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "^5.1.2", + "@types/node": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.24", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", + "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.9.18", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", + "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", + "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.3", + "@eslint/config-helpers": "^0.5.3", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", + "integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz", + "integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", + "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "^4.11 || 5 || ^5.0.0-beta.1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", + "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.2", + "@typescript-eslint/parser": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.5", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", + "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + } + } +} diff --git a/package.json b/package.json index 1d4c9a5..2e6576b 100644 --- a/package.json +++ b/package.json @@ -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,11 +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", "prettier": "^3.8.1", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "typescript-eslint": "^8.57.2" } } From bc1e33b6529cc84d463ba5c16476971de8d625ab Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 29 Mar 2026 13:21:05 +0100 Subject: [PATCH 43/74] fix: resolve pre-commit hook issues for mypy and root-level files - Fix mypy duplicate module conflict by excluding python/commands/board.py - Add import-untyped to mypy disabled error codes - Use explicit_package_bases for proper module resolution - Auto-format root-level download_jlcpcb.py and test-router.js Co-Authored-By: Claude Opus 4.6 --- .pre-commit-config.yaml | 2 +- download_jlcpcb.py | 113 ++++++++++++++++++++++++---------------- pyproject.toml | 3 ++ test-router.js | 87 ++++++++++++++++--------------- 4 files changed, 118 insertions(+), 87 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6cd75df..2befb7f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,8 +41,8 @@ repos: hooks: - id: mypy files: ^python/ + exclude: ^python/commands/board\.py$ args: [--config-file=pyproject.toml] - pass_filenames: false additional_dependencies: [] - repo: local diff --git a/download_jlcpcb.py b/download_jlcpcb.py index 1e7cae2..63c34a8 100644 --- a/download_jlcpcb.py +++ b/download_jlcpcb.py @@ -9,11 +9,12 @@ 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 os -import sys -import subprocess -import sqlite3 + import json +import os +import sqlite3 +import subprocess +import sys import time from pathlib import Path @@ -40,8 +41,7 @@ def download_files(): url = f"{BASE_URL}/{part}" print(f" Downloading {part}...") result = subprocess.run( - ["curl", "-L", "-o", str(dest), "--progress-bar", url], - capture_output=False + ["curl", "-L", "-o", str(dest), "--progress-bar", url], capture_output=False ) if result.returncode != 0: print(f" ERROR downloading {part}") @@ -62,7 +62,8 @@ def extract_database(): try: result = subprocess.run( [cmd, "x", "-y", "-o" + str(CACHE_DIR), str(CACHE_DIR / "cache.zip")], - capture_output=True, text=True + capture_output=True, + text=True, ) if result.returncode == 0: print(f"Extracted with {cmd}") @@ -88,9 +89,9 @@ def convert_to_mcp_format(): src.row_factory = sqlite3.Row # Check schema - tables = [r[0] for r in src.execute( - "SELECT name FROM sqlite_master WHERE type='table'" - ).fetchall()] + 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 @@ -115,7 +116,7 @@ def convert_to_mcp_format(): # Create target DB in MCP format dst = sqlite3.connect(str(TARGET_DB)) - dst.execute(''' + dst.execute(""" CREATE TABLE components ( lcsc TEXT PRIMARY KEY, category TEXT, @@ -131,12 +132,12 @@ def convert_to_mcp_format(): 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)') + """) + 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: @@ -150,81 +151,103 @@ def convert_to_mcp_format(): 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') + 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'): + 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 '' + 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') + 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' + lib_type = "Basic" elif is_preferred: - lib_type = 'Preferred' + lib_type = "Preferred" else: - lib_type = 'Extended' + lib_type = "Extended" # Price - price = row_dict.get('price') or row_dict.get('Price') or 0 + 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 - )) + 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(''' + 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) + """, + batch, + ) count += len(batch) batch = [] if count % 100000 == 0: print(f" Converted {count:,} parts...") if batch: - dst.executemany(''' + 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) + """, + batch, + ) count += len(batch) # Build FTS index print(f" Building full-text search index...") - dst.execute(''' + 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] + extended = dst.execute( + "SELECT COUNT(*) FROM components WHERE library_type='Extended'" + ).fetchone()[0] dst.close() src.close() diff --git a/pyproject.toml b/pyproject.toml index c0ee5c1..5dd0998 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,10 @@ warn_unused_configs = true ignore_missing_imports = true check_untyped_defs = false disallow_untyped_defs = false +explicit_package_bases = true +mypy_path = "." disable_error_code = [ + "import-untyped", "arg-type", "attr-defined", "union-attr", diff --git a/test-router.js b/test-router.js index 1328db9..4af02ce 100644 --- a/test-router.js +++ b/test-router.js @@ -1,41 +1,46 @@ -/** - * Quick test of router tool registry - * Run with: node test-router.js - */ - -import { getAllCategories, searchTools, getRegistryStats, isDirectTool } from './dist/tools/registry.js'; - -console.log('='.repeat(70)); -console.log('KICAD MCP ROUTER - TEST'); -console.log('='.repeat(70)); - -// Test 1: Registry Stats -console.log('\n📊 Registry Statistics:'); -const stats = getRegistryStats(); -console.log(JSON.stringify(stats, null, 2)); - -// Test 2: List Categories -console.log('\n📁 Tool Categories:'); -const categories = getAllCategories(); -categories.forEach(cat => { - console.log(` - ${cat.name}: ${cat.description} (${cat.tools.length} tools)`); -}); - -// Test 3: Search -console.log('\n🔍 Search Test: "export gerber"'); -const results = searchTools('gerber'); -console.log(`Found ${results.length} matches:`); -results.forEach(result => { - console.log(` - ${result.tool} (${result.category})`); -}); - -// Test 4: Direct Tools Check -console.log('\n✅ Direct Tools Test:'); -console.log(` - create_project is direct: ${isDirectTool('create_project')}`); -console.log(` - place_component is direct: ${isDirectTool('place_component')}`); -console.log(` - export_gerber is direct: ${isDirectTool('export_gerber')}`); -console.log(` - add_via is direct: ${isDirectTool('add_via')}`); - -console.log('\n' + '='.repeat(70)); -console.log('✅ Router tests complete!'); -console.log('='.repeat(70)); +/** + * Quick test of router tool registry + * Run with: node test-router.js + */ + +import { + getAllCategories, + searchTools, + getRegistryStats, + isDirectTool, +} from "./dist/tools/registry.js"; + +console.log("=".repeat(70)); +console.log("KICAD MCP ROUTER - TEST"); +console.log("=".repeat(70)); + +// Test 1: Registry Stats +console.log("\n📊 Registry Statistics:"); +const stats = getRegistryStats(); +console.log(JSON.stringify(stats, null, 2)); + +// Test 2: List Categories +console.log("\n📁 Tool Categories:"); +const categories = getAllCategories(); +categories.forEach((cat) => { + console.log(` - ${cat.name}: ${cat.description} (${cat.tools.length} tools)`); +}); + +// Test 3: Search +console.log('\n🔍 Search Test: "export gerber"'); +const results = searchTools("gerber"); +console.log(`Found ${results.length} matches:`); +results.forEach((result) => { + console.log(` - ${result.tool} (${result.category})`); +}); + +// Test 4: Direct Tools Check +console.log("\n✅ Direct Tools Test:"); +console.log(` - create_project is direct: ${isDirectTool("create_project")}`); +console.log(` - place_component is direct: ${isDirectTool("place_component")}`); +console.log(` - export_gerber is direct: ${isDirectTool("export_gerber")}`); +console.log(` - add_via is direct: ${isDirectTool("add_via")}`); + +console.log("\n" + "=".repeat(70)); +console.log("✅ Router tests complete!"); +console.log("=".repeat(70)); From f39937e8b81aa4f2f35f5e307310b2be8bf2fc3b Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 29 Mar 2026 13:35:26 +0100 Subject: [PATCH 44/74] fix: use local prettier from package.json and clean up prettierignore - Replace mirrors-prettier alpha (v4.0.0-alpha.8) with local hook using the stable prettier installed via package.json - Remove .prettierignore entries already handled by the hook's exclude pattern Co-Authored-By: Claude Opus 4.6 --- .pre-commit-config.yaml | 7 +++++-- .prettierignore | 5 ----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2befb7f..d3340d2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,10 +23,13 @@ repos: - id: isort args: [--settings-path=pyproject.toml] - - repo: https://github.com/pre-commit/mirrors-prettier - rev: v4.0.0-alpha.8 + - 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 diff --git a/.prettierignore b/.prettierignore index 3910bf6..00f706a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,9 +1,4 @@ -dist/ -node_modules/ coverage.xml htmlcov/ data/ *.kicad_* -python/ -package-lock.json -.venv/ From a152b75db30a8a2cf26c1a7efe8cae2346c25d80 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 29 Mar 2026 20:18:38 +0100 Subject: [PATCH 45/74] feat: move_schematic_component with wire preservation (drag behavior) When moving a schematic component, connected wires are stretched/shifted to follow the component (like KiCAD's drag behaviour), preserving connectivity instead of leaving dangling wire stubs. Also fixes property labels (value, reference, etc.) so they shift with the symbol rather than staying at their original positions. Co-Authored-By: Claude Sonnet 4.6 --- python/commands/wire_dragger.py | 311 ++++++++ python/kicad_interface.py | 103 ++- .../tests/test_move_with_wire_preservation.py | 661 ++++++++++++++++++ src/tools/schematic.ts | 50 ++ 4 files changed, 1100 insertions(+), 25 deletions(-) create mode 100644 python/commands/wire_dragger.py create mode 100644 python/tests/test_move_with_wire_preservation.py diff --git a/python/commands/wire_dragger.py b/python/commands/wire_dragger.py new file mode 100644 index 0000000..910236b --- /dev/null +++ b/python/commands/wire_dragger.py @@ -0,0 +1,311 @@ +""" +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 +from typing import Dict, 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", + ] +} + +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): + """ + 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): + 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 diff --git a/python/kicad_interface.py b/python/kicad_interface.py index b3903ad..899744d 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -1283,6 +1283,46 @@ class KiCADInterface: logger.error(f"Error listing schematic libraries: {str(e)}") return {"success": False, "message": str(e)} + def _handle_find_unconnected_pins(self, params): + """List component pins with no wire/label/power symbol touching them""" + logger.info("Finding unconnected pins") + try: + from commands.schematic_analysis import find_unconnected_pins + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + result = find_unconnected_pins(schematic_path) + return {"success": True, **result} + except ImportError: + return { + "success": False, + "message": "schematic_analysis module not available", + } + except Exception as e: + logger.error(f"Error finding unconnected pins: {e}") + return {"success": False, "message": str(e)} + + def _handle_check_wire_collisions(self, params): + """Detect wires passing through component bodies without connecting to pins""" + logger.info("Checking wire collisions") + try: + from commands.schematic_analysis import check_wire_collisions + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + result = check_wire_collisions(schematic_path) + return {"success": True, **result} + except ImportError: + return { + "success": False, + "message": "schematic_analysis module not available", + } + except Exception as e: + logger.error(f"Error checking wire collisions: {e}") + return {"success": False, "message": str(e)} + # ------------------------------------------------------------------ # # Footprint handlers # # ------------------------------------------------------------------ # @@ -1998,14 +2038,18 @@ class KiCADInterface: return {"success": False, "message": str(e)} def _handle_move_schematic_component(self, params): - """Move a schematic component to a new position""" + """Move a schematic component to a new position, dragging connected wires.""" logger.info("Moving schematic component") try: + import sexpdata as _sexpdata + from commands.wire_dragger import WireDragger + schematic_path = params.get("schematicPath") reference = params.get("reference") position = params.get("position", {}) new_x = position.get("x") new_y = position.get("y") + preserve_wires = params.get("preserveWires", True) if not schematic_path or not reference: return { @@ -2018,30 +2062,42 @@ class KiCADInterface: "message": "position with x and y is required", } - schematic = SchematicManager.load_schematic(schematic_path) - if not schematic: - return {"success": False, "message": "Failed to load schematic"} + with open(schematic_path, "r", encoding="utf-8") as f: + sch_data = _sexpdata.loads(f.read()) - # Find the symbol - for symbol in schematic.symbol: - if not hasattr(symbol.property, "Reference"): - continue - if symbol.property.Reference.value == reference: - old_pos = list(symbol.at.value) - old_position = {"x": float(old_pos[0]), "y": float(old_pos[1])} + # Find symbol and record old position + found = WireDragger.find_symbol(sch_data, reference) + if found is None: + return {"success": False, "message": f"Component {reference} not found"} + _, old_x, old_y = found[0], found[1], found[2] + old_position = {"x": old_x, "y": old_y} - # Preserve rotation (third element) - rotation = float(old_pos[2]) if len(old_pos) > 2 else 0 - symbol.at.value = [new_x, new_y, rotation] + drag_summary = {} + if preserve_wires: + # Compute pin world positions before and after the move + pin_positions = WireDragger.compute_pin_positions( + sch_data, reference, float(new_x), float(new_y) + ) + # Build old→new coordinate map (deduplicate coincident pins) + old_to_new = {} + for _pin, (old_xy, new_xy) in pin_positions.items(): + old_to_new[old_xy] = new_xy - SchematicManager.save_schematic(schematic, schematic_path) - return { - "success": True, - "oldPosition": old_position, - "newPosition": {"x": new_x, "y": new_y}, - } + drag_summary = WireDragger.drag_wires(sch_data, old_to_new) - return {"success": False, "message": f"Component {reference} not found"} + # Update symbol position + WireDragger.update_symbol_position(sch_data, reference, float(new_x), float(new_y)) + + with open(schematic_path, "w", encoding="utf-8") as f: + f.write(_sexpdata.dumps(sch_data)) + + return { + "success": True, + "oldPosition": old_position, + "newPosition": {"x": new_x, "y": new_y}, + "wiresMoved": drag_summary.get("endpoints_moved", 0), + "wiresRemoved": drag_summary.get("wires_removed", 0), + } except Exception as e: logger.error(f"Error moving schematic component: {e}") @@ -2074,21 +2130,18 @@ class KiCADInterface: continue if symbol.property.Reference.value == reference: pos = list(symbol.at.value) - pos[2] = angle if len(pos) > 2 else angle while len(pos) < 3: pos.append(0) pos[2] = angle symbol.at.value = pos - # Handle mirror if specified if mirror: if hasattr(symbol, "mirror"): symbol.mirror.value = mirror else: logger.warning( f"Mirror '{mirror}' requested for {reference}, " - f"but symbol does not have a 'mirror' attribute; " - f"mirror not applied" + f"but symbol has no mirror attribute; skipped" ) SchematicManager.save_schematic(schematic, schematic_path) diff --git a/python/tests/test_move_with_wire_preservation.py b/python/tests/test_move_with_wire_preservation.py new file mode 100644 index 0000000..acdda76 --- /dev/null +++ b/python/tests/test_move_with_wire_preservation.py @@ -0,0 +1,661 @@ +""" +Tests for move_schematic_component with wire preservation (WireDragger). + +Unit tests use synthetic sexpdata lists — no disk I/O, no KiCAD install needed. +Integration tests copy empty.kicad_sch to a tempdir and exercise the full handler. +""" + +import shutil +import sys +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import sexpdata +from sexpdata import Symbol + +# Make python/ importable +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from commands.wire_dragger import EPS, WireDragger, _rotate + +TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "empty.kicad_sch" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _sym(name: str) -> Symbol: + return Symbol(name) + + +def _make_wire(x1, y1, x2, y2): + return [ + _sym("wire"), + [_sym("pts"), [_sym("xy"), x1, y1], [_sym("xy"), x2, y2]], + [_sym("stroke"), [_sym("width"), 0], [_sym("type"), _sym("default")]], + [_sym("uuid"), "00000000-0000-0000-0000-000000000000"], + ] + + +def _make_junction(x, y): + return [ + _sym("junction"), + [_sym("at"), x, y], + [_sym("diameter"), 0], + [_sym("color"), 0, 0, 0, 0], + [_sym("uuid"), "00000000-0000-0000-0000-000000000001"], + ] + + +def _make_symbol(ref, x, y, rotation=0, lib_id="Device:R", mirror=None): + """Build a minimal placed-symbol s-expression.""" + item = [ + _sym("symbol"), + [_sym("lib_id"), lib_id], + [_sym("at"), x, y, rotation], + [_sym("unit"), 1], + [_sym("property"), "Reference", ref, [_sym("at"), x + 2, y, 0]], + [_sym("property"), "Value", "10k", [_sym("at"), x, y, 0]], + ] + if mirror: + item.append([_sym("mirror"), _sym(mirror)]) + return item + + +def _make_lib_symbol_r(): + """Minimal Device:R lib_symbols entry — pins at (0, 3.81) and (0, -3.81).""" + return [ + _sym("symbol"), + "Device:R", + [ + _sym("symbol"), + "R_1_1", + [ + _sym("pin"), + _sym("passive"), + _sym("line"), + [_sym("at"), 0, 3.81, 270], + [_sym("length"), 1.27], + [ + _sym("name"), + "~", + [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]], + ], + [ + _sym("number"), + "1", + [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]], + ], + ], + [ + _sym("pin"), + _sym("passive"), + _sym("line"), + [_sym("at"), 0, -3.81, 90], + [_sym("length"), 1.27], + [ + _sym("name"), + "~", + [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]], + ], + [ + _sym("number"), + "2", + [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]], + ], + ], + ], + ] + + +def _make_sch_data(extra_items=None): + """Build a minimal sch_data list with lib_symbols and sheet_instances.""" + data = [ + _sym("kicad_sch"), + [_sym("lib_symbols"), _make_lib_symbol_r()], + [_sym("sheet_instances"), [_sym("path"), "/", [_sym("page"), "1"]]], + ] + if extra_items: + # Insert before sheet_instances (last item) + for item in extra_items: + data.insert(len(data) - 1, item) + return data + + +# --------------------------------------------------------------------------- +# TestRotatePoint +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestRotatePoint: + def test_zero_rotation(self): + assert _rotate(1.0, 2.0, 0) == (1.0, 2.0) + + def test_90_degrees(self): + rx, ry = _rotate(1.0, 0.0, 90) + assert abs(rx - 0.0) < 1e-9 + assert abs(ry - 1.0) < 1e-9 + + def test_180_degrees(self): + rx, ry = _rotate(1.0, 0.0, 180) + assert abs(rx - (-1.0)) < 1e-9 + assert abs(ry - 0.0) < 1e-9 + + def test_270_degrees(self): + rx, ry = _rotate(0.0, 1.0, 270) + assert abs(rx - 1.0) < 1e-6 + assert abs(ry - 0.0) < 1e-6 + + +# --------------------------------------------------------------------------- +# TestFindSymbol +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestFindSymbol: + def test_returns_none_for_missing_reference(self): + sch = _make_sch_data([_make_symbol("R1", 10, 20)]) + assert WireDragger.find_symbol(sch, "R99") is None + + def test_returns_item_and_position(self): + sch = _make_sch_data([_make_symbol("R1", 10.5, 20.5, rotation=90)]) + result = WireDragger.find_symbol(sch, "R1") + assert result is not None + _, old_x, old_y, rotation, lib_id, mirror_x, mirror_y = result + assert abs(old_x - 10.5) < EPS + assert abs(old_y - 20.5) < EPS + assert abs(rotation - 90) < EPS + assert lib_id == "Device:R" + assert mirror_x is False + assert mirror_y is False + + def test_detects_mirror_x(self): + sch = _make_sch_data([_make_symbol("R1", 0, 0, mirror="x")]) + result = WireDragger.find_symbol(sch, "R1") + assert result is not None + assert result[5] is True # mirror_x + assert result[6] is False # mirror_y + + def test_detects_mirror_y(self): + sch = _make_sch_data([_make_symbol("R1", 0, 0, mirror="y")]) + result = WireDragger.find_symbol(sch, "R1") + assert result is not None + assert result[5] is False # mirror_x + assert result[6] is True # mirror_y + + +# --------------------------------------------------------------------------- +# TestComputePinPositions +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestComputePinPositions: + def test_resistor_at_origin_no_rotation(self): + """Device:R at (0, 0) rot=0 — pins at (0, 3.81) and (0, -3.81).""" + sch = _make_sch_data([_make_symbol("R1", 0, 0)]) + positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20) + assert "1" in positions and "2" in positions + old1, new1 = positions["1"] + old2, new2 = positions["2"] + # Pin 1 old: (0 + 0, 0 + 3.81) + assert abs(old1[0] - 0) < 1e-4 + assert abs(old1[1] - 3.81) < 1e-4 + # Pin 2 old: (0 + 0, 0 - 3.81) + assert abs(old2[0] - 0) < 1e-4 + assert abs(old2[1] - (-3.81)) < 1e-4 + # New positions shifted by (10, 20) + assert abs(new1[0] - 10) < 1e-4 + assert abs(new1[1] - 23.81) < 1e-4 + assert abs(new2[0] - 10) < 1e-4 + assert abs(new2[1] - 16.19) < 1e-4 + + def test_resistor_rotated_90(self): + """Device:R at (100, 100) rot=90 — pins should be at (100+3.81, 100) and (100-3.81, 100).""" + sch = _make_sch_data([_make_symbol("R1", 100, 100, rotation=90)]) + positions = WireDragger.compute_pin_positions(sch, "R1", 100, 100) + old1, _ = positions["1"] + old2, _ = positions["2"] + # rotate(0, 3.81, 90) = (0*cos90 - 3.81*sin90, 0*sin90 + 3.81*cos90) = (-3.81, 0) + # Wait — pin 1 is at local (0, 3.81), rotated 90° CCW: + # x' = 0*cos90 - 3.81*sin90 = -3.81, y' = 0*sin90 + 3.81*cos90 ≈ 0 + # world: (100 - 3.81, 100 + 0) = (96.19, 100) + assert abs(old1[0] - 96.19) < 1e-3 + assert abs(old1[1] - 100) < 1e-3 + + def test_returns_empty_for_missing_component(self): + sch = _make_sch_data() + result = WireDragger.compute_pin_positions(sch, "MISSING", 0, 0) + assert result == {} + + def test_delta_is_consistent(self): + """new_xy - old_xy should equal (new_x - old_x, new_y - old_y) for any rotation.""" + sch = _make_sch_data([_make_symbol("R1", 50, 50, rotation=45)]) + positions = WireDragger.compute_pin_positions(sch, "R1", 60, 70) + for pin_num, (old_xy, new_xy) in positions.items(): + dx = new_xy[0] - old_xy[0] + dy = new_xy[1] - old_xy[1] + assert abs(dx - 10) < 1e-4, f"Pin {pin_num}: dx={dx}" + assert abs(dy - 20) < 1e-4, f"Pin {pin_num}: dy={dy}" + + +# --------------------------------------------------------------------------- +# TestDragWires +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestDragWires: + def test_no_wires_returns_zero_counts(self): + sch = _make_sch_data() + result = WireDragger.drag_wires(sch, {(0.0, 0.0): (10.0, 10.0)}) + assert result["endpoints_moved"] == 0 + assert result["wires_removed"] == 0 + + def test_wire_start_endpoint_moved(self): + wire = _make_wire(0, 3.81, 0, 10) + sch = _make_sch_data([wire]) + result = WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) + assert result["endpoints_moved"] == 1 + assert result["wires_removed"] == 0 + # Find the updated wire in sch_data + updated = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")) + pts = next(s for s in updated[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")) + xy1 = pts[1] + assert abs(xy1[1] - 10.0) < EPS + assert abs(xy1[2] - 23.81) < EPS + + def test_wire_end_endpoint_moved(self): + wire = _make_wire(0, 10, 0, -3.81) + sch = _make_sch_data([wire]) + result = WireDragger.drag_wires(sch, {(0.0, -3.81): (10.0, 16.19)}) + assert result["endpoints_moved"] == 1 + updated = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")) + pts = next(s for s in updated[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")) + xy2 = pts[2] + assert abs(xy2[1] - 10.0) < EPS + assert abs(xy2[2] - 16.19) < EPS + + def test_zero_length_wire_removed(self): + """When both endpoints of a wire are moved to the same point, wire is deleted.""" + wire = _make_wire(0, 3.81, 0, -3.81) + sch = _make_sch_data([wire]) + # Both pins land at same position (degenerate move) + result = WireDragger.drag_wires( + sch, + { + (0.0, 3.81): (5.0, 5.0), + (0.0, -3.81): (5.0, 5.0), + }, + ) + assert result["wires_removed"] == 1 + wires_remaining = [i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")] + assert len(wires_remaining) == 0 + + def test_unrelated_wire_not_touched(self): + """A wire whose endpoints don't match any old pin is not changed.""" + wire = _make_wire(50, 50, 60, 50) + sch = _make_sch_data([wire]) + original_start = (50.0, 50.0) + result = WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) + assert result["endpoints_moved"] == 0 + updated = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")) + pts = next(s for s in updated[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")) + xy1 = pts[1] + assert abs(xy1[1] - 50.0) < EPS + assert abs(xy1[2] - 50.0) < EPS + + def test_both_endpoints_on_moved_component(self): + """Wire connecting two pins of same component — both endpoints shift together.""" + wire = _make_wire(0, 3.81, 0, -3.81) + sch = _make_sch_data([wire]) + result = WireDragger.drag_wires( + sch, + { + (0.0, 3.81): (10.0, 23.81), + (0.0, -3.81): (10.0, 16.19), + }, + ) + assert result["endpoints_moved"] == 2 + assert result["wires_removed"] == 0 + + def test_junction_moved_with_endpoint(self): + junction = _make_junction(0, 3.81) + sch = _make_sch_data([junction]) + WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) + updated_j = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("junction")) + at_sub = next( + s for s in updated_j[1:] if isinstance(s, list) and s and s[0] == Symbol("at") + ) + assert abs(at_sub[1] - 10.0) < EPS + assert abs(at_sub[2] - 23.81) < EPS + + def test_junction_at_unrelated_position_not_touched(self): + junction = _make_junction(99, 99) + sch = _make_sch_data([junction]) + WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) + updated_j = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("junction")) + at_sub = next( + s for s in updated_j[1:] if isinstance(s, list) and s and s[0] == Symbol("at") + ) + assert abs(at_sub[1] - 99.0) < EPS + assert abs(at_sub[2] - 99.0) < EPS + + +# --------------------------------------------------------------------------- +# TestUpdateSymbolPosition +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestUpdateSymbolPosition: + def test_updates_position(self): + sch = _make_sch_data([_make_symbol("R1", 10, 20)]) + result = WireDragger.update_symbol_position(sch, "R1", 30, 40) + assert result is True + found = WireDragger.find_symbol(sch, "R1") + assert abs(found[1] - 30) < EPS + assert abs(found[2] - 40) < EPS + + def test_returns_false_for_missing(self): + sch = _make_sch_data() + assert WireDragger.update_symbol_position(sch, "MISSING", 0, 0) is False + + def test_preserves_rotation(self): + sch = _make_sch_data([_make_symbol("R1", 10, 20, rotation=90)]) + WireDragger.update_symbol_position(sch, "R1", 30, 40) + found = WireDragger.find_symbol(sch, "R1") + assert abs(found[3] - 90) < EPS # rotation preserved + + def test_property_labels_follow_symbol_move(self): + """Property (at ...) positions must shift by the same delta as the symbol.""" + sym = _make_symbol("R1", 100, 80) + sch = _make_sch_data([sym]) + + # Record initial property positions + prop_k = _sym("property") + at_k = _sym("at") + initial_positions = {} + for sub in sym[1:]: + if isinstance(sub, list) and sub and sub[0] == prop_k: + name = sub[1] + for psub in sub[2:]: + if isinstance(psub, list) and psub and psub[0] == at_k: + initial_positions[name] = (psub[1], psub[2]) + break + assert len(initial_positions) >= 2 # Reference and Value at minimum + + # Move component from (100, 80) to (120, 100) — delta (20, 20) + result = WireDragger.update_symbol_position(sch, "R1", 120, 100) + assert result is True + + # Verify each property shifted by (20, 20) + for sub in sym[1:]: + if isinstance(sub, list) and sub and sub[0] == prop_k: + name = sub[1] + for psub in sub[2:]: + if isinstance(psub, list) and psub and psub[0] == at_k: + expected_x = initial_positions[name][0] + 20 + expected_y = initial_positions[name][1] + 20 + assert ( + abs(psub[1] - expected_x) < EPS + ), f"{name} x: expected {expected_x}, got {psub[1]}" + assert ( + abs(psub[2] - expected_y) < EPS + ), f"{name} y: expected {expected_y}, got {psub[2]}" + break + + +# --------------------------------------------------------------------------- +# Integration tests +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestMoveWithWirePreservation: + """Integration tests using a real .kicad_sch file.""" + + def _make_schematic(self, extra_sexp=""): + """Copy empty.kicad_sch to a temp file and optionally append content.""" + tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" + shutil.copy(TEMPLATE_PATH, tmp) + if extra_sexp: + content = tmp.read_text(encoding="utf-8") + idx = content.rfind(")") + content = content[:idx] + "\n" + extra_sexp + "\n)" + tmp.write_text(content, encoding="utf-8") + return tmp + + def _add_resistor(self, path: Path, ref: str, x: float, y: float, rotation: float = 0) -> Path: + """Append a Device:R symbol to the schematic file.""" + import uuid + + u = str(uuid.uuid4()) + sexp = f""" + (symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1) + (in_bom yes) (on_board yes) (dnp no) + (uuid "{u}") + (property "Reference" "{ref}" (at {x + 2.032} {y} 90) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "10k" (at {x} {y} 90) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at {x - 1.778} {y} 90) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at {x} {y} 0) + (effects (font (size 1.27 1.27)) hide) + ) + (pin "1" (uuid "{uuid.uuid4()}")) + (pin "2" (uuid "{uuid.uuid4()}")) + (instances (project "test" (path "/" (reference "{ref}") (unit 1)))) + )""" + content = path.read_text(encoding="utf-8") + idx = content.rfind(")") + path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8") + return path + + def _add_wire(self, path: Path, x1, y1, x2, y2) -> Path: + """Append a wire to the schematic file.""" + import uuid + + wire_sexp = f""" + (wire (pts (xy {x1} {y1}) (xy {x2} {y2})) + (stroke (width 0) (type default)) + (uuid "{uuid.uuid4()}") + )""" + content = path.read_text(encoding="utf-8") + idx = content.rfind(")") + path.write_text(content[:idx] + "\n" + wire_sexp + "\n)", encoding="utf-8") + return path + + def _parse_wires(self, path: Path): + """Return list of ((x1,y1),(x2,y2)) for every wire in the file.""" + content = path.read_text(encoding="utf-8") + data = sexpdata.loads(content) + wires = [] + for item in data: + if not (isinstance(item, list) and item and item[0] == Symbol("wire")): + continue + pts = next( + (s for s in item[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")), + None, + ) + if pts is None: + continue + xys = [ + p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy") + ] + if len(xys) >= 2: + wires.append( + ( + (float(xys[0][1]), float(xys[0][2])), + (float(xys[-1][1]), float(xys[-1][2])), + ) + ) + return wires + + def _get_symbol_pos(self, path: Path, ref: str): + content = path.read_text(encoding="utf-8") + data = sexpdata.loads(content) + found = WireDragger.find_symbol(data, ref) + if found is None: + return None + return found[1], found[2] + + def test_symbol_position_updated(self): + sch = self._make_schematic() + self._add_resistor(sch, "R1", 100, 100) + # Call handler directly + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + result = iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 120, "y": 130}, + }, + ) + assert result["success"], result.get("message") + pos = self._get_symbol_pos(sch, "R1") + assert abs(pos[0] - 120) < EPS + assert abs(pos[1] - 130) < EPS + + def test_connected_wire_endpoint_follows_pin(self): + """Wire endpoint at pin 1 of R1 should move with the component.""" + sch = self._make_schematic() + # R1 at (100, 100) — pin 1 at (100, 103.81) + self._add_resistor(sch, "R1", 100, 100) + self._add_wire(sch, 100, 103.81, 100, 120) # wire from pin 1 upward + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + result = iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 110, "y": 100}, + }, + ) + assert result["success"], result.get("message") + assert result["wiresMoved"] >= 1 + + wires = self._parse_wires(sch) + assert len(wires) == 1 + # Pin 1 new world position: (110 + 0, 100 + 3.81) = (110, 103.81) + w = wires[0] + endpoints = {w[0], w[1]} + new_pin1 = (110.0, 103.81) + assert any( + abs(ep[0] - new_pin1[0]) < 0.01 and abs(ep[1] - new_pin1[1]) < 0.01 for ep in endpoints + ), f"Expected pin endpoint near {new_pin1}, got {endpoints}" + + def test_unrelated_wire_unchanged(self): + """A wire not connected to R1 must not be modified.""" + sch = self._make_schematic() + self._add_resistor(sch, "R1", 100, 100) + self._add_wire(sch, 50, 50, 60, 50) # unrelated wire + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 110, "y": 110}, + }, + ) + + wires = self._parse_wires(sch) + unrelated = [(s, e) for s, e in wires if abs(s[0] - 50) < 0.01 and abs(s[1] - 50) < 0.01] + assert len(unrelated) == 1 + + def test_no_zero_length_wires_after_move(self): + """No zero-length wires should appear in the file after a move.""" + sch = self._make_schematic() + self._add_resistor(sch, "R1", 100, 100) + # Wire from pin 1 to pin 2 of same component (intra-component wire) + self._add_wire(sch, 100, 103.81, 100, 96.19) + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 110, "y": 100}, + }, + ) + + wires = self._parse_wires(sch) + for start, end in wires: + assert not ( + abs(start[0] - end[0]) < EPS and abs(start[1] - end[1]) < EPS + ), f"Zero-length wire found at {start}" + + def test_preserve_wires_false_skips_wire_update(self): + """preserveWires=False should move the symbol but leave wires alone.""" + sch = self._make_schematic() + self._add_resistor(sch, "R1", 100, 100) + self._add_wire(sch, 100, 103.81, 100, 120) + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + result = iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 110, "y": 100}, + "preserveWires": False, + }, + ) + assert result["success"] + assert result["wiresMoved"] == 0 + + # Wire should still start at old pin position + wires = self._parse_wires(sch) + assert len(wires) == 1 + endpoints = {wires[0][0], wires[0][1]} + old_pin1 = (100.0, 103.81) + assert any( + abs(ep[0] - old_pin1[0]) < 0.01 and abs(ep[1] - old_pin1[1]) < 0.01 for ep in endpoints + ), f"Wire should still be at {old_pin1}, got {endpoints}" + + def test_missing_component_returns_error(self): + sch = self._make_schematic() + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + result = iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "NOTHERE", + "position": {"x": 0, "y": 0}, + }, + ) + assert not result["success"] + assert "not found" in result.get("message", "").lower() diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index ad75abd..08e4d28 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -1124,6 +1124,56 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, ); + // Move a placed symbol, dragging connected wires + server.tool( + "move_schematic_component", + "Move a placed symbol to a new position in the schematic. By default (preserveWires=true) wire endpoints touching the component's pins are stretched to follow the new position.", + { + schematicPath: z.string().describe("Path to the .kicad_sch file"), + reference: z.string().describe("Reference designator (e.g., R1, U1)"), + position: z + .object({ x: z.number(), y: z.number() }) + .describe("New position in schematic mm coordinates"), + preserveWires: z + .boolean() + .optional() + .describe("Stretch connected wire endpoints to follow the move (default true)"), + }, + async (args: { + schematicPath: string; + reference: string; + position: { x: number; y: number }; + preserveWires?: boolean; + }) => { + const result = await callKicadScript("move_schematic_component", args); + if (result.success) { + const moved = result.wiresMoved ?? 0; + const removed = result.wiresRemoved ?? 0; + return { + content: [ + { + type: "text", + text: + `Moved ${args.reference} from (${result.oldPosition.x}, ${result.oldPosition.y}) ` + + `to (${result.newPosition.x}, ${result.newPosition.y})` + + (moved > 0 ? `, ${moved} wire endpoint(s) updated` : "") + + (removed > 0 ? `, ${removed} zero-length wire(s) removed` : ""), + }, + ], + }; + } + return { + content: [ + { + type: "text", + text: `Failed to move component: ${result.message || "Unknown error"}`, + }, + ], + isError: true, + }; + }, + ); + // Sync schematic to PCB board (equivalent to KiCAD F8 / "Update PCB from Schematic") server.tool( "sync_schematic_to_board", From d58283ef0a482c0c77e930d86b5bed37dc442839 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 29 Mar 2026 23:41:42 +0100 Subject: [PATCH 46/74] feat: synthesize wires for touching-pin connections on component move When moving a schematic component whose pins directly touch pins of stationary components (no wire segment, just pin-to-pin contact), synthesize bridge wires to preserve the electrical connection after the move. Also fixes duplicate-pin-position collision in old_to_new map and updates symbol reference assignment to use setAllReferences. Co-Authored-By: Claude Sonnet 4.6 --- python/commands/dynamic_symbol_loader.py | 8 + python/commands/wire_dragger.py | 130 ++++++- python/kicad_interface.py | 17 +- .../tests/test_move_with_wire_preservation.py | 350 +++++++++++++++++- 4 files changed, 502 insertions(+), 3 deletions(-) diff --git a/python/commands/dynamic_symbol_loader.py b/python/commands/dynamic_symbol_loader.py index 4c9b778..22ea7c4 100644 --- a/python/commands/dynamic_symbol_loader.py +++ b/python/commands/dynamic_symbol_loader.py @@ -425,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: diff --git a/python/commands/wire_dragger.py b/python/commands/wire_dragger.py index 910236b..2ad7a54 100644 --- a/python/commands/wire_dragger.py +++ b/python/commands/wire_dragger.py @@ -6,7 +6,8 @@ All methods operate on in-memory sexpdata lists (no disk I/O). import logging import math -from typing import Dict, Optional, Tuple +import uuid +from typing import Dict, List, Optional, Tuple import sexpdata from sexpdata import Symbol @@ -27,6 +28,10 @@ _K = { "wire", "junction", "property", + "stroke", + "width", + "type", + "uuid", ] } @@ -309,3 +314,126 @@ class WireDragger: 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 diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 899744d..1c14710 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -2081,10 +2081,24 @@ class KiCADInterface: # Build old→new coordinate map (deduplicate coincident pins) old_to_new = {} for _pin, (old_xy, new_xy) in pin_positions.items(): + if old_xy in old_to_new: + logger.warning( + f"move_schematic_component: pin {_pin!r} of {reference!r} " + f"shares old position {old_xy} with another pin; " + f"keeping first entry, skipping duplicate" + ) + continue old_to_new[old_xy] = new_xy drag_summary = WireDragger.drag_wires(sch_data, old_to_new) + # Synthesize wires for touching-pin connections after dragging, + # so drag_wires doesn't accidentally move and collapse the new wire. + wires_synthesized = WireDragger.synthesize_touching_pin_wires( + sch_data, reference, pin_positions + ) + drag_summary["wires_synthesized"] = wires_synthesized + # Update symbol position WireDragger.update_symbol_position(sch_data, reference, float(new_x), float(new_y)) @@ -2097,6 +2111,7 @@ class KiCADInterface: "newPosition": {"x": new_x, "y": new_y}, "wiresMoved": drag_summary.get("endpoints_moved", 0), "wiresRemoved": drag_summary.get("wires_removed", 0), + "wiresSynthesized": drag_summary.get("wires_synthesized", 0), } except Exception as e: @@ -2212,7 +2227,7 @@ class KiCADInterface: old_ref = symbol.property.Reference.value new_ref = f"{prefix}{next_num}" - symbol.property.Reference.value = new_ref + symbol.setAllReferences(new_ref) existing_refs[prefix].add(next_num) uuid_val = str(symbol.uuid.value) if hasattr(symbol, "uuid") else "" diff --git a/python/tests/test_move_with_wire_preservation.py b/python/tests/test_move_with_wire_preservation.py index acdda76..fceefc8 100644 --- a/python/tests/test_move_with_wire_preservation.py +++ b/python/tests/test_move_with_wire_preservation.py @@ -18,7 +18,7 @@ from sexpdata import Symbol # Make python/ importable sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from commands.wire_dragger import EPS, WireDragger, _rotate +from commands.wire_dragger import EPS, WireDragger, _coords_match, _rotate TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "empty.kicad_sch" @@ -659,3 +659,351 @@ class TestMoveWithWirePreservation: ) assert not result["success"] assert "not found" in result.get("message", "").lower() + + +# --------------------------------------------------------------------------- +# TestSynthesizeTouchingPinWires (unit) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestSynthesizeTouchingPinWires: + """Unit tests for WireDragger.synthesize_touching_pin_wires.""" + + def _make_two_resistors(self, r1_x, r1_y, r2_x, r2_y): + """Build sch_data with R1 and R2, each Device:R.""" + return _make_sch_data( + [ + _make_symbol("R1", r1_x, r1_y), + _make_symbol("R2", r2_x, r2_y), + ] + ) + + def test_no_stationary_symbols_returns_zero(self): + """With only the moved component in sch_data, nothing is synthesized.""" + sch = _make_sch_data([_make_symbol("R1", 0, 0)]) + pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20) + count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) + assert count == 0 + + def test_touching_pin_gap_generates_wire(self): + """ + R1 at (0, 0) pin2 at (0, -3.81). + R2 at (0, -7.62) pin1 at (0, -3.81). ← pins touch + Moving R1 to (10, 0) causes pin2 to move to (10, -3.81). + A wire from (0, -3.81) to (10, -3.81) should be synthesized. + """ + # R2 pin1 is at (0, -7.62 + 3.81) = (0, -3.81) + sch = self._make_two_resistors(0, 0, 0, -7.62) + + # Verify the touching: R1 pin2 old = (0, -3.81), R2 pin1 = (0, -3.81) + pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0) + old2, new2 = pin_positions["2"] + assert abs(old2[0] - 0) < 1e-3 and abs(old2[1] - (-3.81)) < 1e-3 + assert abs(new2[0] - 10) < 1e-3 and abs(new2[1] - (-3.81)) < 1e-3 + + wire_count_before = sum( + 1 for item in sch if isinstance(item, list) and item and item[0] == _sym("wire") + ) + count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) + assert count == 1, f"Expected 1 synthesized wire, got {count}" + + wires = [ + item for item in sch if isinstance(item, list) and item and item[0] == _sym("wire") + ] + assert len(wires) == wire_count_before + 1 + + # The new wire should span (0, -3.81) → (10, -3.81) + new_wire = wires[-1] + pts = next(s for s in new_wire[1:] if isinstance(s, list) and s and s[0] == _sym("pts")) + xys = [p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == _sym("xy")] + assert len(xys) == 2 + endpoints = { + (round(float(xys[0][1]), 3), round(float(xys[0][2]), 3)), + (round(float(xys[1][1]), 3), round(float(xys[1][2]), 3)), + } + assert (0.0, -3.81) in endpoints, f"Expected (0, -3.81) in wire endpoints, got {endpoints}" + assert ( + 10.0, + -3.81, + ) in endpoints, f"Expected (10, -3.81) in wire endpoints, got {endpoints}" + + def test_no_wire_when_pin_didnt_move(self): + """ + If old_xy == new_xy for a touching pin (component moved but this pin stayed put), + no wire should be synthesized. + """ + # R1 at (0, 0), R2 at (0, -7.62) — pin2 of R1 and pin1 of R2 touch at (0, -3.81) + sch = self._make_two_resistors(0, 0, 0, -7.62) + # Moving R1 to (0, 0) — effectively no move, same position + pin_positions = WireDragger.compute_pin_positions(sch, "R1", 0, 0) + count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) + assert count == 0 + + def test_no_wire_when_rejoins_other_stationary_pin(self): + """ + If the moved pin's new position coincides with another stationary pin, + no wire should be synthesized (they touch again). + """ + # R1 at (0, 0), R2 at (0, -7.62), R3 at (10, -7.62) + # R1 pin2 was touching R2 pin1 at (0, -3.81). + # Moving R1 to (10, 0): pin2 lands at (10, -3.81) which is R3 pin1. + sch = _make_sch_data( + [ + _make_symbol("R1", 0, 0), + _make_symbol("R2", 0, -7.62), + _make_symbol("R3", 10, -7.62), + ] + ) + pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0) + count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) + assert count == 0, f"Expected 0 synthesized wires (rejoined), got {count}" + + def test_empty_pin_positions_returns_zero(self): + sch = _make_sch_data([_make_symbol("R1", 0, 0)]) + count = WireDragger.synthesize_touching_pin_wires(sch, "R1", {}) + assert count == 0 + + def test_non_touching_pins_not_affected(self): + """ + When R1 and R2 are NOT touching (different positions), no wire is synthesized. + """ + # R1 at (0, 0), R2 at (100, 100) — far apart + sch = self._make_two_resistors(0, 0, 100, 100) + pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0) + count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) + assert count == 0 + + +# --------------------------------------------------------------------------- +# TestOldToNewCollision (unit) — regression for the duplicate-pin-position bug +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestOldToNewCollision: + """Verify that coincident pins do not silently overwrite each other in old_to_new.""" + + def test_handler_logs_warning_on_collision(self, caplog): + """ + When two pins share the same old position, a warning should be logged + and the *first* mapping should be kept (not overwritten by the second). + """ + import logging + + # Build a fake pin_positions dict with a deliberate collision + pin_positions = { + "1": ((0.0, 3.81), (10.0, 23.81)), + "2": ((0.0, 3.81), (10.0, 16.19)), # same old_xy as pin "1" + } + + old_to_new = {} + with caplog.at_level(logging.WARNING, logger="kicad_interface"): + for _pin, (old_xy, new_xy) in pin_positions.items(): + if old_xy in old_to_new: + import logging as _logging + + logger_inner = _logging.getLogger("kicad_interface") + logger_inner.warning( + f"move_schematic_component: pin {_pin!r} shares old position {old_xy} " + f"with another pin; keeping first entry, skipping duplicate" + ) + continue + old_to_new[old_xy] = new_xy + + # Only one entry should exist, and it should be the first one + assert len(old_to_new) == 1 + assert old_to_new[(0.0, 3.81)] == (10.0, 23.81) + # Warning should have been logged + assert any("skipping duplicate" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# TestTouchingPinIntegration (integration) +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestTouchingPinIntegration: + """Integration tests for pin-touching connection wire synthesis.""" + + def _make_schematic(self, extra_sexp=""): + """Copy empty.kicad_sch to a temp file.""" + tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" + shutil.copy(TEMPLATE_PATH, tmp) + if extra_sexp: + content = tmp.read_text(encoding="utf-8") + idx = content.rfind(")") + content = content[:idx] + "\n" + extra_sexp + "\n)" + tmp.write_text(content, encoding="utf-8") + return tmp + + def _add_resistor(self, path: Path, ref: str, x: float, y: float, rotation: float = 0) -> Path: + import uuid as _uuid + + u = str(_uuid.uuid4()) + sexp = f""" + (symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1) + (in_bom yes) (on_board yes) (dnp no) + (uuid "{u}") + (property "Reference" "{ref}" (at {x + 2.032} {y} 90) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "10k" (at {x} {y} 90) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at {x - 1.778} {y} 90) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at {x} {y} 0) + (effects (font (size 1.27 1.27)) hide) + ) + (pin "1" (uuid "{_uuid.uuid4()}")) + (pin "2" (uuid "{_uuid.uuid4()}")) + (instances (project "test" (path "/" (reference "{ref}") (unit 1)))) + )""" + content = path.read_text(encoding="utf-8") + idx = content.rfind(")") + path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8") + return path + + def _parse_wires(self, path: Path): + content = path.read_text(encoding="utf-8") + data = sexpdata.loads(content) + wires = [] + for item in data: + if not (isinstance(item, list) and item and item[0] == Symbol("wire")): + continue + pts = next( + (s for s in item[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")), + None, + ) + if pts is None: + continue + xys = [ + p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy") + ] + if len(xys) >= 2: + wires.append( + ( + (float(xys[0][1]), float(xys[0][2])), + (float(xys[-1][1]), float(xys[-1][2])), + ) + ) + return wires + + def test_touching_pin_wire_created_on_move(self): + """ + R1 at (100, 100) and R2 at (100, 92.38) share a touching pin: + R1 pin2 = (100, 96.19), R2 pin1 = (100, 96.19). + Moving R1 to (110, 100) should synthesize a wire from (100, 96.19) to (110, 96.19). + """ + sch = self._make_schematic() + # R1 pin2 world position = 100 + (-3.81) = 96.19 + # R2 pin1 world position = 92.38 + 3.81 = 96.19 + self._add_resistor(sch, "R1", 100, 100) + self._add_resistor(sch, "R2", 100, 92.38) + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + result = iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 110, "y": 100}, + }, + ) + assert result["success"], result.get("message") + assert ( + result.get("wiresSynthesized", 0) >= 1 + ), f"Expected at least 1 synthesized wire, got {result}" + + wires = self._parse_wires(sch) + # There should be a wire bridging the old and new pin2 positions + old_pin2 = (100.0, 96.19) + new_pin2 = (110.0, 96.19) + bridging = [ + (s, e) + for s, e in wires + if ( + ( + abs(s[0] - old_pin2[0]) < 0.05 + and abs(s[1] - old_pin2[1]) < 0.05 + and abs(e[0] - new_pin2[0]) < 0.05 + and abs(e[1] - new_pin2[1]) < 0.05 + ) + or ( + abs(e[0] - old_pin2[0]) < 0.05 + and abs(e[1] - old_pin2[1]) < 0.05 + and abs(s[0] - new_pin2[0]) < 0.05 + and abs(s[1] - new_pin2[1]) < 0.05 + ) + ) + ] + assert ( + len(bridging) >= 1 + ), f"Expected a bridging wire from {old_pin2} to {new_pin2}, got wires: {wires}" + + def test_no_wire_synthesized_when_no_touching_pins(self): + """ + Two resistors with no pin overlap should not generate any synthesized wires. + """ + sch = self._make_schematic() + self._add_resistor(sch, "R1", 100, 100) + self._add_resistor(sch, "R2", 150, 150) # far away + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + result = iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 110, "y": 100}, + }, + ) + assert result["success"], result.get("message") + assert result.get("wiresSynthesized", 0) == 0 + + def test_existing_wires_still_dragged_with_touching_pins(self): + """ + When R1 has both an explicit wire AND a touching-pin connection, + both should be handled: the wire dragged and the touching-pin bridged. + """ + sch = self._make_schematic() + # R1 at (100, 100), R2 at (100, 92.38) — pin2 of R1 touches pin1 of R2 + self._add_resistor(sch, "R1", 100, 100) + self._add_resistor(sch, "R2", 100, 92.38) + + # Also add an explicit wire at pin1 of R1 (100, 103.81) going up + import uuid as _uuid + + wire_sexp = f""" + (wire (pts (xy 100 103.81) (xy 100 115)) + (stroke (width 0) (type default)) + (uuid "{_uuid.uuid4()}") + )""" + content = sch.read_text(encoding="utf-8") + idx = content.rfind(")") + sch.write_text(content[:idx] + "\n" + wire_sexp + "\n)", encoding="utf-8") + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + result = iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 110, "y": 100}, + }, + ) + assert result["success"], result.get("message") + assert result.get("wiresMoved", 0) >= 1, "Expected at least one wire endpoint dragged" + assert result.get("wiresSynthesized", 0) >= 1, "Expected at least one touching-pin wire" From c4f013e6c7bd81c53437885c6d416a71aab2d2bb Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 29 Mar 2026 23:45:21 +0100 Subject: [PATCH 47/74] fix: remove duplicate move_schematic_component tool registration and add regression test The PR added a second server.tool("move_schematic_component", ...) at line 1127 without removing the original registration at line 722, causing the server to fail on startup with "Tool move_schematic_component is already registered". Also adds tests/test_ts_tool_registry.py which scans all src/tools/**/*.ts files for duplicate server.tool() names so this class of bug is caught automatically. Co-Authored-By: Claude Sonnet 4.6 --- src/tools/schematic.ts | 42 ----------------------- tests/test_ts_tool_registry.py | 61 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 42 deletions(-) create mode 100644 tests/test_ts_tool_registry.py diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index 08e4d28..c054b22 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -717,48 +717,6 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, ); - // Move schematic component - server.tool( - "move_schematic_component", - "Move a placed symbol to a new position in the schematic.", - { - schematicPath: z.string().describe("Path to the .kicad_sch file"), - reference: z.string().describe("Reference designator (e.g., R1, U1)"), - position: z - .object({ - x: z.number(), - y: z.number(), - }) - .describe("New position"), - }, - async (args: { - schematicPath: string; - reference: string; - position: { x: number; y: number }; - }) => { - const result = await callKicadScript("move_schematic_component", args); - if (result.success) { - return { - content: [ - { - type: "text", - text: `Moved ${args.reference} from (${result.oldPosition.x}, ${result.oldPosition.y}) to (${result.newPosition.x}, ${result.newPosition.y})`, - }, - ], - }; - } - return { - content: [ - { - type: "text", - text: `Failed to move component: ${result.message || "Unknown error"}`, - }, - ], - isError: true, - }; - }, - ); - // Rotate schematic component server.tool( "rotate_schematic_component", diff --git a/tests/test_ts_tool_registry.py b/tests/test_ts_tool_registry.py new file mode 100644 index 0000000..bcf5b1f --- /dev/null +++ b/tests/test_ts_tool_registry.py @@ -0,0 +1,61 @@ +""" +Regression test: no MCP tool name is registered more than once across all +TypeScript tool files in src/tools/. + +This caught a real bug where move_schematic_component was registered twice +(once in the original code and once in the PR adding wire-preservation), +causing the server to fail on startup with: + Error: Tool move_schematic_component is already registered +""" + +import re +from collections import Counter +from pathlib import Path + +import pytest + +SRC_TOOLS_DIR = Path(__file__).parent.parent / "src" / "tools" + +# Pattern matches the tool-name argument to server.tool( +# server.tool( +# "some_tool_name", +_SERVER_TOOL_RE = re.compile(r'server\.tool\(\s*["\']([a-zA-Z0-9_]+)["\']') + + +@pytest.mark.unit +class TestTsToolRegistry: + def _collect_registrations(self): + """Return list of (tool_name, file, line_no) for every server.tool() call.""" + registrations = [] + for ts_file in sorted(SRC_TOOLS_DIR.glob("**/*.ts")): + text = ts_file.read_text(encoding="utf-8") + for m in _SERVER_TOOL_RE.finditer(text): + line_no = text[: m.start()].count("\n") + 1 + registrations.append((m.group(1), ts_file.name, line_no)) + return registrations + + def test_no_duplicate_tool_names(self): + """Every tool name must appear exactly once across all TS tool files.""" + registrations = self._collect_registrations() + assert registrations, "No server.tool() calls found — check SRC_TOOLS_DIR path" + + counts = Counter(name for name, _, _ in registrations) + duplicates = {name: count for name, count in counts.items() if count > 1} + + if duplicates: + details = [] + for dup_name in sorted(duplicates): + locations = [ + f" {fname}:{line}" for name, fname, line in registrations if name == dup_name + ] + details.append(f"{dup_name} ({duplicates[dup_name]}x):\n" + "\n".join(locations)) + pytest.fail( + "Duplicate MCP tool registrations found — server will fail to start:\n\n" + + "\n\n".join(details) + ) + + def test_tool_files_exist(self): + """Sanity check: src/tools/ directory must be present and contain TS files.""" + assert SRC_TOOLS_DIR.is_dir(), f"src/tools/ not found at {SRC_TOOLS_DIR}" + ts_files = list(SRC_TOOLS_DIR.glob("**/*.ts")) + assert ts_files, "No .ts files found in src/tools/" From 25e81b6411d955bbbc17dfca5b7ee324dc52bec2 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 29 Mar 2026 23:54:29 +0100 Subject: [PATCH 48/74] refactor: move move_schematic_component registration back to original position Restore the tool registration order so move_schematic_component appears before rotate_schematic_component, matching the pre-PR location. Co-Authored-By: Claude Sonnet 4.6 --- src/tools/schematic.ts | 100 ++++++++++++++++++++--------------------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index c054b22..eec672a 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -717,6 +717,56 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, ); + // Move a placed symbol, dragging connected wires + server.tool( + "move_schematic_component", + "Move a placed symbol to a new position in the schematic. By default (preserveWires=true) wire endpoints touching the component's pins are stretched to follow the new position.", + { + schematicPath: z.string().describe("Path to the .kicad_sch file"), + reference: z.string().describe("Reference designator (e.g., R1, U1)"), + position: z + .object({ x: z.number(), y: z.number() }) + .describe("New position in schematic mm coordinates"), + preserveWires: z + .boolean() + .optional() + .describe("Stretch connected wire endpoints to follow the move (default true)"), + }, + async (args: { + schematicPath: string; + reference: string; + position: { x: number; y: number }; + preserveWires?: boolean; + }) => { + const result = await callKicadScript("move_schematic_component", args); + if (result.success) { + const moved = result.wiresMoved ?? 0; + const removed = result.wiresRemoved ?? 0; + return { + content: [ + { + type: "text", + text: + `Moved ${args.reference} from (${result.oldPosition.x}, ${result.oldPosition.y}) ` + + `to (${result.newPosition.x}, ${result.newPosition.y})` + + (moved > 0 ? `, ${moved} wire endpoint(s) updated` : "") + + (removed > 0 ? `, ${removed} zero-length wire(s) removed` : ""), + }, + ], + }; + } + return { + content: [ + { + type: "text", + text: `Failed to move component: ${result.message || "Unknown error"}`, + }, + ], + isError: true, + }; + }, + ); + // Rotate schematic component server.tool( "rotate_schematic_component", @@ -1082,56 +1132,6 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, ); - // Move a placed symbol, dragging connected wires - server.tool( - "move_schematic_component", - "Move a placed symbol to a new position in the schematic. By default (preserveWires=true) wire endpoints touching the component's pins are stretched to follow the new position.", - { - schematicPath: z.string().describe("Path to the .kicad_sch file"), - reference: z.string().describe("Reference designator (e.g., R1, U1)"), - position: z - .object({ x: z.number(), y: z.number() }) - .describe("New position in schematic mm coordinates"), - preserveWires: z - .boolean() - .optional() - .describe("Stretch connected wire endpoints to follow the move (default true)"), - }, - async (args: { - schematicPath: string; - reference: string; - position: { x: number; y: number }; - preserveWires?: boolean; - }) => { - const result = await callKicadScript("move_schematic_component", args); - if (result.success) { - const moved = result.wiresMoved ?? 0; - const removed = result.wiresRemoved ?? 0; - return { - content: [ - { - type: "text", - text: - `Moved ${args.reference} from (${result.oldPosition.x}, ${result.oldPosition.y}) ` + - `to (${result.newPosition.x}, ${result.newPosition.y})` + - (moved > 0 ? `, ${moved} wire endpoint(s) updated` : "") + - (removed > 0 ? `, ${removed} zero-length wire(s) removed` : ""), - }, - ], - }; - } - return { - content: [ - { - type: "text", - text: `Failed to move component: ${result.message || "Unknown error"}`, - }, - ], - isError: true, - }; - }, - ); - // Sync schematic to PCB board (equivalent to KiCAD F8 / "Update PCB from Schematic") server.tool( "sync_schematic_to_board", From 0bc00b14510acbfb1ded4f7b500c5f210aabc3b7 Mon Sep 17 00:00:00 2001 From: William Viana Date: Fri, 3 Apr 2026 11:24:42 -0700 Subject: [PATCH 49/74] fix: prevent pcbnew stdout noise from causing sync_schematic_to_board timeouts The TS<->Python communication channel uses stdout for JSON responses. pcbnew's C++ SWIG layer can write warnings and diagnostics directly to C-level stdout (fd 1), corrupting the JSON framing. The TS parser then never sees valid JSON and the command times out after 30 seconds. Three changes fix this: 1. Python stdout redirect: In main(), save the original stdout fd for exclusive JSON response use, then redirect fd 1 to stderr so all pcbnew C++ output goes to logs instead of the response pipe. 2. Robust TS JSON parser: tryParseResponse() now uses newline-delimited parsing as a fallback. The Python side writes single-line JSON terminated by \n; the parser uses this as the completion signal instead of brace-matching, which prevents premature resolution of truncated chunked responses. Non-JSON preamble lines are logged and stripped. 3. Fix stray print() calls: Converted print() to logger in component_schematic.py and library_schematic.py so they don't leak to stdout during normal operations. Also adds sync_schematic_to_board to the longRunningCommands list for an appropriate timeout value. --- python/commands/component_schematic.py | 21 +++-- python/commands/library_schematic.py | 19 ++-- python/kicad_interface.py | 29 ++++-- src/server.ts | 118 +++++++++++++++++++------ 4 files changed, 137 insertions(+), 50 deletions(-) diff --git a/python/commands/component_schematic.py b/python/commands/component_schematic.py index 2592820..597709d 100644 --- a/python/commands/component_schematic.py +++ b/python/commands/component_schematic.py @@ -278,13 +278,13 @@ 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 @@ -302,15 +302,14 @@ 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 @@ -318,9 +317,9 @@ class ComponentManager: """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 @@ -339,13 +338,13 @@ 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): """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) diff --git a/python/commands/library_schematic.py b/python/commands/library_schematic.py index 6ddf7b4..8d683d5 100644 --- a/python/commands/library_schematic.py +++ b/python/commands/library_schematic.py @@ -1,10 +1,13 @@ import glob +import logging # Symbol class might not be directly importable in the current version import os from skip import Schematic +logger = logging.getLogger(__name__) + class LibraryManager: """Manage symbol libraries""" @@ -31,11 +34,11 @@ 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( + logger.info( f"Found {len(library_names)} libraries: {', '.join(library_names[:10])}{'...' if len(library_names) > 10 else ''}" ) @@ -54,12 +57,12 @@ 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( + 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 @@ -68,12 +71,12 @@ class LibraryManager: 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( + 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 @@ -89,12 +92,12 @@ class LibraryManager: libraries = LibraryManager.list_available_libraries(search_paths) results = [] - print( + 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 diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 1c14710..68db19d 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -4024,8 +4024,29 @@ print("ok") } +def _write_response(response_fd, response): + """Write a JSON response to the original stdout fd. + + All response output goes through this function so that stray C-level + writes from pcbnew (warnings, diagnostics) never corrupt the JSON + framing seen by the TypeScript host. + """ + payload = json.dumps(response) + "\n" + os.write(response_fd, payload.encode("utf-8")) + + def main(): """Main entry point""" + # --- Redirect stdout so pcbnew C++ noise never reaches the TS host --- + # Save the real stdout fd for our exclusive JSON response channel. + _response_fd = os.dup(1) + # Point fd 1 (C-level stdout) at stderr so that any printf / std::cout + # output from pcbnew or other C extensions is visible in logs but does + # NOT corrupt the JSON stream the TypeScript side is parsing. + os.dup2(2, 1) + # Also redirect Python-level stdout to stderr for the same reason. + sys.stdout = sys.stderr + logger.info("Starting KiCAD interface...") interface = KiCADInterface() @@ -4167,10 +4188,9 @@ def main(): # Handle command response = interface.handle_command(command, params) - # Send response + # Send response via the clean fd (immune to pcbnew stdout noise) logger.debug(f"Sending response: {response}") - print(json.dumps(response)) - sys.stdout.flush() + _write_response(_response_fd, response) except json.JSONDecodeError as e: logger.error(f"Invalid JSON input: {str(e)}") @@ -4179,8 +4199,7 @@ def main(): "message": "Invalid JSON input", "errorDetails": str(e), } - print(json.dumps(response)) - sys.stdout.flush() + _write_response(_response_fd, response) except KeyboardInterrupt: logger.info("KiCAD interface stopped") diff --git a/src/server.ts b/src/server.ts index 6abd742..708e607 100644 --- a/src/server.ts +++ b/src/server.ts @@ -516,7 +516,13 @@ export class KiCADMcpServer { // Determine timeout based on command type // DRC and export operations need longer timeouts for large boards let commandTimeout = 30000; // Default 30 seconds - const longRunningCommands = ["run_drc", "export_gerber", "export_pdf", "export_3d"]; + const longRunningCommands = [ + "run_drc", + "export_gerber", + "export_pdf", + "export_3d", + "sync_schematic_to_board", + ]; if (longRunningCommands.includes(command)) { commandTimeout = 600000; // 10 minutes for long operations logger.info(`Using extended timeout (${commandTimeout / 1000}s) for command: ${command}`); @@ -550,7 +556,21 @@ export class KiCADMcpServer { } /** - * Try to parse a complete JSON response from the buffer + * Try to parse a complete JSON response from the buffer. + * + * Responses from the Python side are single-line JSON terminated by '\n' + * (written via _write_response). The buffer may also contain non-JSON + * preamble lines (e.g. C-level warnings from pcbnew that leaked to the + * response fd before the redirect took effect). + * + * Strategy: + * 1. Fast path: JSON.parse(buffer) — works for clean, complete responses + * (JSON.parse tolerates trailing whitespace/newlines). + * 2. If that fails and the buffer has no '\n' yet, the response line is + * still arriving in chunks — keep collecting. + * 3. If the buffer has '\n', split into lines and search from the END for + * a parseable JSON line. This avoids prematurely resolving with a + * truncated JSON object when a large response is still chunking in. */ private tryParseResponse(): void { if (!this.currentRequestHandler) { @@ -564,37 +584,83 @@ export class KiCADMcpServer { return; } + let result: any; + + // Fast path: try to parse the response as JSON. Handles the common + // case of a clean, complete JSON response (possibly with trailing \n). try { - // Try to parse the response as JSON - const result = JSON.parse(this.responseBuffer); - - // If we get here, we have a valid JSON response - logger.debug( - `Completed KiCAD command with result: ${result.success ? "success" : "failure"}`, - ); - - // Clear the timeout since we got a response - if (this.currentRequestHandler.timeoutHandle) { - clearTimeout(this.currentRequestHandler.timeoutHandle); + result = JSON.parse(this.responseBuffer); + } catch { + // Direct parse failed. Either the response is still arriving in + // chunks, or the buffer has non-JSON preamble from pcbnew. + // + // The Python side writes each response as a single line of JSON + // terminated by \n. We use the newline as the completion signal: + // if there is no \n in the buffer yet, the JSON line is still + // being assembled from chunks — keep collecting. + if (!this.responseBuffer.includes("\n")) { + return; } - // Get the handler before clearing - const handler = this.currentRequestHandler; + // Buffer contains newline(s). Split into lines and look for a + // complete JSON object, searching from the END so that preamble + // lines (which may themselves contain '{') are skipped. + const lines = this.responseBuffer.split("\n"); + let jsonLineIndex = -1; - // Clear state - this.responseBuffer = ""; - this.currentRequestHandler = null; - this.processingRequest = false; + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (line.length === 0) continue; + if (!line.startsWith("{")) continue; - // Resolve the promise with the result - handler.resolve(result); + try { + result = JSON.parse(line); + jsonLineIndex = i; + break; + } catch { + // Looks like JSON but doesn't parse — could be an incomplete + // final line still being chunked. Keep collecting. + continue; + } + } - // Process next request if any - setTimeout(() => this.processNextRequest(), 0); - } catch (e) { - // Not a complete JSON yet, keep collecting data - // This is normal for large responses that come in chunks + if (jsonLineIndex < 0) { + // No parseable JSON line found yet. Either only preamble has + // arrived, or the JSON line is split across the last \n boundary + // and is still incomplete. Keep collecting. + return; + } + + // Log any preceding non-JSON lines as preamble + const preambleLines = lines.slice(0, jsonLineIndex).filter((l) => l.trim().length > 0); + if (preambleLines.length > 0) { + logger.warn( + `Stripped non-JSON preamble from Python response: ${preambleLines.join(" | ")}`, + ); + } } + + // If we get here, we have a valid JSON response + logger.debug(`Completed KiCAD command with result: ${result.success ? "success" : "failure"}`); + + // Clear the timeout since we got a response + if (this.currentRequestHandler.timeoutHandle) { + clearTimeout(this.currentRequestHandler.timeoutHandle); + } + + // Get the handler before clearing + const handler = this.currentRequestHandler; + + // Clear state + this.responseBuffer = ""; + this.currentRequestHandler = null; + this.processingRequest = false; + + // Resolve the promise with the result + handler.resolve(result); + + // Process next request if any + setTimeout(() => this.processNextRequest(), 0); } /** From c43784cd78b03106073fcf85976533195763b00f Mon Sep 17 00:00:00 2001 From: William Viana Date: Sun, 5 Apr 2026 09:33:29 -0700 Subject: [PATCH 50/74] fix: extend timeout for schematic query commands on large files Add list_schematic_nets, list_schematic_labels, and get_schematic_view to the longRunningCommands list so they use the 10-minute timeout instead of the default 30 seconds. These commands regularly exceed the 30s limit on larger schematic files. --- src/server.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/server.ts b/src/server.ts index 708e607..8d7deb0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -522,6 +522,9 @@ export class KiCADMcpServer { "export_pdf", "export_3d", "sync_schematic_to_board", + "list_schematic_nets", + "list_schematic_labels", + "get_schematic_view", ]; if (longRunningCommands.includes(command)) { commandTimeout = 600000; // 10 minutes for long operations From 5d1477f548ec2b1fd8bdf90baf867a46c0828575 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 5 Apr 2026 21:24:00 +0100 Subject: [PATCH 51/74] chore: enable import-untyped and import-not-found mypy checks Remove global ignore_missing_imports and the import-untyped disable, replacing them with targeted per-package overrides for third-party libraries that lack stubs (pcbnew, skip, sexpdata, cairosvg, kipy, schematic). Also add python/ to mypy_path so internal modules resolve. Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5dd0998..9600547 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,13 +15,11 @@ line_length = 100 python_version = "3.10" warn_return_any = false warn_unused_configs = true -ignore_missing_imports = true check_untyped_defs = false disallow_untyped_defs = false explicit_package_bases = true -mypy_path = "." +mypy_path = ".:python" disable_error_code = [ - "import-untyped", "arg-type", "attr-defined", "union-attr", @@ -37,3 +35,20 @@ disable_error_code = [ "return-value", "operator", ] + +[[tool.mypy.overrides]] +module = [ + "pcbnew", + "pcbnew.*", + "skip", + "skip.*", + "sexpdata", + "sexpdata.*", + "cairosvg", + "cairosvg.*", + "kipy", + "kipy.*", + "schematic", + "schematic.*", +] +ignore_missing_imports = true From c0630c2b64cdf0e1949f4dd4af53ebf3697dc648 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 5 Apr 2026 21:26:05 +0100 Subject: [PATCH 52/74] chore: remove unused mypy overrides for cairosvg, kipy, schematic These packages are not imported anywhere in the codebase, so their ignore_missing_imports overrides were unnecessary. Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9600547..3401c9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,11 +44,5 @@ module = [ "skip.*", "sexpdata", "sexpdata.*", - "cairosvg", - "cairosvg.*", - "kipy", - "kipy.*", - "schematic", - "schematic.*", ] ignore_missing_imports = true From 5a2b481db394a41e82618ba9e068f4a876018d01 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 5 Apr 2026 21:30:50 +0100 Subject: [PATCH 53/74] chore: remove mypy overrides now that venv uses system-site-packages With --system-site-packages, mypy can resolve pcbnew, skip, and sexpdata directly from the system/venv, so per-module ignore_missing_imports overrides are no longer needed. Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3401c9c..5346eb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,14 +35,3 @@ disable_error_code = [ "return-value", "operator", ] - -[[tool.mypy.overrides]] -module = [ - "pcbnew", - "pcbnew.*", - "skip", - "skip.*", - "sexpdata", - "sexpdata.*", -] -ignore_missing_imports = true From 9b1024a8f3c081897a05fcfb5b25bc7669b2cfdb Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 5 Apr 2026 23:48:35 +0100 Subject: [PATCH 54/74] chore: enable strict mypy checks and fix pre-commit mypy hook Add type annotations to all previously untyped functions and remove 9 suppressed error codes (call-arg, assignment, return-value, operator, has-type, dict-item, misc, list-item, annotation-unchecked) by fixing the underlying type issues. Add [[tool.mypy.overrides]] with ignore_missing_imports for KiCAD-specific modules (pcbnew, sexpdata, skip, cairosvg, kipy, PIL) so the pre-commit mypy hook passes in its isolated venv. Add types-requests and pytest to additional_dependencies in .pre-commit-config.yaml. Also fixes several real bugs uncovered by stricter checks: incorrect static calls to instance methods in swig_backend, wrong return type on get_size, missing value param in BoardAPI.place_component, variable shadowing in kicad_process.py, unqualified LibraryManager reference in kicad_interface, and missing top-level Path import. Co-Authored-By: Claude Sonnet 4.6 --- .pre-commit-config.yaml | 4 +- pyproject.toml | 23 +- python/commands/component_schematic.py | 18 +- python/commands/connection_schematic.py | 30 +- python/commands/datasheet_manager.py | 4 +- python/commands/footprint.py | 5 +- python/commands/freerouting.py | 6 +- python/commands/jlcpcb_parts.py | 18 +- python/commands/jlcsearch.py | 10 +- python/commands/library.py | 4 +- python/commands/library_schematic.py | 13 +- python/commands/library_symbol.py | 6 +- python/commands/pin_locator.py | 8 +- python/commands/routing.py | 2 +- python/commands/schematic.py | 9 +- python/commands/schematic_analysis.py | 2 +- python/commands/svg_import.py | 10 +- python/commands/wire_connectivity.py | 14 +- python/commands/wire_dragger.py | 6 +- python/commands/wire_manager.py | 4 +- python/kicad_api/base.py | 3 +- python/kicad_api/ipc_backend.py | 21 +- python/kicad_api/swig_backend.py | 35 +- python/kicad_interface.py | 195 +- python/resources/resource_definitions.py | 18 +- python/test_ipc_backend.py | 17 +- .../tests/test_delete_schematic_component.py | 415 ++-- python/tests/test_freerouting.py | 77 +- .../tests/test_move_with_wire_preservation.py | 2021 +++++++++-------- python/tests/test_schematic_analysis.py | 1896 ++++++++-------- .../tests/test_schematic_component_fields.py | 739 +++--- python/tests/test_schematic_tools.py | 953 ++++---- python/tests/test_wire_connectivity.py | 664 +++--- python/tests/test_wire_junction_changes.py | 2019 ++++++++-------- python/utils/kicad_process.py | 6 +- 35 files changed, 4665 insertions(+), 4610 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d3340d2..5e23b3d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,7 +46,9 @@ repos: files: ^python/ exclude: ^python/commands/board\.py$ args: [--config-file=pyproject.toml] - additional_dependencies: [] + additional_dependencies: + - types-requests + - pytest - repo: local hooks: diff --git a/pyproject.toml b/pyproject.toml index 5346eb0..fd99a12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,15 +23,20 @@ disable_error_code = [ "arg-type", "attr-defined", "union-attr", - "call-arg", - "assignment", "var-annotated", - "annotation-unchecked", "index", - "has-type", - "dict-item", - "misc", - "list-item", - "return-value", - "operator", ] + +[[tool.mypy.overrides]] +module = [ + "pcbnew", + "cairosvg", + "sexpdata", + "skip", + "kipy", + "kipy.*", + "schematic", + "PIL", + "PIL.*", +] +ignore_missing_imports = true diff --git a/python/commands/component_schematic.py b/python/commands/component_schematic.py index 597709d..c280919 100644 --- a/python/commands/component_schematic.py +++ b/python/commands/component_schematic.py @@ -2,7 +2,7 @@ import logging import os import uuid from pathlib import Path -from typing import Optional +from typing import Any, Dict, List, Optional, Tuple from skip import Schematic @@ -25,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() @@ -86,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 ( @@ -165,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 @@ -265,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. @@ -288,7 +288,7 @@ class ComponentManager: 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 @@ -313,7 +313,7 @@ class ComponentManager: 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: @@ -323,7 +323,7 @@ class ComponentManager: 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 = [] @@ -342,7 +342,7 @@ class ComponentManager: return matching_components @staticmethod - def get_all_components(schematic: Schematic): + def get_all_components(schematic: Schematic) -> List[Any]: """Get all components in schematic""" logger.debug(f"Retrieving all {len(schematic.symbol)} components.") return list(schematic.symbol) diff --git a/python/commands/connection_schematic.py b/python/commands/connection_schematic.py index 19dfb95..69f8ceb 100644 --- a/python/commands/connection_schematic.py +++ b/python/commands/connection_schematic.py @@ -1,7 +1,7 @@ import logging import os from pathlib import Path -from typing import Optional +from typing import Any, Dict, List, Optional from skip import Schematic @@ -25,14 +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_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 @@ -57,7 +57,9 @@ class ConnectionManager: return None @staticmethod - def connect_to_net(schematic_path: Path, component_ref: str, pin_name: str, net_name: str): + def connect_to_net( + schematic_path: Path, component_ref: str, pin_name: str, net_name: str + ) -> bool: """ Connect a component pin to a named net using a wire stub and label @@ -132,7 +134,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}. @@ -203,7 +205,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 @@ -221,7 +223,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 @@ -324,8 +326,8 @@ class ConnectionManager: continue # Check if pin coincides with any wire point - for wire_pt in connected_wire_points: - if points_coincide(pin_loc, list(wire_pt)): + for wire_pt_tup in connected_wire_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 @@ -344,8 +346,10 @@ class ConnectionManager: symbol_y = float(symbol_pos[1]) # Check if symbol is near any wire point (within 10mm) - for wire_pt in connected_wire_points: - dist = ((symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2) ** 0.5 + for wire_pt_tup in connected_wire_points: + dist = ( + (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"}) break # Only add once per component @@ -361,7 +365,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 diff --git a/python/commands/datasheet_manager.py b/python/commands/datasheet_manager.py index fe11f14..db39a16 100644 --- a/python/commands/datasheet_manager.py +++ b/python/commands/datasheet_manager.py @@ -12,7 +12,7 @@ No API key required. 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. diff --git a/python/commands/footprint.py b/python/commands/footprint.py index 55e54f7..389822f 100644 --- a/python/commands/footprint.py +++ b/python/commands/footprint.py @@ -238,7 +238,10 @@ class FootprintCreator: 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, block, count=1 + r'(pad\s+"[^"]*"\s+\w+\s+)\w+', + lambda m: str(m.group(1)) + shape, + block, + count=1, ) if n: changes.append(f"shape→{shape}") diff --git a/python/commands/freerouting.py b/python/commands/freerouting.py index 3da22b1..8dff349 100644 --- a/python/commands/freerouting.py +++ b/python/commands/freerouting.py @@ -95,6 +95,8 @@ 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) @@ -120,6 +122,8 @@ def _build_freerouting_cmd( ] else: java_exe = _find_java() + if java_exe is None: + raise RuntimeError("Java executable not found") return [ java_exe, "-jar", @@ -136,7 +140,7 @@ def _build_freerouting_cmd( 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]: diff --git a/python/commands/jlcpcb_parts.py b/python/commands/jlcpcb_parts.py index 3edadd5..76f6f4e 100644 --- a/python/commands/jlcpcb_parts.py +++ b/python/commands/jlcpcb_parts.py @@ -11,7 +11,7 @@ import os import sqlite3 from datetime import datetime from pathlib import Path -from typing import Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional, Tuple logger = logging.getLogger("kicad_interface") @@ -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 @@ -90,7 +90,9 @@ class JLCPCBPartsManager: 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 @@ -167,7 +169,9 @@ class JLCPCBPartsManager: else: 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 @@ -452,7 +456,7 @@ class JLCPCBPartsManager: 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): + 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", "[]")) @@ -467,7 +471,7 @@ class JLCPCBPartsManager: return alternatives[:limit] - def close(self): + def close(self) -> None: """Close database connection""" if self.conn: self.conn.close() diff --git a/python/commands/jlcsearch.py b/python/commands/jlcsearch.py index 9ae38c8..1ca6ad2 100644 --- a/python/commands/jlcsearch.py +++ b/python/commands/jlcsearch.py @@ -7,7 +7,7 @@ jlcsearch service at https://jlcsearch.tscircuit.com/ import logging import time -from typing import Callable, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional, Union import requests @@ -24,12 +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 @@ -87,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: @@ -109,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: diff --git a/python/commands/library.py b/python/commands/library.py index 5e137e8..f38c496 100644 --- a/python/commands/library.py +++ b/python/commands/library.py @@ -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() @@ -78,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 diff --git a/python/commands/library_schematic.py b/python/commands/library_schematic.py index 8d683d5..d057884 100644 --- a/python/commands/library_schematic.py +++ b/python/commands/library_schematic.py @@ -3,6 +3,7 @@ import logging # Symbol class might not be directly importable in the current version import os +from typing import Any, Dict, List, Optional from skip import Schematic @@ -13,7 +14,7 @@ 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 @@ -46,7 +47,7 @@ class LibraryManager: 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 @@ -66,7 +67,7 @@ class LibraryManager: 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 @@ -80,7 +81,7 @@ class LibraryManager: 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: @@ -101,7 +102,9 @@ class LibraryManager: 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 diff --git a/python/commands/library_symbol.py b/python/commands/library_symbol.py index 95cd716..ff7f650 100644 --- a/python/commands/library_symbol.py +++ b/python/commands/library_symbol.py @@ -55,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() @@ -98,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 @@ -370,7 +370,7 @@ 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 = [ diff --git a/python/commands/pin_locator.py b/python/commands/pin_locator.py index 7122216..2ce4716 100644 --- a/python/commands/pin_locator.py +++ b/python/commands/pin_locator.py @@ -9,7 +9,7 @@ import logging import math import tempfile from pathlib import Path -from typing import Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple import sexpdata from sexpdata import Symbol @@ -21,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 @@ -41,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 diff --git a/python/commands/routing.py b/python/commands/routing.py index 317de8b..9accea3 100644 --- a/python/commands/routing.py +++ b/python/commands/routing.py @@ -115,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: diff --git a/python/commands/schematic.py b/python/commands/schematic.py index 19a801a..4d4c312 100644 --- a/python/commands/schematic.py +++ b/python/commands/schematic.py @@ -2,6 +2,7 @@ import logging import os import shutil import uuid +from typing import Any, Optional from skip import Schematic @@ -12,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) @@ -70,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}") @@ -84,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 @@ -96,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. diff --git a/python/commands/schematic_analysis.py b/python/commands/schematic_analysis.py index d1a212a..975b005 100644 --- a/python/commands/schematic_analysis.py +++ b/python/commands/schematic_analysis.py @@ -782,7 +782,7 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]: collisions = [] # Pre-compute per-symbol data - 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: diff --git a/python/commands/svg_import.py b/python/commands/svg_import.py index e6f2f33..6654277 100644 --- a/python/commands/svg_import.py +++ b/python/commands/svg_import.py @@ -129,7 +129,7 @@ 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): + 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)))) ) @@ -293,10 +293,10 @@ 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): + 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() @@ -345,7 +345,7 @@ def _apply_transform(pts: List[Point], mat: List[List[float]]) -> List[Point]: return out -def _mat_mul(A, B): +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)] @@ -366,7 +366,7 @@ def _get_attr(el: ET.Element, name: str, default: Optional[str] = None) -> Optio return default -def _identity(): +def _identity() -> List[List[float]]: return [[1, 0, 0], [0, 1, 0], [0, 0, 1]] diff --git a/python/commands/wire_connectivity.py b/python/commands/wire_connectivity.py index acd1f3c..d1728c9 100644 --- a/python/commands/wire_connectivity.py +++ b/python/commands/wire_connectivity.py @@ -8,7 +8,7 @@ coordinate matching, mirroring KiCad's own connectivity algorithm. import logging from pathlib import Path -from typing import Dict, List, Optional, Set, Tuple +from typing import Any, Dict, List, Optional, Set, Tuple from commands.pin_locator import PinLocator @@ -22,7 +22,7 @@ def _to_iu(x_mm: float, y_mm: float) -> Tuple[int, int]: return (round(x_mm * _IU_PER_MM), round(y_mm * _IU_PER_MM)) -def _parse_wires(schematic) -> List[List[Tuple[int, int]]]: +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: @@ -67,7 +67,9 @@ def _build_adjacency( return adjacency, iu_to_wires -def _parse_virtual_connections(schematic, schematic_path): +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: @@ -175,8 +177,8 @@ def _find_connected_wires( def _find_pins_on_net( net_points: Set[Tuple[int, int]], - schematic_path, - schematic, + schematic_path: Any, + schematic: Any, ) -> List[Dict]: """Find component pins that land on net points using exact IU matching. @@ -216,7 +218,7 @@ def _find_pins_on_net( def get_wire_connections( - schematic, schematic_path: str, x_mm: float, y_mm: float + schematic: Any, schematic_path: str, x_mm: float, y_mm: float ) -> Optional[Dict]: """Find all component pins reachable from a point via connected wires, net labels, and power symbols. diff --git a/python/commands/wire_dragger.py b/python/commands/wire_dragger.py index 2ad7a54..379df22 100644 --- a/python/commands/wire_dragger.py +++ b/python/commands/wire_dragger.py @@ -7,7 +7,7 @@ All methods operate on in-memory sexpdata lists (no disk I/O). import logging import math import uuid -from typing import Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple import sexpdata from sexpdata import Symbol @@ -55,7 +55,7 @@ class WireDragger: """Pure-logic helpers for wire-endpoint dragging during component moves.""" @staticmethod - def find_symbol(sch_data: list, reference: str): + def find_symbol(sch_data: list, reference: str) -> Any: """ Find a placed symbol by reference designator. @@ -218,7 +218,7 @@ class WireDragger: junction_k = _K["junction"] at_k = _K["at"] - def find_new(x: float, y: float): + 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 diff --git a/python/commands/wire_manager.py b/python/commands/wire_manager.py index 3851f36..eb55ad9 100644 --- a/python/commands/wire_manager.py +++ b/python/commands/wire_manager.py @@ -11,7 +11,7 @@ import math import tempfile import uuid from pathlib import Path -from typing import List, Optional, Tuple +from typing import Any, List, Optional, Tuple import sexpdata from sexpdata import Symbol @@ -255,7 +255,7 @@ class WireManager: @staticmethod def _parse_wire( - wire_item, + wire_item: Any, ) -> Optional[Tuple[Tuple[float, float], Tuple[float, float], float, str]]: """ Parse a wire S-expression item in a single pass. diff --git a/python/kicad_api/base.py b/python/kicad_api/base.py index b64c922..2bdbd14 100644 --- a/python/kicad_api/base.py +++ b/python/kicad_api/base.py @@ -127,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 @@ -169,6 +169,7 @@ class BoardAPI(ABC): y: float, rotation: float = 0, layer: str = "F.Cu", + value: str = "", ) -> bool: """ Place a component on the board diff --git a/python/kicad_api/ipc_backend.py b/python/kicad_api/ipc_backend.py index 65a3879..e69b94e 100644 --- a/python/kicad_api/ipc_backend.py +++ b/python/kicad_api/ipc_backend.py @@ -40,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: @@ -257,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: @@ -350,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() @@ -490,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. @@ -546,7 +546,14 @@ class IPCBoardAPI(BoardAPI): return None def _place_loaded_footprint( - self, loaded_fp, reference: str, x: float, y: float, rotation: float, layer: str, value: str + self, + loaded_fp: Any, + reference: str, + x: float, + y: float, + rotation: float, + layer: str, + value: str, ) -> bool: """ Place a loaded pcbnew footprint onto the board. diff --git a/python/kicad_api/swig_backend.py b/python/kicad_api/swig_backend.py index 7864c2b..a12b604 100644 --- a/python/kicad_api/swig_backend.py +++ b/python/kicad_api/swig_backend.py @@ -26,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( @@ -98,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}") @@ -112,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}") @@ -137,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 @@ -146,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") @@ -173,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 [] @@ -189,17 +193,20 @@ class SWIGBoardAPI(BoardAPI): y: float, rotation: float = 0, 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: diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 68db19d..7fd00b7 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -12,6 +12,7 @@ import logging import os import sys import traceback +from pathlib import Path from typing import Any, Dict, Optional from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read @@ -241,7 +242,7 @@ except ImportError as e: class KiCADInterface: """Main interface class to handle KiCAD operations""" - def __init__(self): + def __init__(self) -> None: """Initialize the interface and command handlers""" self.board = None self.project_filename = None @@ -560,7 +561,7 @@ class KiCADInterface: "connect_passthrough", } - def _auto_save_board(self): + def _auto_save_board(self) -> None: """Save board to disk after SWIG mutations. Called automatically after every board-mutating SWIG command so that data is not lost if Claude hits the context limit before save_project. @@ -574,7 +575,7 @@ class KiCADInterface: except Exception as e: logger.warning(f"Auto-save failed: {e}") - def _update_command_handlers(self): + def _update_command_handlers(self) -> None: """Update board reference in all command handlers""" logger.debug("Updating board reference in command handlers") self.project_commands.board = self.board @@ -586,7 +587,7 @@ class KiCADInterface: self.freerouting_commands.board = self.board # Schematic command handlers - def _handle_create_schematic(self, params): + def _handle_create_schematic(self, params: Dict[str, Any]) -> Dict[str, Any]: """Create a new schematic""" logger.info("Creating schematic") try: @@ -623,7 +624,7 @@ class KiCADInterface: logger.error(f"Error creating schematic: {str(e)}") return {"success": False, "message": str(e)} - def _handle_load_schematic(self, params): + def _handle_load_schematic(self, params: Dict[str, Any]) -> Dict[str, Any]: """Load an existing schematic""" logger.info("Loading schematic") try: @@ -644,7 +645,7 @@ class KiCADInterface: logger.error(f"Error loading schematic: {str(e)}") return {"success": False, "message": str(e)} - def _handle_place_component(self, params): + def _handle_place_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """Place a component on the PCB, with project-local fp-lib-table support. If boardPath is given and differs from the currently loaded board, the board is reloaded from boardPath before placing — prevents silent failures @@ -679,7 +680,7 @@ class KiCADInterface: return self.component_commands.place_component(params) - def _handle_add_schematic_component(self, params): + def _handle_add_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add a component to a schematic using text-based injection (no sexpdata)""" logger.info("Adding component to schematic") try: @@ -732,7 +733,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_delete_schematic_component(self, params): + def _handle_delete_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """Remove a placed symbol from a schematic using text-based manipulation (no skip writes)""" logger.info("Deleting schematic component") try: @@ -757,7 +758,7 @@ class KiCADInterface: with open(sch_file, "r", encoding="utf-8") as f: content = f.read() - def find_matching_paren(s, start): + def find_matching_paren(s: str, start: int) -> int: """Find the closing paren matching the opening paren at start.""" depth = 0 i = start @@ -838,7 +839,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_edit_schematic_component(self, params): + def _handle_edit_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """Update properties of a placed symbol in a schematic (footprint, value, reference). Uses text-based in-place editing – preserves position, UUID and all other fields. """ @@ -883,7 +884,7 @@ class KiCADInterface: with open(sch_file, "r", encoding="utf-8") as f: content = f.read() - def find_matching_paren(s, start): + def find_matching_paren(s: str, start: int) -> int: """Find the position of the closing paren matching the opening paren at start.""" depth = 0 i = start @@ -928,7 +929,7 @@ class KiCADInterface: break search_start = end + 1 - if block_start is None: + if block_start is None or block_end is None: return { "success": False, "message": f"Component '{reference}' not found in schematic", @@ -991,7 +992,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_get_schematic_component(self, params): + def _handle_get_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """Return full component info: position and all field values with their (at x y angle) positions.""" logger.info("Getting schematic component info") try: @@ -1016,7 +1017,7 @@ class KiCADInterface: with open(sch_file, "r", encoding="utf-8") as f: content = f.read() - def find_matching_paren(s, start): + def find_matching_paren(s: str, start: int) -> int: depth = 0 i = start while i < len(s): @@ -1058,7 +1059,7 @@ class KiCADInterface: break search_start = end + 1 - if block_start is None: + if block_start is None or block_end is None: return { "success": False, "message": f"Component '{reference}' not found in schematic", @@ -1114,7 +1115,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_add_schematic_wire(self, params): + def _handle_add_schematic_wire(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add a wire to a schematic using WireManager, with optional pin snapping""" logger.info("Adding wire to schematic") try: @@ -1164,7 +1165,7 @@ class KiCADInterface: for pin_num, coords in pin_locs.items(): all_pins.append((ref, pin_num, coords)) - def find_nearest_pin(point, tolerance): + def find_nearest_pin(point: Any, tolerance: Any) -> Any: """Find the nearest pin within tolerance of a point.""" best = None best_dist = tolerance @@ -1238,7 +1239,7 @@ class KiCADInterface: "errorDetails": traceback.format_exc(), } - def _handle_add_schematic_junction(self, params): + def _handle_add_schematic_junction(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add a junction (connection dot) to a schematic using WireManager""" logger.info("Adding junction to schematic") try: @@ -1271,19 +1272,19 @@ class KiCADInterface: "errorDetails": traceback.format_exc(), } - def _handle_list_schematic_libraries(self, params): + def _handle_list_schematic_libraries(self, params: Dict[str, Any]) -> Dict[str, Any]: """List available symbol libraries""" logger.info("Listing schematic libraries") try: search_paths = params.get("searchPaths") - libraries = LibraryManager.list_available_libraries(search_paths) + libraries = SchematicLibraryManager.list_available_libraries(search_paths) return {"success": True, "libraries": libraries} except Exception as e: logger.error(f"Error listing schematic libraries: {str(e)}") return {"success": False, "message": str(e)} - def _handle_find_unconnected_pins(self, params): + def _handle_find_unconnected_pins(self, params: Dict[str, Any]) -> Dict[str, Any]: """List component pins with no wire/label/power symbol touching them""" logger.info("Finding unconnected pins") try: @@ -1303,7 +1304,7 @@ class KiCADInterface: logger.error(f"Error finding unconnected pins: {e}") return {"success": False, "message": str(e)} - def _handle_check_wire_collisions(self, params): + def _handle_check_wire_collisions(self, params: Dict[str, Any]) -> Dict[str, Any]: """Detect wires passing through component bodies without connecting to pins""" logger.info("Checking wire collisions") try: @@ -1327,7 +1328,7 @@ class KiCADInterface: # Footprint handlers # # ------------------------------------------------------------------ # - def _handle_create_footprint(self, params): + def _handle_create_footprint(self, params: Dict[str, Any]) -> Dict[str, Any]: """Create a new .kicad_mod footprint file in a .pretty library.""" logger.info(f"create_footprint: {params.get('name')} in {params.get('libraryPath')}") try: @@ -1349,7 +1350,7 @@ class KiCADInterface: logger.error(f"create_footprint error: {e}") return {"success": False, "error": str(e)} - def _handle_edit_footprint_pad(self, params): + def _handle_edit_footprint_pad(self, params: Dict[str, Any]) -> Dict[str, Any]: """Edit an existing pad in a .kicad_mod file.""" logger.info( f"edit_footprint_pad: pad {params.get('padNumber')} in {params.get('footprintPath')}" @@ -1368,7 +1369,7 @@ class KiCADInterface: logger.error(f"edit_footprint_pad error: {e}") return {"success": False, "error": str(e)} - def _handle_list_footprint_libraries(self, params): + def _handle_list_footprint_libraries(self, params: Dict[str, Any]) -> Dict[str, Any]: """List .pretty footprint libraries and their contents.""" logger.info("list_footprint_libraries") try: @@ -1378,7 +1379,7 @@ class KiCADInterface: logger.error(f"list_footprint_libraries error: {e}") return {"success": False, "error": str(e)} - def _handle_register_footprint_library(self, params): + def _handle_register_footprint_library(self, params: Dict[str, Any]) -> Dict[str, Any]: """Register a .pretty library in KiCAD's fp-lib-table.""" logger.info(f"register_footprint_library: {params.get('libraryPath')}") try: @@ -1398,7 +1399,7 @@ class KiCADInterface: # Symbol creator handlers # # ------------------------------------------------------------------ # - def _handle_create_symbol(self, params): + def _handle_create_symbol(self, params: Dict[str, Any]) -> Dict[str, Any]: """Create a new symbol in a .kicad_sym library.""" logger.info(f"create_symbol: {params.get('name')} in {params.get('libraryPath')}") try: @@ -1422,7 +1423,7 @@ class KiCADInterface: logger.error(f"create_symbol error: {e}") return {"success": False, "error": str(e)} - def _handle_delete_symbol(self, params): + def _handle_delete_symbol(self, params: Dict[str, Any]) -> Dict[str, Any]: """Delete a symbol from a .kicad_sym library.""" logger.info(f"delete_symbol: {params.get('name')} from {params.get('libraryPath')}") try: @@ -1435,7 +1436,7 @@ class KiCADInterface: logger.error(f"delete_symbol error: {e}") return {"success": False, "error": str(e)} - def _handle_list_symbols_in_library(self, params): + def _handle_list_symbols_in_library(self, params: Dict[str, Any]) -> Dict[str, Any]: """List all symbols in a .kicad_sym file.""" logger.info(f"list_symbols_in_library: {params.get('libraryPath')}") try: @@ -1447,7 +1448,7 @@ class KiCADInterface: logger.error(f"list_symbols_in_library error: {e}") return {"success": False, "error": str(e)} - def _handle_register_symbol_library(self, params): + def _handle_register_symbol_library(self, params: Dict[str, Any]) -> Dict[str, Any]: """Register a .kicad_sym library in KiCAD's sym-lib-table.""" logger.info(f"register_symbol_library: {params.get('libraryPath')}") try: @@ -1463,7 +1464,7 @@ class KiCADInterface: logger.error(f"register_symbol_library error: {e}") return {"success": False, "error": str(e)} - def _handle_export_schematic_pdf(self, params): + def _handle_export_schematic_pdf(self, params: Dict[str, Any]) -> Dict[str, Any]: """Export schematic to PDF""" logger.info("Exporting schematic to PDF") try: @@ -1512,7 +1513,7 @@ class KiCADInterface: logger.error(f"Error exporting schematic to PDF: {str(e)}") return {"success": False, "message": str(e)} - def _handle_add_schematic_net_label(self, params): + def _handle_add_schematic_net_label(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add a net label to schematic using WireManager""" logger.info("Adding net label to schematic") try: @@ -1558,7 +1559,7 @@ class KiCADInterface: "errorDetails": traceback.format_exc(), } - def _handle_connect_to_net(self, params): + def _handle_connect_to_net(self, params: Dict[str, Any]) -> Dict[str, Any]: """Connect a component pin to a named net using wire stub and label""" logger.info("Connecting component pin to net") try: @@ -1595,7 +1596,7 @@ class KiCADInterface: "errorDetails": traceback.format_exc(), } - def _handle_connect_passthrough(self, params): + def _handle_connect_passthrough(self, params: Dict[str, Any]) -> Dict[str, Any]: """Connect all pins of source connector to matching pins of target connector""" logger.info("Connecting passthrough between two connectors") try: @@ -1632,7 +1633,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_get_schematic_pin_locations(self, params): + def _handle_get_schematic_pin_locations(self, params: Dict[str, Any]) -> Dict[str, Any]: """Return exact pin endpoint coordinates for a schematic component""" logger.info("Getting schematic pin locations") try: @@ -1687,7 +1688,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_get_schematic_view(self, params): + def _handle_get_schematic_view(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get a rasterised image of the schematic (SVG export → optional PNG conversion)""" logger.info("Getting schematic view") import base64 @@ -1776,7 +1777,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_list_schematic_components(self, params): + def _handle_list_schematic_components(self, params: Dict[str, Any]) -> Dict[str, Any]: """List all components in a schematic""" logger.info("Listing schematic components") try: @@ -1869,7 +1870,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_list_schematic_nets(self, params): + def _handle_list_schematic_nets(self, params: Dict[str, Any]) -> Dict[str, Any]: """List all nets in a schematic with their connections""" logger.info("Listing schematic nets") try: @@ -1915,7 +1916,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_list_schematic_wires(self, params): + def _handle_list_schematic_wires(self, params: Dict[str, Any]) -> Dict[str, Any]: """List all wires in a schematic""" logger.info("Listing schematic wires") try: @@ -1958,7 +1959,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_list_schematic_labels(self, params): + def _handle_list_schematic_labels(self, params: Dict[str, Any]) -> Dict[str, Any]: """List all net labels and power flags in a schematic""" logger.info("Listing schematic labels") try: @@ -2037,7 +2038,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_move_schematic_component(self, params): + def _handle_move_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """Move a schematic component to a new position, dragging connected wires.""" logger.info("Moving schematic component") try: @@ -2121,7 +2122,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_rotate_schematic_component(self, params): + def _handle_rotate_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """Rotate a schematic component""" logger.info("Rotating schematic component") try: @@ -2171,7 +2172,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_annotate_schematic(self, params): + def _handle_annotate_schematic(self, params: Dict[str, Any]) -> Dict[str, Any]: """Annotate unannotated components in schematic (R? -> R1, R2, ...)""" logger.info("Annotating schematic") try: @@ -2249,7 +2250,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_delete_schematic_wire(self, params): + def _handle_delete_schematic_wire(self, params: Dict[str, Any]) -> Dict[str, Any]: """Delete a wire from the schematic matching start/end points""" logger.info("Deleting schematic wire") try: @@ -2280,7 +2281,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_delete_schematic_net_label(self, params): + def _handle_delete_schematic_net_label(self, params: Dict[str, Any]) -> Dict[str, Any]: """Delete a net label from the schematic""" logger.info("Deleting schematic net label") try: @@ -2315,7 +2316,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_export_schematic_svg(self, params): + def _handle_export_schematic_svg(self, params: Dict[str, Any]) -> Dict[str, Any]: """Export schematic to SVG using kicad-cli""" logger.info("Exporting schematic SVG") import glob @@ -2389,7 +2390,7 @@ class KiCADInterface: logger.error(f"Error exporting schematic SVG: {e}") return {"success": False, "message": str(e)} - def _handle_get_net_connections(self, params): + def _handle_get_net_connections(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get all connections for a named net""" logger.info("Getting net connections") try: @@ -2409,7 +2410,7 @@ class KiCADInterface: logger.error(f"Error getting net connections: {str(e)}") return {"success": False, "message": str(e)} - def _handle_get_wire_connections(self, params): + def _handle_get_wire_connections(self, params: Dict[str, Any]) -> Dict[str, Any]: """Find all component pins reachable from a point via connected wires""" logger.info("Getting wire connections") try: @@ -2456,7 +2457,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_run_erc(self, params): + def _handle_run_erc(self, params: Dict[str, Any]) -> Dict[str, Any]: """Run Electrical Rules Check on a schematic via kicad-cli""" logger.info("Running ERC on schematic") import os @@ -2552,7 +2553,7 @@ class KiCADInterface: logger.error(f"Error running ERC: {str(e)}") return {"success": False, "message": str(e)} - def _handle_generate_netlist(self, params): + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic""" logger.info("Generating netlist from schematic") try: @@ -2571,7 +2572,7 @@ class KiCADInterface: logger.error(f"Error generating netlist: {str(e)}") return {"success": False, "message": str(e)} - def _handle_sync_schematic_to_board(self, params): + def _handle_sync_schematic_to_board(self, params: Dict[str, Any]) -> Dict[str, Any]: """Sync schematic netlist to PCB board (equivalent to KiCAD F8 'Update PCB from Schematic'). Reads net connections from the schematic and assigns them to the matching pads in the PCB. """ @@ -2694,7 +2695,7 @@ class KiCADInterface: # Schematic analysis tools (read-only) # =================================================================== - def _handle_get_schematic_view_region(self, params): + def _handle_get_schematic_view_region(self, params: Dict[str, Any]) -> Dict[str, Any]: """Export a cropped region of the schematic as an image""" logger.info("Exporting schematic view region") import base64 @@ -2809,7 +2810,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_find_overlapping_elements(self, params): + def _handle_find_overlapping_elements(self, params: Dict[str, Any]) -> Dict[str, Any]: """Detect spatially overlapping symbols, wires, and labels""" logger.info("Finding overlapping elements in schematic") try: @@ -2835,7 +2836,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_get_elements_in_region(self, params): + def _handle_get_elements_in_region(self, params: Dict[str, Any]) -> Dict[str, Any]: """List all wires, labels, and symbols within a rectangular region""" logger.info("Getting elements in schematic region") try: @@ -2865,7 +2866,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_find_wires_crossing_symbols(self, params): + def _handle_find_wires_crossing_symbols(self, params: Dict[str, Any]) -> Dict[str, Any]: """Find wires that cross over component symbol bodies""" logger.info("Finding wires crossing symbols in schematic") try: @@ -2891,7 +2892,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_import_svg_logo(self, params): + def _handle_import_svg_logo(self, params: Dict[str, Any]) -> Dict[str, Any]: """Import an SVG file as PCB graphic polygons on the silkscreen""" logger.info("Importing SVG logo into PCB") try: @@ -2938,7 +2939,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_snapshot_project(self, params): + def _handle_snapshot_project(self, params: Dict[str, Any]) -> Dict[str, Any]: """Copy the entire project folder to a snapshot directory for checkpoint/resume.""" import shutil from datetime import datetime @@ -3022,7 +3023,7 @@ class KiCADInterface: logger.error(f"snapshot_project error: {e}") return {"success": False, "message": str(e)} - def _handle_check_kicad_ui(self, params): + def _handle_check_kicad_ui(self, params: Dict[str, Any]) -> Dict[str, Any]: """Check if KiCAD UI is running""" logger.info("Checking if KiCAD UI is running") try: @@ -3040,7 +3041,7 @@ class KiCADInterface: logger.error(f"Error checking KiCAD UI status: {str(e)}") return {"success": False, "message": str(e)} - def _handle_launch_kicad_ui(self, params): + def _handle_launch_kicad_ui(self, params: Dict[str, Any]) -> Dict[str, Any]: """Launch KiCAD UI""" logger.info("Launching KiCAD UI") try: @@ -3059,7 +3060,7 @@ class KiCADInterface: logger.error(f"Error launching KiCAD UI: {str(e)}") return {"success": False, "message": str(e)} - def _handle_refill_zones(self, params): + def _handle_refill_zones(self, params: Dict[str, Any]) -> Dict[str, Any]: """Refill all copper pour zones on the board. pcbnew.ZONE_FILLER.Fill() can cause a C++ access violation (0xC0000005) @@ -3146,7 +3147,7 @@ print("ok") # These methods are called automatically when IPC is available # ========================================================================= - def _ipc_route_trace(self, params): + def _ipc_route_trace(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for route_trace - adds track with real-time UI update""" try: # Extract parameters matching the existing route_trace interface @@ -3189,7 +3190,7 @@ print("ok") logger.error(f"IPC route_trace error: {e}") return {"success": False, "message": str(e)} - def _ipc_add_via(self, params): + def _ipc_add_via(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for add_via - adds via with real-time UI update""" try: position = params.get("position", {}) @@ -3222,7 +3223,7 @@ print("ok") logger.error(f"IPC add_via error: {e}") return {"success": False, "message": str(e)} - def _ipc_add_net(self, params): + def _ipc_add_net(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for add_net""" # Note: Net creation via IPC is limited - nets are typically created # when components are placed. Return success for compatibility. @@ -3234,7 +3235,7 @@ print("ok") "net": {"name": name}, } - def _ipc_add_copper_pour(self, params): + def _ipc_add_copper_pour(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for add_copper_pour - adds zone with real-time UI update""" try: layer = params.get("layer", "F.Cu") @@ -3289,7 +3290,7 @@ print("ok") logger.error(f"IPC add_copper_pour error: {e}") return {"success": False, "message": str(e)} - def _ipc_refill_zones(self, params): + def _ipc_refill_zones(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for refill_zones - refills all zones with real-time UI update""" try: success = self.ipc_board_api.refill_zones() @@ -3304,7 +3305,7 @@ print("ok") logger.error(f"IPC refill_zones error: {e}") return {"success": False, "message": str(e)} - def _ipc_add_text(self, params): + def _ipc_add_text(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for add_text/add_board_text - adds text with real-time UI update""" try: text = params.get("text", "") @@ -3331,7 +3332,7 @@ print("ok") logger.error(f"IPC add_text error: {e}") return {"success": False, "message": str(e)} - def _ipc_set_board_size(self, params): + def _ipc_set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for set_board_size""" try: width = params.get("width", 100) @@ -3353,7 +3354,7 @@ print("ok") logger.error(f"IPC set_board_size error: {e}") return {"success": False, "message": str(e)} - def _ipc_get_board_info(self, params): + def _ipc_get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for get_board_info""" try: size = self.ipc_board_api.get_size() @@ -3378,7 +3379,7 @@ print("ok") logger.error(f"IPC get_board_info error: {e}") return {"success": False, "message": str(e)} - def _ipc_place_component(self, params): + def _ipc_place_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for place_component - places component with real-time UI update""" try: reference = params.get("reference", params.get("componentId", "")) @@ -3419,7 +3420,7 @@ print("ok") logger.error(f"IPC place_component error: {e}") return {"success": False, "message": str(e)} - def _ipc_move_component(self, params): + def _ipc_move_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for move_component - moves component with real-time UI update""" try: reference = params.get("reference", params.get("componentId", "")) @@ -3444,7 +3445,7 @@ print("ok") logger.error(f"IPC move_component error: {e}") return {"success": False, "message": str(e)} - def _ipc_delete_component(self, params): + def _ipc_delete_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for delete_component - deletes component with real-time UI update""" try: reference = params.get("reference", params.get("componentId", "")) @@ -3463,7 +3464,7 @@ print("ok") logger.error(f"IPC delete_component error: {e}") return {"success": False, "message": str(e)} - def _ipc_get_component_list(self, params): + def _ipc_get_component_list(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for get_component_list""" try: components = self.ipc_board_api.list_components() @@ -3473,7 +3474,7 @@ print("ok") logger.error(f"IPC get_component_list error: {e}") return {"success": False, "message": str(e)} - def _ipc_save_project(self, params): + def _ipc_save_project(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for save_project""" try: success = self.ipc_board_api.save() @@ -3486,14 +3487,14 @@ print("ok") logger.error(f"IPC save_project error: {e}") return {"success": False, "message": str(e)} - def _ipc_delete_trace(self, params): + def _ipc_delete_trace(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for delete_trace - Note: IPC doesn't support direct trace deletion yet""" # IPC API doesn't have a direct delete track method # Fall back to SWIG for this operation logger.info("delete_trace: Falling back to SWIG (IPC doesn't support trace deletion)") return self.routing_commands.delete_trace(params) - def _ipc_get_nets_list(self, params): + def _ipc_get_nets_list(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for get_nets_list - gets nets with real-time data""" try: nets = self.ipc_board_api.get_nets() @@ -3503,7 +3504,7 @@ print("ok") logger.error(f"IPC get_nets_list error: {e}") return {"success": False, "message": str(e)} - def _ipc_add_board_outline(self, params): + def _ipc_add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for add_board_outline - adds board edge with real-time UI update. Rounded rectangles are delegated to the SWIG path because the IPC BoardSegment type cannot represent arcs; the SWIG path writes directly to the .kicad_pcb file @@ -3566,7 +3567,7 @@ print("ok") logger.error(f"IPC add_board_outline error: {e}") return {"success": False, "message": str(e)} - def _ipc_add_mounting_hole(self, params): + def _ipc_add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for add_mounting_hole - adds mounting hole with real-time UI update""" try: from kipy.board_types import BoardCircle @@ -3601,7 +3602,7 @@ print("ok") logger.error(f"IPC add_mounting_hole error: {e}") return {"success": False, "message": str(e)} - def _ipc_get_layer_list(self, params): + def _ipc_get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for get_layer_list - gets enabled layers""" try: layers = self.ipc_board_api.get_enabled_layers() @@ -3611,7 +3612,7 @@ print("ok") logger.error(f"IPC get_layer_list error: {e}") return {"success": False, "message": str(e)} - def _ipc_rotate_component(self, params): + def _ipc_rotate_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for rotate_component - rotates component with real-time UI update""" try: reference = params.get("reference", params.get("componentId", "")) @@ -3653,7 +3654,7 @@ print("ok") logger.error(f"IPC rotate_component error: {e}") return {"success": False, "message": str(e)} - def _ipc_get_component_properties(self, params): + def _ipc_get_component_properties(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for get_component_properties - gets detailed component info""" try: reference = params.get("reference", params.get("componentId", "")) @@ -3677,7 +3678,7 @@ print("ok") # Legacy IPC command handlers (explicit ipc_* commands) # ========================================================================= - def _handle_get_backend_info(self, params): + def _handle_get_backend_info(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get information about the current backend""" return { "success": True, @@ -3692,7 +3693,7 @@ print("ok") ), } - def _handle_ipc_add_track(self, params): + def _handle_ipc_add_track(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add a track using IPC backend (real-time)""" if not self.use_ipc or not self.ipc_board_api: return {"success": False, "message": "IPC backend not available"} @@ -3718,7 +3719,7 @@ print("ok") logger.error(f"Error adding track via IPC: {e}") return {"success": False, "message": str(e)} - def _handle_ipc_add_via(self, params): + def _handle_ipc_add_via(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add a via using IPC backend (real-time)""" if not self.use_ipc or not self.ipc_board_api: return {"success": False, "message": "IPC backend not available"} @@ -3741,7 +3742,7 @@ print("ok") logger.error(f"Error adding via via IPC: {e}") return {"success": False, "message": str(e)} - def _handle_ipc_add_text(self, params): + def _handle_ipc_add_text(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add text using IPC backend (real-time)""" if not self.use_ipc or not self.ipc_board_api: return {"success": False, "message": "IPC backend not available"} @@ -3766,7 +3767,7 @@ print("ok") logger.error(f"Error adding text via IPC: {e}") return {"success": False, "message": str(e)} - def _handle_ipc_list_components(self, params): + def _handle_ipc_list_components(self, params: Dict[str, Any]) -> Dict[str, Any]: """List components using IPC backend""" if not self.use_ipc or not self.ipc_board_api: return {"success": False, "message": "IPC backend not available"} @@ -3778,7 +3779,7 @@ print("ok") logger.error(f"Error listing components via IPC: {e}") return {"success": False, "message": str(e)} - def _handle_ipc_get_tracks(self, params): + def _handle_ipc_get_tracks(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get tracks using IPC backend""" if not self.use_ipc or not self.ipc_board_api: return {"success": False, "message": "IPC backend not available"} @@ -3790,7 +3791,7 @@ print("ok") logger.error(f"Error getting tracks via IPC: {e}") return {"success": False, "message": str(e)} - def _handle_ipc_get_vias(self, params): + def _handle_ipc_get_vias(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get vias using IPC backend""" if not self.use_ipc or not self.ipc_board_api: return {"success": False, "message": "IPC backend not available"} @@ -3802,7 +3803,7 @@ print("ok") logger.error(f"Error getting vias via IPC: {e}") return {"success": False, "message": str(e)} - def _handle_ipc_save_board(self, params): + def _handle_ipc_save_board(self, params: Dict[str, Any]) -> Dict[str, Any]: """Save board using IPC backend""" if not self.use_ipc or not self.ipc_board_api: return {"success": False, "message": "IPC backend not available"} @@ -3819,7 +3820,7 @@ print("ok") # JLCPCB API handlers - def _handle_download_jlcpcb_database(self, params): + def _handle_download_jlcpcb_database(self, params: Dict[str, Any]) -> Dict[str, Any]: """Download JLCPCB parts database from JLCSearch API""" try: force = params.get("force", False) @@ -3870,7 +3871,7 @@ print("ok") "message": f"Failed to download database: {str(e)}", } - def _handle_search_jlcpcb_parts(self, params): + def _handle_search_jlcpcb_parts(self, params: Dict[str, Any]) -> Dict[str, Any]: """Search JLCPCB parts database""" try: query = params.get("query") @@ -3909,7 +3910,7 @@ print("ok") logger.error(f"Error searching JLCPCB parts: {e}", exc_info=True) return {"success": False, "message": f"Search failed: {str(e)}"} - def _handle_get_jlcpcb_part(self, params): + def _handle_get_jlcpcb_part(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get detailed information for a specific JLCPCB part""" try: lcsc_number = params.get("lcsc_number") @@ -3929,7 +3930,7 @@ print("ok") logger.error(f"Error getting JLCPCB part: {e}", exc_info=True) return {"success": False, "message": f"Failed to get part info: {str(e)}"} - def _handle_get_jlcpcb_database_stats(self, params): + def _handle_get_jlcpcb_database_stats(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get statistics about JLCPCB database""" try: stats = self.jlcpcb_parts.get_database_stats() @@ -3939,7 +3940,7 @@ print("ok") logger.error(f"Error getting database stats: {e}", exc_info=True) return {"success": False, "message": f"Failed to get stats: {str(e)}"} - def _handle_suggest_jlcpcb_alternatives(self, params): + def _handle_suggest_jlcpcb_alternatives(self, params: Dict[str, Any]) -> Dict[str, Any]: """Suggest alternative JLCPCB parts""" try: lcsc_number = params.get("lcsc_number") @@ -3980,7 +3981,7 @@ print("ok") "message": f"Failed to suggest alternatives: {str(e)}", } - def _handle_enrich_datasheets(self, params): + def _handle_enrich_datasheets(self, params: Dict[str, Any]) -> Dict[str, Any]: """Enrich schematic Datasheet fields from LCSC numbers""" try: from pathlib import Path @@ -3998,7 +3999,7 @@ print("ok") "message": f"Failed to enrich datasheets: {str(e)}", } - def _handle_get_datasheet_url(self, params): + def _handle_get_datasheet_url(self, params: Dict[str, Any]) -> Dict[str, Any]: """Return LCSC datasheet and product URLs for a part number""" try: lcsc = params.get("lcsc", "") @@ -4024,7 +4025,7 @@ print("ok") } -def _write_response(response_fd, response): +def _write_response(response_fd: Any, response: Any) -> None: """Write a JSON response to the original stdout fd. All response output goes through this function so that stray C-level @@ -4035,7 +4036,7 @@ def _write_response(response_fd, response): os.write(response_fd, payload.encode("utf-8")) -def main(): +def main() -> None: """Main entry point""" # --- Redirect stdout so pcbnew C++ noise never reaches the TS host --- # Save the real stdout fd for our exclusive JSON response channel. diff --git a/python/resources/resource_definitions.py b/python/resources/resource_definitions.py index ba893db..5919b3e 100644 --- a/python/resources/resource_definitions.py +++ b/python/resources/resource_definitions.py @@ -72,7 +72,7 @@ RESOURCE_DEFINITIONS = [ # ============================================================================= -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 @@ -116,7 +116,7 @@ def handle_resource_read(uri: str, interface) -> Dict[str, Any]: # ============================================================================= -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({}) @@ -142,7 +142,7 @@ def _get_project_info(interface) -> Dict[str, Any]: } -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({}) @@ -168,7 +168,7 @@ def _get_board_info(interface) -> Dict[str, Any]: } -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({}) @@ -197,7 +197,7 @@ def _get_components(interface) -> Dict[str, Any]: } -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({}) @@ -224,7 +224,7 @@ def _get_nets(interface) -> Dict[str, Any]: } -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({}) @@ -251,7 +251,7 @@ def _get_layers(interface) -> Dict[str, Any]: } -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({}) @@ -277,7 +277,7 @@ def _get_design_rules(interface) -> Dict[str, Any]: } -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({}) @@ -313,7 +313,7 @@ def _get_drc_report(interface) -> Dict[str, Any]: } -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}) diff --git a/python/test_ipc_backend.py b/python/test_ipc_backend.py index a6baf16..2eb5c35 100644 --- a/python/test_ipc_backend.py +++ b/python/test_ipc_backend.py @@ -16,6 +16,7 @@ Usage: 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__))) @@ -29,7 +30,7 @@ logging.basicConfig( 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") @@ -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") @@ -168,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") @@ -200,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") @@ -229,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") @@ -255,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") diff --git a/python/tests/test_delete_schematic_component.py b/python/tests/test_delete_schematic_component.py index cefeb55..7fa7265 100644 --- a/python/tests/test_delete_schematic_component.py +++ b/python/tests/test_delete_schematic_component.py @@ -1,207 +1,208 @@ -""" -Regression tests for delete_schematic_component. - -Key regression: the handler previously used a line-by-line regex that required -`(symbol` and `(lib_id` to appear on the *same* line. KiCAD's file writer puts -them on *separate* lines, so every real-world delete returned "not found". -""" - -import re -import sys -import tempfile -from pathlib import Path - -import pytest - -sys.path.insert(0, str(Path(__file__).parent.parent)) - -TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch" - -# Inline format (single line) – matches what tests previously used -PLACED_RESISTOR_INLINE = """\ - (symbol (lib_id "Device:R") (at 50 50 0) (unit 1) - (in_bom yes) (on_board yes) (dnp no) - (uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") - (property "Reference" "R1" (at 51.27 47.46 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "10k" (at 51.27 52.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at 50 50 0) - (effects (font (size 1.27 1.27)) hide) - ) - ) -""" - -# Multi-line format – as KiCAD's own file writer produces it. -# (symbol and (lib_id are on separate lines, which broke the old regex. -PLACED_RESISTOR_MULTILINE = """\ -\t(symbol -\t\t(lib_id "Device:R") -\t\t(at 50 50 0) -\t\t(unit 1) -\t\t(in_bom yes) -\t\t(on_board yes) -\t\t(dnp no) -\t\t(uuid "bbbbbbbb-cccc-dddd-eeee-ffffffffffff") -\t\t(property "Reference" "R2" -\t\t\t(at 51.27 47.46 0) -\t\t\t(effects -\t\t\t\t(font -\t\t\t\t\t(size 1.27 1.27) -\t\t\t\t) -\t\t\t) -\t\t) -\t\t(property "Value" "4.7k" -\t\t\t(at 51.27 52.54 0) -\t\t\t(effects -\t\t\t\t(font -\t\t\t\t\t(size 1.27 1.27) -\t\t\t\t) -\t\t\t) -\t\t) -\t) -""" - -# Multi-line power symbol – the exact scenario that was reported as broken. -PLACED_POWER_SYMBOL_MULTILINE = """\ -\t(symbol -\t\t(lib_id "power:VCC") -\t\t(at 365.6 38.1 0) -\t\t(unit 1) -\t\t(in_bom yes) -\t\t(on_board yes) -\t\t(dnp no) -\t\t(uuid "cccccccc-dddd-eeee-ffff-000000000030") -\t\t(property "Reference" "#PWR030" -\t\t\t(at 365.6 41.91 0) -\t\t\t(effects -\t\t\t\t(font -\t\t\t\t\t(size 1.27 1.27) -\t\t\t\t) -\t\t\t\t(hide yes) -\t\t\t) -\t\t) -\t\t(property "Value" "VCC" -\t\t\t(at 365.6 35.56 0) -\t\t\t(effects -\t\t\t\t(font -\t\t\t\t\t(size 1.27 1.27) -\t\t\t\t) -\t\t\t) -\t\t) -\t) -""" - - -def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path: - dest = tmp_path / "test.kicad_sch" - src_content = TEMPLATE_SCH.read_text(encoding="utf-8") - if extra_block: - src_content = src_content.rstrip() - if src_content.endswith(")"): - src_content = src_content[:-1] + "\n" + extra_block + ")\n" - dest.write_text(src_content, encoding="utf-8") - return dest - - -# --------------------------------------------------------------------------- -# Unit tests – regression proof for the old regex vs the new approach -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestDeleteDetectionRegex: - """Verify that the new content-string pattern finds blocks in both formats.""" - - OLD_PATTERN = re.compile(r"^\s*\(symbol\s+\(lib_id\s+\"", re.MULTILINE) - NEW_PATTERN = re.compile(r'\(symbol\s+\(lib_id\s+"') - - def test_old_regex_fails_on_multiline_format(self): - """Regression: old line-by-line regex must NOT match the multi-line format.""" - # The old code used re.match on individual lines; simulate that here. - lines = PLACED_RESISTOR_MULTILINE.split("\n") - matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)] - assert matches == [], "Old regex should not match multi-line KiCAD format" - - def test_old_regex_matches_inline_format(self): - """Old regex did work on single-line (inline) format.""" - lines = PLACED_RESISTOR_INLINE.split("\n") - matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)] - assert len(matches) == 1 - - def test_new_pattern_matches_multiline_format(self): - """New content-string pattern must find blocks in multi-line format.""" - assert self.NEW_PATTERN.search(PLACED_RESISTOR_MULTILINE) is not None - - def test_new_pattern_matches_inline_format(self): - """New content-string pattern also works on inline format.""" - assert self.NEW_PATTERN.search(PLACED_RESISTOR_INLINE) is not None - - def test_new_pattern_matches_power_symbol_multiline(self): - """New pattern must find #PWR030 power symbol in multi-line format.""" - assert self.NEW_PATTERN.search(PLACED_POWER_SYMBOL_MULTILINE) is not None - - def test_reference_extraction_from_multiline_block(self): - """Reference property can be found inside a multi-line block.""" - ref_pattern = re.compile(r'\(property\s+"Reference"\s+"#PWR030"') - assert ref_pattern.search(PLACED_POWER_SYMBOL_MULTILINE) is not None - - -# --------------------------------------------------------------------------- -# Integration tests – real file I/O using the handler -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -class TestDeleteSchematicComponentIntegration: - def _get_handler(self): - from kicad_interface import KiCADInterface - - iface = KiCADInterface.__new__(KiCADInterface) - return iface._handle_delete_schematic_component - - def test_delete_inline_format_succeeds(self, tmp_path): - sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE) - result = self._get_handler()({"schematicPath": str(sch), "reference": "R1"}) - assert result["success"] is True - assert result["deleted_count"] == 1 - - def test_delete_multiline_format_succeeds(self, tmp_path): - """Regression: must succeed when KiCAD writes (symbol and (lib_id on separate lines.""" - sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_MULTILINE) - result = self._get_handler()({"schematicPath": str(sch), "reference": "R2"}) - assert result["success"] is True - assert result["deleted_count"] == 1 - - def test_delete_power_symbol_multiline_succeeds(self, tmp_path): - """Regression: #PWR030 multi-line power symbol must be deletable.""" - sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE) - result = self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"}) - assert result["success"] is True - assert result["deleted_count"] == 1 - - def test_component_absent_after_delete(self, tmp_path): - sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE) - self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"}) - remaining = sch.read_text(encoding="utf-8") - assert '"#PWR030"' not in remaining - - def test_unknown_reference_returns_failure(self, tmp_path): - sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE) - result = self._get_handler()({"schematicPath": str(sch), "reference": "U99"}) - assert result["success"] is False - assert "not found" in result["message"] - - def test_missing_schematic_path_returns_failure(self, tmp_path): - result = self._get_handler()({"reference": "R1"}) - assert result["success"] is False - - def test_missing_reference_returns_failure(self, tmp_path): - sch = _make_test_schematic(tmp_path) - result = self._get_handler()({"schematicPath": str(sch)}) - assert result["success"] is False +""" +Regression tests for delete_schematic_component. + +Key regression: the handler previously used a line-by-line regex that required +`(symbol` and `(lib_id` to appear on the *same* line. KiCAD's file writer puts +them on *separate* lines, so every real-world delete returned "not found". +""" + +import re +import sys +import tempfile +from pathlib import Path +from typing import Any + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch" + +# Inline format (single line) – matches what tests previously used +PLACED_RESISTOR_INLINE = """\ + (symbol (lib_id "Device:R") (at 50 50 0) (unit 1) + (in_bom yes) (on_board yes) (dnp no) + (uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + (property "Reference" "R1" (at 51.27 47.46 0) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "10k" (at 51.27 52.54 0) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 50 50 0) + (effects (font (size 1.27 1.27)) hide) + ) + ) +""" + +# Multi-line format – as KiCAD's own file writer produces it. +# (symbol and (lib_id are on separate lines, which broke the old regex. +PLACED_RESISTOR_MULTILINE = """\ +\t(symbol +\t\t(lib_id "Device:R") +\t\t(at 50 50 0) +\t\t(unit 1) +\t\t(in_bom yes) +\t\t(on_board yes) +\t\t(dnp no) +\t\t(uuid "bbbbbbbb-cccc-dddd-eeee-ffffffffffff") +\t\t(property "Reference" "R2" +\t\t\t(at 51.27 47.46 0) +\t\t\t(effects +\t\t\t\t(font +\t\t\t\t\t(size 1.27 1.27) +\t\t\t\t) +\t\t\t) +\t\t) +\t\t(property "Value" "4.7k" +\t\t\t(at 51.27 52.54 0) +\t\t\t(effects +\t\t\t\t(font +\t\t\t\t\t(size 1.27 1.27) +\t\t\t\t) +\t\t\t) +\t\t) +\t) +""" + +# Multi-line power symbol – the exact scenario that was reported as broken. +PLACED_POWER_SYMBOL_MULTILINE = """\ +\t(symbol +\t\t(lib_id "power:VCC") +\t\t(at 365.6 38.1 0) +\t\t(unit 1) +\t\t(in_bom yes) +\t\t(on_board yes) +\t\t(dnp no) +\t\t(uuid "cccccccc-dddd-eeee-ffff-000000000030") +\t\t(property "Reference" "#PWR030" +\t\t\t(at 365.6 41.91 0) +\t\t\t(effects +\t\t\t\t(font +\t\t\t\t\t(size 1.27 1.27) +\t\t\t\t) +\t\t\t\t(hide yes) +\t\t\t) +\t\t) +\t\t(property "Value" "VCC" +\t\t\t(at 365.6 35.56 0) +\t\t\t(effects +\t\t\t\t(font +\t\t\t\t\t(size 1.27 1.27) +\t\t\t\t) +\t\t\t) +\t\t) +\t) +""" + + +def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path: + dest = tmp_path / "test.kicad_sch" + src_content = TEMPLATE_SCH.read_text(encoding="utf-8") + if extra_block: + src_content = src_content.rstrip() + if src_content.endswith(")"): + src_content = src_content[:-1] + "\n" + extra_block + ")\n" + dest.write_text(src_content, encoding="utf-8") + return dest + + +# --------------------------------------------------------------------------- +# Unit tests – regression proof for the old regex vs the new approach +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestDeleteDetectionRegex: + """Verify that the new content-string pattern finds blocks in both formats.""" + + OLD_PATTERN = re.compile(r"^\s*\(symbol\s+\(lib_id\s+\"", re.MULTILINE) + NEW_PATTERN = re.compile(r'\(symbol\s+\(lib_id\s+"') + + def test_old_regex_fails_on_multiline_format(self) -> None: + """Regression: old line-by-line regex must NOT match the multi-line format.""" + # The old code used re.match on individual lines; simulate that here. + lines = PLACED_RESISTOR_MULTILINE.split("\n") + matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)] + assert matches == [], "Old regex should not match multi-line KiCAD format" + + def test_old_regex_matches_inline_format(self) -> None: + """Old regex did work on single-line (inline) format.""" + lines = PLACED_RESISTOR_INLINE.split("\n") + matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)] + assert len(matches) == 1 + + def test_new_pattern_matches_multiline_format(self) -> None: + """New content-string pattern must find blocks in multi-line format.""" + assert self.NEW_PATTERN.search(PLACED_RESISTOR_MULTILINE) is not None + + def test_new_pattern_matches_inline_format(self) -> None: + """New content-string pattern also works on inline format.""" + assert self.NEW_PATTERN.search(PLACED_RESISTOR_INLINE) is not None + + def test_new_pattern_matches_power_symbol_multiline(self) -> None: + """New pattern must find #PWR030 power symbol in multi-line format.""" + assert self.NEW_PATTERN.search(PLACED_POWER_SYMBOL_MULTILINE) is not None + + def test_reference_extraction_from_multiline_block(self) -> None: + """Reference property can be found inside a multi-line block.""" + ref_pattern = re.compile(r'\(property\s+"Reference"\s+"#PWR030"') + assert ref_pattern.search(PLACED_POWER_SYMBOL_MULTILINE) is not None + + +# --------------------------------------------------------------------------- +# Integration tests – real file I/O using the handler +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestDeleteSchematicComponentIntegration: + def _get_handler(self) -> Any: + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + return iface._handle_delete_schematic_component + + def test_delete_inline_format_succeeds(self, tmp_path: Any) -> None: + sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE) + result = self._get_handler()({"schematicPath": str(sch), "reference": "R1"}) + assert result["success"] is True + assert result["deleted_count"] == 1 + + def test_delete_multiline_format_succeeds(self, tmp_path: Any) -> None: + """Regression: must succeed when KiCAD writes (symbol and (lib_id on separate lines.""" + sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_MULTILINE) + result = self._get_handler()({"schematicPath": str(sch), "reference": "R2"}) + assert result["success"] is True + assert result["deleted_count"] == 1 + + def test_delete_power_symbol_multiline_succeeds(self, tmp_path: Any) -> None: + """Regression: #PWR030 multi-line power symbol must be deletable.""" + sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE) + result = self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"}) + assert result["success"] is True + assert result["deleted_count"] == 1 + + def test_component_absent_after_delete(self, tmp_path: Any) -> None: + sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE) + self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"}) + remaining = sch.read_text(encoding="utf-8") + assert '"#PWR030"' not in remaining + + def test_unknown_reference_returns_failure(self, tmp_path: Any) -> None: + sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE) + result = self._get_handler()({"schematicPath": str(sch), "reference": "U99"}) + assert result["success"] is False + assert "not found" in result["message"] + + def test_missing_schematic_path_returns_failure(self, tmp_path: Any) -> None: + result = self._get_handler()({"reference": "R1"}) + assert result["success"] is False + + def test_missing_reference_returns_failure(self, tmp_path: Any) -> None: + sch = _make_test_schematic(tmp_path) + result = self._get_handler()({"schematicPath": str(sch)}) + assert result["success"] is False diff --git a/python/tests/test_freerouting.py b/python/tests/test_freerouting.py index c04f769..116d72c 100644 --- a/python/tests/test_freerouting.py +++ b/python/tests/test_freerouting.py @@ -12,6 +12,7 @@ Covers: import sys from pathlib import Path +from typing import Any from unittest.mock import MagicMock, patch import pytest @@ -33,7 +34,7 @@ pcbnew_mock = sys.modules["pcbnew"] @pytest.fixture(autouse=True) -def reset_pcbnew_mock(): +def reset_pcbnew_mock() -> Any: """Reset pcbnew mock before each test.""" pcbnew_mock.reset_mock() pcbnew_mock.ExportSpecctraDSN.side_effect = None @@ -44,7 +45,7 @@ def reset_pcbnew_mock(): @pytest.fixture -def mock_board(): +def mock_board() -> Any: board = MagicMock() board.GetFileName.return_value = "/tmp/test_project/test.kicad_pcb" board.GetTracks.return_value = [] @@ -52,16 +53,16 @@ def mock_board(): @pytest.fixture -def cmds(mock_board): +def cmds(mock_board: Any) -> Any: return FreeroutingCommands(board=mock_board) @pytest.fixture -def cmds_no_board(): +def cmds_no_board() -> Any: return FreeroutingCommands(board=None) -def _patch_direct_java(): +def _patch_direct_java() -> Any: """Patch to simulate Java 21+ available locally.""" return patch.object( FreeroutingCommands, @@ -70,7 +71,7 @@ def _patch_direct_java(): ) -def _patch_docker_mode(): +def _patch_docker_mode() -> Any: """Patch to simulate Docker execution mode.""" return patch.object( FreeroutingCommands, @@ -79,7 +80,7 @@ def _patch_docker_mode(): ) -def _patch_no_runtime(): +def _patch_no_runtime() -> Any: """Patch to simulate no Java and no Docker.""" return patch.object( FreeroutingCommands, @@ -97,7 +98,7 @@ def _patch_no_runtime(): class TestCheckFreerouting: - def test_no_java_no_docker(self, cmds): + def test_no_java_no_docker(self, cmds: Any) -> None: with ( patch("commands.freerouting._find_java", return_value=None), patch( @@ -112,7 +113,7 @@ class TestCheckFreerouting: assert result["ready"] is False assert result["execution_mode"] == "none" - def test_java_too_old_docker_available(self, cmds, tmp_path): + def test_java_too_old_docker_available(self, cmds: Any, tmp_path: Any) -> None: jar = tmp_path / "freerouting.jar" jar.touch() with ( @@ -135,7 +136,7 @@ class TestCheckFreerouting: assert result["ready"] is True assert result["execution_mode"] == "docker" - def test_java_21_direct(self, cmds, tmp_path): + def test_java_21_direct(self, cmds: Any, tmp_path: Any) -> None: jar = tmp_path / "freerouting.jar" jar.touch() with ( @@ -165,12 +166,12 @@ class TestCheckFreerouting: class TestExportDsn: - def test_no_board(self, cmds_no_board): + def test_no_board(self, cmds_no_board: Any) -> None: result = cmds_no_board.export_dsn({}) assert result["success"] is False assert "No board" in result["message"] - def test_export_success(self, cmds, tmp_path): + def test_export_success(self, cmds: Any, tmp_path: Any) -> None: board_path = str(tmp_path / "test.kicad_pcb") dsn_path = str(tmp_path / "test.dsn") cmds.board.GetFileName.return_value = board_path @@ -182,7 +183,7 @@ class TestExportDsn: assert result["success"] is True assert result["path"] == dsn_path - def test_export_custom_path(self, cmds, tmp_path): + def test_export_custom_path(self, cmds: Any, tmp_path: Any) -> None: output = str(tmp_path / "custom.dsn") pcbnew_mock.ExportSpecctraDSN.return_value = True Path(output).write_text("(pcb test)") @@ -191,7 +192,7 @@ class TestExportDsn: assert result["success"] is True assert result["path"] == output - def test_export_failure(self, cmds): + def test_export_failure(self, cmds: Any) -> None: pcbnew_mock.ExportSpecctraDSN.side_effect = Exception("DSN error") result = cmds.export_dsn({}) assert result["success"] is False @@ -204,22 +205,22 @@ class TestExportDsn: class TestImportSes: - def test_no_board(self, cmds_no_board): + def test_no_board(self, cmds_no_board: Any) -> None: result = cmds_no_board.import_ses({"sesPath": "/tmp/test.ses"}) assert result["success"] is False assert "No board" in result["message"] - def test_missing_ses_path(self, cmds): + def test_missing_ses_path(self, cmds: Any) -> None: result = cmds.import_ses({}) assert result["success"] is False assert "Missing sesPath" in result["message"] - def test_ses_file_not_found(self, cmds): + def test_ses_file_not_found(self, cmds: Any) -> None: result = cmds.import_ses({"sesPath": "/nonexistent/test.ses"}) assert result["success"] is False assert "not found" in result["message"] - def test_import_success(self, cmds, tmp_path): + def test_import_success(self, cmds: Any, tmp_path: Any) -> None: ses_file = tmp_path / "test.ses" ses_file.write_text("(session test)") @@ -229,7 +230,7 @@ class TestImportSes: result = cmds.import_ses({"sesPath": str(ses_file)}) assert result["success"] is True - def test_import_failure(self, cmds, tmp_path): + def test_import_failure(self, cmds: Any, tmp_path: Any) -> None: ses_file = tmp_path / "test.ses" ses_file.write_text("(session test)") @@ -245,12 +246,12 @@ class TestImportSes: class TestAutoroute: - def test_no_board(self, cmds_no_board): + def test_no_board(self, cmds_no_board: Any) -> None: result = cmds_no_board.autoroute({}) assert result["success"] is False assert "No board" in result["message"] - def test_no_runtime(self, cmds, tmp_path): + def test_no_runtime(self, cmds: Any, tmp_path: Any) -> None: jar = tmp_path / "freerouting.jar" jar.touch() with _patch_no_runtime(): @@ -258,13 +259,13 @@ class TestAutoroute: assert result["success"] is False assert "No suitable Java runtime" in result["message"] - def test_no_jar(self, cmds): + def test_no_jar(self, cmds: Any) -> None: result = cmds.autoroute({"freeroutingJar": "/nonexistent/freerouting.jar"}) assert result["success"] is False assert "JAR not found" in result["message"] @patch("commands.freerouting.subprocess.run") - def test_dsn_export_fails(self, mock_run, cmds, tmp_path): + def test_dsn_export_fails(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None: jar = tmp_path / "freerouting.jar" jar.touch() @@ -276,7 +277,7 @@ class TestAutoroute: assert "DSN export failed" in result["message"] @patch("commands.freerouting.subprocess.run") - def test_freerouting_timeout(self, mock_run, cmds, tmp_path): + def test_freerouting_timeout(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None: import subprocess jar = tmp_path / "freerouting.jar" @@ -300,7 +301,7 @@ class TestAutoroute: assert "timed out" in result["message"] @patch("commands.freerouting.subprocess.run") - def test_full_success_direct(self, mock_run, cmds, tmp_path): + def test_full_success_direct(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None: jar = tmp_path / "freerouting.jar" jar.touch() board_dir = tmp_path / "project" @@ -336,7 +337,7 @@ class TestAutoroute: assert "elapsed_seconds" in result @patch("commands.freerouting.subprocess.run") - def test_full_success_docker(self, mock_run, cmds, tmp_path): + def test_full_success_docker(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None: jar = tmp_path / "freerouting.jar" jar.touch() board_dir = tmp_path / "project" @@ -375,7 +376,7 @@ class TestAutoroute: assert "--rm" in call_args @patch("commands.freerouting.subprocess.run") - def test_freerouting_nonzero_exit(self, mock_run, cmds, tmp_path): + def test_freerouting_nonzero_exit(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None: jar = tmp_path / "freerouting.jar" jar.touch() board_dir = tmp_path / "project" @@ -404,14 +405,14 @@ class TestAutoroute: class TestFindJava: - def test_finds_via_which(self): + def test_finds_via_which(self) -> None: with patch( "commands.freerouting.shutil.which", return_value="/usr/bin/java", ): assert _find_java() == "/usr/bin/java" - def test_none_when_not_found(self): + def test_none_when_not_found(self) -> None: with ( patch( "commands.freerouting.shutil.which", @@ -423,21 +424,21 @@ class TestFindJava: class TestFindDocker: - def test_finds_docker(self): + def test_finds_docker(self) -> None: with patch( "commands.freerouting.shutil.which", side_effect=lambda x: "/usr/bin/docker" if x == "docker" else None, ): assert _find_docker() == "/usr/bin/docker" - def test_finds_podman(self): + def test_finds_podman(self) -> None: with patch( "commands.freerouting.shutil.which", side_effect=lambda x: "/usr/bin/podman" if x == "podman" else None, ): assert _find_docker() == "/usr/bin/podman" - def test_none_when_not_found(self): + def test_none_when_not_found(self) -> None: with patch( "commands.freerouting.shutil.which", return_value=None, @@ -446,7 +447,7 @@ class TestFindDocker: class TestDockerAvailable: - def test_docker_found(self): + def test_docker_found(self) -> None: with ( patch( "commands.freerouting._find_docker", @@ -457,14 +458,14 @@ class TestDockerAvailable: mock_run.return_value = MagicMock(returncode=0) assert _docker_available() is True - def test_docker_not_installed(self): + def test_docker_not_installed(self) -> None: with patch( "commands.freerouting._find_docker", return_value=None, ): assert _docker_available() is False - def test_docker_not_running(self): + def test_docker_not_running(self) -> None: with ( patch( "commands.freerouting._find_docker", @@ -477,17 +478,17 @@ class TestDockerAvailable: class TestJavaVersionOk: - def test_java_21(self): + def test_java_21(self) -> None: with patch("commands.freerouting.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stderr='openjdk version "21.0.1"', stdout="") assert _java_version_ok("/usr/bin/java") is True - def test_java_17(self): + def test_java_17(self) -> None: with patch("commands.freerouting.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stderr='openjdk version "17.0.18"', stdout="") assert _java_version_ok("/usr/bin/java") is False - def test_java_error(self): + def test_java_error(self) -> None: with patch( "commands.freerouting.subprocess.run", side_effect=Exception("not found"), diff --git a/python/tests/test_move_with_wire_preservation.py b/python/tests/test_move_with_wire_preservation.py index fceefc8..0952d41 100644 --- a/python/tests/test_move_with_wire_preservation.py +++ b/python/tests/test_move_with_wire_preservation.py @@ -1,1009 +1,1012 @@ -""" -Tests for move_schematic_component with wire preservation (WireDragger). - -Unit tests use synthetic sexpdata lists — no disk I/O, no KiCAD install needed. -Integration tests copy empty.kicad_sch to a tempdir and exercise the full handler. -""" - -import shutil -import sys -import tempfile -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -import sexpdata -from sexpdata import Symbol - -# Make python/ importable -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from commands.wire_dragger import EPS, WireDragger, _coords_match, _rotate - -TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "empty.kicad_sch" - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _sym(name: str) -> Symbol: - return Symbol(name) - - -def _make_wire(x1, y1, x2, y2): - return [ - _sym("wire"), - [_sym("pts"), [_sym("xy"), x1, y1], [_sym("xy"), x2, y2]], - [_sym("stroke"), [_sym("width"), 0], [_sym("type"), _sym("default")]], - [_sym("uuid"), "00000000-0000-0000-0000-000000000000"], - ] - - -def _make_junction(x, y): - return [ - _sym("junction"), - [_sym("at"), x, y], - [_sym("diameter"), 0], - [_sym("color"), 0, 0, 0, 0], - [_sym("uuid"), "00000000-0000-0000-0000-000000000001"], - ] - - -def _make_symbol(ref, x, y, rotation=0, lib_id="Device:R", mirror=None): - """Build a minimal placed-symbol s-expression.""" - item = [ - _sym("symbol"), - [_sym("lib_id"), lib_id], - [_sym("at"), x, y, rotation], - [_sym("unit"), 1], - [_sym("property"), "Reference", ref, [_sym("at"), x + 2, y, 0]], - [_sym("property"), "Value", "10k", [_sym("at"), x, y, 0]], - ] - if mirror: - item.append([_sym("mirror"), _sym(mirror)]) - return item - - -def _make_lib_symbol_r(): - """Minimal Device:R lib_symbols entry — pins at (0, 3.81) and (0, -3.81).""" - return [ - _sym("symbol"), - "Device:R", - [ - _sym("symbol"), - "R_1_1", - [ - _sym("pin"), - _sym("passive"), - _sym("line"), - [_sym("at"), 0, 3.81, 270], - [_sym("length"), 1.27], - [ - _sym("name"), - "~", - [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]], - ], - [ - _sym("number"), - "1", - [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]], - ], - ], - [ - _sym("pin"), - _sym("passive"), - _sym("line"), - [_sym("at"), 0, -3.81, 90], - [_sym("length"), 1.27], - [ - _sym("name"), - "~", - [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]], - ], - [ - _sym("number"), - "2", - [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]], - ], - ], - ], - ] - - -def _make_sch_data(extra_items=None): - """Build a minimal sch_data list with lib_symbols and sheet_instances.""" - data = [ - _sym("kicad_sch"), - [_sym("lib_symbols"), _make_lib_symbol_r()], - [_sym("sheet_instances"), [_sym("path"), "/", [_sym("page"), "1"]]], - ] - if extra_items: - # Insert before sheet_instances (last item) - for item in extra_items: - data.insert(len(data) - 1, item) - return data - - -# --------------------------------------------------------------------------- -# TestRotatePoint -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestRotatePoint: - def test_zero_rotation(self): - assert _rotate(1.0, 2.0, 0) == (1.0, 2.0) - - def test_90_degrees(self): - rx, ry = _rotate(1.0, 0.0, 90) - assert abs(rx - 0.0) < 1e-9 - assert abs(ry - 1.0) < 1e-9 - - def test_180_degrees(self): - rx, ry = _rotate(1.0, 0.0, 180) - assert abs(rx - (-1.0)) < 1e-9 - assert abs(ry - 0.0) < 1e-9 - - def test_270_degrees(self): - rx, ry = _rotate(0.0, 1.0, 270) - assert abs(rx - 1.0) < 1e-6 - assert abs(ry - 0.0) < 1e-6 - - -# --------------------------------------------------------------------------- -# TestFindSymbol -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestFindSymbol: - def test_returns_none_for_missing_reference(self): - sch = _make_sch_data([_make_symbol("R1", 10, 20)]) - assert WireDragger.find_symbol(sch, "R99") is None - - def test_returns_item_and_position(self): - sch = _make_sch_data([_make_symbol("R1", 10.5, 20.5, rotation=90)]) - result = WireDragger.find_symbol(sch, "R1") - assert result is not None - _, old_x, old_y, rotation, lib_id, mirror_x, mirror_y = result - assert abs(old_x - 10.5) < EPS - assert abs(old_y - 20.5) < EPS - assert abs(rotation - 90) < EPS - assert lib_id == "Device:R" - assert mirror_x is False - assert mirror_y is False - - def test_detects_mirror_x(self): - sch = _make_sch_data([_make_symbol("R1", 0, 0, mirror="x")]) - result = WireDragger.find_symbol(sch, "R1") - assert result is not None - assert result[5] is True # mirror_x - assert result[6] is False # mirror_y - - def test_detects_mirror_y(self): - sch = _make_sch_data([_make_symbol("R1", 0, 0, mirror="y")]) - result = WireDragger.find_symbol(sch, "R1") - assert result is not None - assert result[5] is False # mirror_x - assert result[6] is True # mirror_y - - -# --------------------------------------------------------------------------- -# TestComputePinPositions -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestComputePinPositions: - def test_resistor_at_origin_no_rotation(self): - """Device:R at (0, 0) rot=0 — pins at (0, 3.81) and (0, -3.81).""" - sch = _make_sch_data([_make_symbol("R1", 0, 0)]) - positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20) - assert "1" in positions and "2" in positions - old1, new1 = positions["1"] - old2, new2 = positions["2"] - # Pin 1 old: (0 + 0, 0 + 3.81) - assert abs(old1[0] - 0) < 1e-4 - assert abs(old1[1] - 3.81) < 1e-4 - # Pin 2 old: (0 + 0, 0 - 3.81) - assert abs(old2[0] - 0) < 1e-4 - assert abs(old2[1] - (-3.81)) < 1e-4 - # New positions shifted by (10, 20) - assert abs(new1[0] - 10) < 1e-4 - assert abs(new1[1] - 23.81) < 1e-4 - assert abs(new2[0] - 10) < 1e-4 - assert abs(new2[1] - 16.19) < 1e-4 - - def test_resistor_rotated_90(self): - """Device:R at (100, 100) rot=90 — pins should be at (100+3.81, 100) and (100-3.81, 100).""" - sch = _make_sch_data([_make_symbol("R1", 100, 100, rotation=90)]) - positions = WireDragger.compute_pin_positions(sch, "R1", 100, 100) - old1, _ = positions["1"] - old2, _ = positions["2"] - # rotate(0, 3.81, 90) = (0*cos90 - 3.81*sin90, 0*sin90 + 3.81*cos90) = (-3.81, 0) - # Wait — pin 1 is at local (0, 3.81), rotated 90° CCW: - # x' = 0*cos90 - 3.81*sin90 = -3.81, y' = 0*sin90 + 3.81*cos90 ≈ 0 - # world: (100 - 3.81, 100 + 0) = (96.19, 100) - assert abs(old1[0] - 96.19) < 1e-3 - assert abs(old1[1] - 100) < 1e-3 - - def test_returns_empty_for_missing_component(self): - sch = _make_sch_data() - result = WireDragger.compute_pin_positions(sch, "MISSING", 0, 0) - assert result == {} - - def test_delta_is_consistent(self): - """new_xy - old_xy should equal (new_x - old_x, new_y - old_y) for any rotation.""" - sch = _make_sch_data([_make_symbol("R1", 50, 50, rotation=45)]) - positions = WireDragger.compute_pin_positions(sch, "R1", 60, 70) - for pin_num, (old_xy, new_xy) in positions.items(): - dx = new_xy[0] - old_xy[0] - dy = new_xy[1] - old_xy[1] - assert abs(dx - 10) < 1e-4, f"Pin {pin_num}: dx={dx}" - assert abs(dy - 20) < 1e-4, f"Pin {pin_num}: dy={dy}" - - -# --------------------------------------------------------------------------- -# TestDragWires -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestDragWires: - def test_no_wires_returns_zero_counts(self): - sch = _make_sch_data() - result = WireDragger.drag_wires(sch, {(0.0, 0.0): (10.0, 10.0)}) - assert result["endpoints_moved"] == 0 - assert result["wires_removed"] == 0 - - def test_wire_start_endpoint_moved(self): - wire = _make_wire(0, 3.81, 0, 10) - sch = _make_sch_data([wire]) - result = WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) - assert result["endpoints_moved"] == 1 - assert result["wires_removed"] == 0 - # Find the updated wire in sch_data - updated = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")) - pts = next(s for s in updated[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")) - xy1 = pts[1] - assert abs(xy1[1] - 10.0) < EPS - assert abs(xy1[2] - 23.81) < EPS - - def test_wire_end_endpoint_moved(self): - wire = _make_wire(0, 10, 0, -3.81) - sch = _make_sch_data([wire]) - result = WireDragger.drag_wires(sch, {(0.0, -3.81): (10.0, 16.19)}) - assert result["endpoints_moved"] == 1 - updated = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")) - pts = next(s for s in updated[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")) - xy2 = pts[2] - assert abs(xy2[1] - 10.0) < EPS - assert abs(xy2[2] - 16.19) < EPS - - def test_zero_length_wire_removed(self): - """When both endpoints of a wire are moved to the same point, wire is deleted.""" - wire = _make_wire(0, 3.81, 0, -3.81) - sch = _make_sch_data([wire]) - # Both pins land at same position (degenerate move) - result = WireDragger.drag_wires( - sch, - { - (0.0, 3.81): (5.0, 5.0), - (0.0, -3.81): (5.0, 5.0), - }, - ) - assert result["wires_removed"] == 1 - wires_remaining = [i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")] - assert len(wires_remaining) == 0 - - def test_unrelated_wire_not_touched(self): - """A wire whose endpoints don't match any old pin is not changed.""" - wire = _make_wire(50, 50, 60, 50) - sch = _make_sch_data([wire]) - original_start = (50.0, 50.0) - result = WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) - assert result["endpoints_moved"] == 0 - updated = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")) - pts = next(s for s in updated[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")) - xy1 = pts[1] - assert abs(xy1[1] - 50.0) < EPS - assert abs(xy1[2] - 50.0) < EPS - - def test_both_endpoints_on_moved_component(self): - """Wire connecting two pins of same component — both endpoints shift together.""" - wire = _make_wire(0, 3.81, 0, -3.81) - sch = _make_sch_data([wire]) - result = WireDragger.drag_wires( - sch, - { - (0.0, 3.81): (10.0, 23.81), - (0.0, -3.81): (10.0, 16.19), - }, - ) - assert result["endpoints_moved"] == 2 - assert result["wires_removed"] == 0 - - def test_junction_moved_with_endpoint(self): - junction = _make_junction(0, 3.81) - sch = _make_sch_data([junction]) - WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) - updated_j = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("junction")) - at_sub = next( - s for s in updated_j[1:] if isinstance(s, list) and s and s[0] == Symbol("at") - ) - assert abs(at_sub[1] - 10.0) < EPS - assert abs(at_sub[2] - 23.81) < EPS - - def test_junction_at_unrelated_position_not_touched(self): - junction = _make_junction(99, 99) - sch = _make_sch_data([junction]) - WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) - updated_j = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("junction")) - at_sub = next( - s for s in updated_j[1:] if isinstance(s, list) and s and s[0] == Symbol("at") - ) - assert abs(at_sub[1] - 99.0) < EPS - assert abs(at_sub[2] - 99.0) < EPS - - -# --------------------------------------------------------------------------- -# TestUpdateSymbolPosition -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestUpdateSymbolPosition: - def test_updates_position(self): - sch = _make_sch_data([_make_symbol("R1", 10, 20)]) - result = WireDragger.update_symbol_position(sch, "R1", 30, 40) - assert result is True - found = WireDragger.find_symbol(sch, "R1") - assert abs(found[1] - 30) < EPS - assert abs(found[2] - 40) < EPS - - def test_returns_false_for_missing(self): - sch = _make_sch_data() - assert WireDragger.update_symbol_position(sch, "MISSING", 0, 0) is False - - def test_preserves_rotation(self): - sch = _make_sch_data([_make_symbol("R1", 10, 20, rotation=90)]) - WireDragger.update_symbol_position(sch, "R1", 30, 40) - found = WireDragger.find_symbol(sch, "R1") - assert abs(found[3] - 90) < EPS # rotation preserved - - def test_property_labels_follow_symbol_move(self): - """Property (at ...) positions must shift by the same delta as the symbol.""" - sym = _make_symbol("R1", 100, 80) - sch = _make_sch_data([sym]) - - # Record initial property positions - prop_k = _sym("property") - at_k = _sym("at") - initial_positions = {} - for sub in sym[1:]: - if isinstance(sub, list) and sub and sub[0] == prop_k: - name = sub[1] - for psub in sub[2:]: - if isinstance(psub, list) and psub and psub[0] == at_k: - initial_positions[name] = (psub[1], psub[2]) - break - assert len(initial_positions) >= 2 # Reference and Value at minimum - - # Move component from (100, 80) to (120, 100) — delta (20, 20) - result = WireDragger.update_symbol_position(sch, "R1", 120, 100) - assert result is True - - # Verify each property shifted by (20, 20) - for sub in sym[1:]: - if isinstance(sub, list) and sub and sub[0] == prop_k: - name = sub[1] - for psub in sub[2:]: - if isinstance(psub, list) and psub and psub[0] == at_k: - expected_x = initial_positions[name][0] + 20 - expected_y = initial_positions[name][1] + 20 - assert ( - abs(psub[1] - expected_x) < EPS - ), f"{name} x: expected {expected_x}, got {psub[1]}" - assert ( - abs(psub[2] - expected_y) < EPS - ), f"{name} y: expected {expected_y}, got {psub[2]}" - break - - -# --------------------------------------------------------------------------- -# Integration tests -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -class TestMoveWithWirePreservation: - """Integration tests using a real .kicad_sch file.""" - - def _make_schematic(self, extra_sexp=""): - """Copy empty.kicad_sch to a temp file and optionally append content.""" - tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" - shutil.copy(TEMPLATE_PATH, tmp) - if extra_sexp: - content = tmp.read_text(encoding="utf-8") - idx = content.rfind(")") - content = content[:idx] + "\n" + extra_sexp + "\n)" - tmp.write_text(content, encoding="utf-8") - return tmp - - def _add_resistor(self, path: Path, ref: str, x: float, y: float, rotation: float = 0) -> Path: - """Append a Device:R symbol to the schematic file.""" - import uuid - - u = str(uuid.uuid4()) - sexp = f""" - (symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1) - (in_bom yes) (on_board yes) (dnp no) - (uuid "{u}") - (property "Reference" "{ref}" (at {x + 2.032} {y} 90) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "10k" (at {x} {y} 90) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at {x - 1.778} {y} 90) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at {x} {y} 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid "{uuid.uuid4()}")) - (pin "2" (uuid "{uuid.uuid4()}")) - (instances (project "test" (path "/" (reference "{ref}") (unit 1)))) - )""" - content = path.read_text(encoding="utf-8") - idx = content.rfind(")") - path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8") - return path - - def _add_wire(self, path: Path, x1, y1, x2, y2) -> Path: - """Append a wire to the schematic file.""" - import uuid - - wire_sexp = f""" - (wire (pts (xy {x1} {y1}) (xy {x2} {y2})) - (stroke (width 0) (type default)) - (uuid "{uuid.uuid4()}") - )""" - content = path.read_text(encoding="utf-8") - idx = content.rfind(")") - path.write_text(content[:idx] + "\n" + wire_sexp + "\n)", encoding="utf-8") - return path - - def _parse_wires(self, path: Path): - """Return list of ((x1,y1),(x2,y2)) for every wire in the file.""" - content = path.read_text(encoding="utf-8") - data = sexpdata.loads(content) - wires = [] - for item in data: - if not (isinstance(item, list) and item and item[0] == Symbol("wire")): - continue - pts = next( - (s for s in item[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")), - None, - ) - if pts is None: - continue - xys = [ - p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy") - ] - if len(xys) >= 2: - wires.append( - ( - (float(xys[0][1]), float(xys[0][2])), - (float(xys[-1][1]), float(xys[-1][2])), - ) - ) - return wires - - def _get_symbol_pos(self, path: Path, ref: str): - content = path.read_text(encoding="utf-8") - data = sexpdata.loads(content) - found = WireDragger.find_symbol(data, ref) - if found is None: - return None - return found[1], found[2] - - def test_symbol_position_updated(self): - sch = self._make_schematic() - self._add_resistor(sch, "R1", 100, 100) - # Call handler directly - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from kicad_interface import KiCADInterface - - iface = KiCADInterface() - result = iface.handle_command( - "move_schematic_component", - { - "schematicPath": str(sch), - "reference": "R1", - "position": {"x": 120, "y": 130}, - }, - ) - assert result["success"], result.get("message") - pos = self._get_symbol_pos(sch, "R1") - assert abs(pos[0] - 120) < EPS - assert abs(pos[1] - 130) < EPS - - def test_connected_wire_endpoint_follows_pin(self): - """Wire endpoint at pin 1 of R1 should move with the component.""" - sch = self._make_schematic() - # R1 at (100, 100) — pin 1 at (100, 103.81) - self._add_resistor(sch, "R1", 100, 100) - self._add_wire(sch, 100, 103.81, 100, 120) # wire from pin 1 upward - - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from kicad_interface import KiCADInterface - - iface = KiCADInterface() - result = iface.handle_command( - "move_schematic_component", - { - "schematicPath": str(sch), - "reference": "R1", - "position": {"x": 110, "y": 100}, - }, - ) - assert result["success"], result.get("message") - assert result["wiresMoved"] >= 1 - - wires = self._parse_wires(sch) - assert len(wires) == 1 - # Pin 1 new world position: (110 + 0, 100 + 3.81) = (110, 103.81) - w = wires[0] - endpoints = {w[0], w[1]} - new_pin1 = (110.0, 103.81) - assert any( - abs(ep[0] - new_pin1[0]) < 0.01 and abs(ep[1] - new_pin1[1]) < 0.01 for ep in endpoints - ), f"Expected pin endpoint near {new_pin1}, got {endpoints}" - - def test_unrelated_wire_unchanged(self): - """A wire not connected to R1 must not be modified.""" - sch = self._make_schematic() - self._add_resistor(sch, "R1", 100, 100) - self._add_wire(sch, 50, 50, 60, 50) # unrelated wire - - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from kicad_interface import KiCADInterface - - iface = KiCADInterface() - iface.handle_command( - "move_schematic_component", - { - "schematicPath": str(sch), - "reference": "R1", - "position": {"x": 110, "y": 110}, - }, - ) - - wires = self._parse_wires(sch) - unrelated = [(s, e) for s, e in wires if abs(s[0] - 50) < 0.01 and abs(s[1] - 50) < 0.01] - assert len(unrelated) == 1 - - def test_no_zero_length_wires_after_move(self): - """No zero-length wires should appear in the file after a move.""" - sch = self._make_schematic() - self._add_resistor(sch, "R1", 100, 100) - # Wire from pin 1 to pin 2 of same component (intra-component wire) - self._add_wire(sch, 100, 103.81, 100, 96.19) - - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from kicad_interface import KiCADInterface - - iface = KiCADInterface() - iface.handle_command( - "move_schematic_component", - { - "schematicPath": str(sch), - "reference": "R1", - "position": {"x": 110, "y": 100}, - }, - ) - - wires = self._parse_wires(sch) - for start, end in wires: - assert not ( - abs(start[0] - end[0]) < EPS and abs(start[1] - end[1]) < EPS - ), f"Zero-length wire found at {start}" - - def test_preserve_wires_false_skips_wire_update(self): - """preserveWires=False should move the symbol but leave wires alone.""" - sch = self._make_schematic() - self._add_resistor(sch, "R1", 100, 100) - self._add_wire(sch, 100, 103.81, 100, 120) - - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from kicad_interface import KiCADInterface - - iface = KiCADInterface() - result = iface.handle_command( - "move_schematic_component", - { - "schematicPath": str(sch), - "reference": "R1", - "position": {"x": 110, "y": 100}, - "preserveWires": False, - }, - ) - assert result["success"] - assert result["wiresMoved"] == 0 - - # Wire should still start at old pin position - wires = self._parse_wires(sch) - assert len(wires) == 1 - endpoints = {wires[0][0], wires[0][1]} - old_pin1 = (100.0, 103.81) - assert any( - abs(ep[0] - old_pin1[0]) < 0.01 and abs(ep[1] - old_pin1[1]) < 0.01 for ep in endpoints - ), f"Wire should still be at {old_pin1}, got {endpoints}" - - def test_missing_component_returns_error(self): - sch = self._make_schematic() - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from kicad_interface import KiCADInterface - - iface = KiCADInterface() - result = iface.handle_command( - "move_schematic_component", - { - "schematicPath": str(sch), - "reference": "NOTHERE", - "position": {"x": 0, "y": 0}, - }, - ) - assert not result["success"] - assert "not found" in result.get("message", "").lower() - - -# --------------------------------------------------------------------------- -# TestSynthesizeTouchingPinWires (unit) -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestSynthesizeTouchingPinWires: - """Unit tests for WireDragger.synthesize_touching_pin_wires.""" - - def _make_two_resistors(self, r1_x, r1_y, r2_x, r2_y): - """Build sch_data with R1 and R2, each Device:R.""" - return _make_sch_data( - [ - _make_symbol("R1", r1_x, r1_y), - _make_symbol("R2", r2_x, r2_y), - ] - ) - - def test_no_stationary_symbols_returns_zero(self): - """With only the moved component in sch_data, nothing is synthesized.""" - sch = _make_sch_data([_make_symbol("R1", 0, 0)]) - pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20) - count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) - assert count == 0 - - def test_touching_pin_gap_generates_wire(self): - """ - R1 at (0, 0) pin2 at (0, -3.81). - R2 at (0, -7.62) pin1 at (0, -3.81). ← pins touch - Moving R1 to (10, 0) causes pin2 to move to (10, -3.81). - A wire from (0, -3.81) to (10, -3.81) should be synthesized. - """ - # R2 pin1 is at (0, -7.62 + 3.81) = (0, -3.81) - sch = self._make_two_resistors(0, 0, 0, -7.62) - - # Verify the touching: R1 pin2 old = (0, -3.81), R2 pin1 = (0, -3.81) - pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0) - old2, new2 = pin_positions["2"] - assert abs(old2[0] - 0) < 1e-3 and abs(old2[1] - (-3.81)) < 1e-3 - assert abs(new2[0] - 10) < 1e-3 and abs(new2[1] - (-3.81)) < 1e-3 - - wire_count_before = sum( - 1 for item in sch if isinstance(item, list) and item and item[0] == _sym("wire") - ) - count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) - assert count == 1, f"Expected 1 synthesized wire, got {count}" - - wires = [ - item for item in sch if isinstance(item, list) and item and item[0] == _sym("wire") - ] - assert len(wires) == wire_count_before + 1 - - # The new wire should span (0, -3.81) → (10, -3.81) - new_wire = wires[-1] - pts = next(s for s in new_wire[1:] if isinstance(s, list) and s and s[0] == _sym("pts")) - xys = [p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == _sym("xy")] - assert len(xys) == 2 - endpoints = { - (round(float(xys[0][1]), 3), round(float(xys[0][2]), 3)), - (round(float(xys[1][1]), 3), round(float(xys[1][2]), 3)), - } - assert (0.0, -3.81) in endpoints, f"Expected (0, -3.81) in wire endpoints, got {endpoints}" - assert ( - 10.0, - -3.81, - ) in endpoints, f"Expected (10, -3.81) in wire endpoints, got {endpoints}" - - def test_no_wire_when_pin_didnt_move(self): - """ - If old_xy == new_xy for a touching pin (component moved but this pin stayed put), - no wire should be synthesized. - """ - # R1 at (0, 0), R2 at (0, -7.62) — pin2 of R1 and pin1 of R2 touch at (0, -3.81) - sch = self._make_two_resistors(0, 0, 0, -7.62) - # Moving R1 to (0, 0) — effectively no move, same position - pin_positions = WireDragger.compute_pin_positions(sch, "R1", 0, 0) - count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) - assert count == 0 - - def test_no_wire_when_rejoins_other_stationary_pin(self): - """ - If the moved pin's new position coincides with another stationary pin, - no wire should be synthesized (they touch again). - """ - # R1 at (0, 0), R2 at (0, -7.62), R3 at (10, -7.62) - # R1 pin2 was touching R2 pin1 at (0, -3.81). - # Moving R1 to (10, 0): pin2 lands at (10, -3.81) which is R3 pin1. - sch = _make_sch_data( - [ - _make_symbol("R1", 0, 0), - _make_symbol("R2", 0, -7.62), - _make_symbol("R3", 10, -7.62), - ] - ) - pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0) - count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) - assert count == 0, f"Expected 0 synthesized wires (rejoined), got {count}" - - def test_empty_pin_positions_returns_zero(self): - sch = _make_sch_data([_make_symbol("R1", 0, 0)]) - count = WireDragger.synthesize_touching_pin_wires(sch, "R1", {}) - assert count == 0 - - def test_non_touching_pins_not_affected(self): - """ - When R1 and R2 are NOT touching (different positions), no wire is synthesized. - """ - # R1 at (0, 0), R2 at (100, 100) — far apart - sch = self._make_two_resistors(0, 0, 100, 100) - pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0) - count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) - assert count == 0 - - -# --------------------------------------------------------------------------- -# TestOldToNewCollision (unit) — regression for the duplicate-pin-position bug -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestOldToNewCollision: - """Verify that coincident pins do not silently overwrite each other in old_to_new.""" - - def test_handler_logs_warning_on_collision(self, caplog): - """ - When two pins share the same old position, a warning should be logged - and the *first* mapping should be kept (not overwritten by the second). - """ - import logging - - # Build a fake pin_positions dict with a deliberate collision - pin_positions = { - "1": ((0.0, 3.81), (10.0, 23.81)), - "2": ((0.0, 3.81), (10.0, 16.19)), # same old_xy as pin "1" - } - - old_to_new = {} - with caplog.at_level(logging.WARNING, logger="kicad_interface"): - for _pin, (old_xy, new_xy) in pin_positions.items(): - if old_xy in old_to_new: - import logging as _logging - - logger_inner = _logging.getLogger("kicad_interface") - logger_inner.warning( - f"move_schematic_component: pin {_pin!r} shares old position {old_xy} " - f"with another pin; keeping first entry, skipping duplicate" - ) - continue - old_to_new[old_xy] = new_xy - - # Only one entry should exist, and it should be the first one - assert len(old_to_new) == 1 - assert old_to_new[(0.0, 3.81)] == (10.0, 23.81) - # Warning should have been logged - assert any("skipping duplicate" in r.message for r in caplog.records) - - -# --------------------------------------------------------------------------- -# TestTouchingPinIntegration (integration) -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -class TestTouchingPinIntegration: - """Integration tests for pin-touching connection wire synthesis.""" - - def _make_schematic(self, extra_sexp=""): - """Copy empty.kicad_sch to a temp file.""" - tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" - shutil.copy(TEMPLATE_PATH, tmp) - if extra_sexp: - content = tmp.read_text(encoding="utf-8") - idx = content.rfind(")") - content = content[:idx] + "\n" + extra_sexp + "\n)" - tmp.write_text(content, encoding="utf-8") - return tmp - - def _add_resistor(self, path: Path, ref: str, x: float, y: float, rotation: float = 0) -> Path: - import uuid as _uuid - - u = str(_uuid.uuid4()) - sexp = f""" - (symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1) - (in_bom yes) (on_board yes) (dnp no) - (uuid "{u}") - (property "Reference" "{ref}" (at {x + 2.032} {y} 90) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "10k" (at {x} {y} 90) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at {x - 1.778} {y} 90) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at {x} {y} 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid "{_uuid.uuid4()}")) - (pin "2" (uuid "{_uuid.uuid4()}")) - (instances (project "test" (path "/" (reference "{ref}") (unit 1)))) - )""" - content = path.read_text(encoding="utf-8") - idx = content.rfind(")") - path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8") - return path - - def _parse_wires(self, path: Path): - content = path.read_text(encoding="utf-8") - data = sexpdata.loads(content) - wires = [] - for item in data: - if not (isinstance(item, list) and item and item[0] == Symbol("wire")): - continue - pts = next( - (s for s in item[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")), - None, - ) - if pts is None: - continue - xys = [ - p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy") - ] - if len(xys) >= 2: - wires.append( - ( - (float(xys[0][1]), float(xys[0][2])), - (float(xys[-1][1]), float(xys[-1][2])), - ) - ) - return wires - - def test_touching_pin_wire_created_on_move(self): - """ - R1 at (100, 100) and R2 at (100, 92.38) share a touching pin: - R1 pin2 = (100, 96.19), R2 pin1 = (100, 96.19). - Moving R1 to (110, 100) should synthesize a wire from (100, 96.19) to (110, 96.19). - """ - sch = self._make_schematic() - # R1 pin2 world position = 100 + (-3.81) = 96.19 - # R2 pin1 world position = 92.38 + 3.81 = 96.19 - self._add_resistor(sch, "R1", 100, 100) - self._add_resistor(sch, "R2", 100, 92.38) - - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from kicad_interface import KiCADInterface - - iface = KiCADInterface() - result = iface.handle_command( - "move_schematic_component", - { - "schematicPath": str(sch), - "reference": "R1", - "position": {"x": 110, "y": 100}, - }, - ) - assert result["success"], result.get("message") - assert ( - result.get("wiresSynthesized", 0) >= 1 - ), f"Expected at least 1 synthesized wire, got {result}" - - wires = self._parse_wires(sch) - # There should be a wire bridging the old and new pin2 positions - old_pin2 = (100.0, 96.19) - new_pin2 = (110.0, 96.19) - bridging = [ - (s, e) - for s, e in wires - if ( - ( - abs(s[0] - old_pin2[0]) < 0.05 - and abs(s[1] - old_pin2[1]) < 0.05 - and abs(e[0] - new_pin2[0]) < 0.05 - and abs(e[1] - new_pin2[1]) < 0.05 - ) - or ( - abs(e[0] - old_pin2[0]) < 0.05 - and abs(e[1] - old_pin2[1]) < 0.05 - and abs(s[0] - new_pin2[0]) < 0.05 - and abs(s[1] - new_pin2[1]) < 0.05 - ) - ) - ] - assert ( - len(bridging) >= 1 - ), f"Expected a bridging wire from {old_pin2} to {new_pin2}, got wires: {wires}" - - def test_no_wire_synthesized_when_no_touching_pins(self): - """ - Two resistors with no pin overlap should not generate any synthesized wires. - """ - sch = self._make_schematic() - self._add_resistor(sch, "R1", 100, 100) - self._add_resistor(sch, "R2", 150, 150) # far away - - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from kicad_interface import KiCADInterface - - iface = KiCADInterface() - result = iface.handle_command( - "move_schematic_component", - { - "schematicPath": str(sch), - "reference": "R1", - "position": {"x": 110, "y": 100}, - }, - ) - assert result["success"], result.get("message") - assert result.get("wiresSynthesized", 0) == 0 - - def test_existing_wires_still_dragged_with_touching_pins(self): - """ - When R1 has both an explicit wire AND a touching-pin connection, - both should be handled: the wire dragged and the touching-pin bridged. - """ - sch = self._make_schematic() - # R1 at (100, 100), R2 at (100, 92.38) — pin2 of R1 touches pin1 of R2 - self._add_resistor(sch, "R1", 100, 100) - self._add_resistor(sch, "R2", 100, 92.38) - - # Also add an explicit wire at pin1 of R1 (100, 103.81) going up - import uuid as _uuid - - wire_sexp = f""" - (wire (pts (xy 100 103.81) (xy 100 115)) - (stroke (width 0) (type default)) - (uuid "{_uuid.uuid4()}") - )""" - content = sch.read_text(encoding="utf-8") - idx = content.rfind(")") - sch.write_text(content[:idx] + "\n" + wire_sexp + "\n)", encoding="utf-8") - - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from kicad_interface import KiCADInterface - - iface = KiCADInterface() - result = iface.handle_command( - "move_schematic_component", - { - "schematicPath": str(sch), - "reference": "R1", - "position": {"x": 110, "y": 100}, - }, - ) - assert result["success"], result.get("message") - assert result.get("wiresMoved", 0) >= 1, "Expected at least one wire endpoint dragged" - assert result.get("wiresSynthesized", 0) >= 1, "Expected at least one touching-pin wire" +""" +Tests for move_schematic_component with wire preservation (WireDragger). + +Unit tests use synthetic sexpdata lists — no disk I/O, no KiCAD install needed. +Integration tests copy empty.kicad_sch to a tempdir and exercise the full handler. +""" + +import shutil +import sys +import tempfile +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +import sexpdata +from sexpdata import Symbol + +# Make python/ importable +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from commands.wire_dragger import EPS, WireDragger, _coords_match, _rotate + +TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "empty.kicad_sch" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _sym(name: str) -> Symbol: + return Symbol(name) + + +def _make_wire(x1: Any, y1: Any, x2: Any, y2: Any) -> Any: + return [ + _sym("wire"), + [_sym("pts"), [_sym("xy"), x1, y1], [_sym("xy"), x2, y2]], + [_sym("stroke"), [_sym("width"), 0], [_sym("type"), _sym("default")]], + [_sym("uuid"), "00000000-0000-0000-0000-000000000000"], + ] + + +def _make_junction(x: Any, y: Any) -> Any: + return [ + _sym("junction"), + [_sym("at"), x, y], + [_sym("diameter"), 0], + [_sym("color"), 0, 0, 0, 0], + [_sym("uuid"), "00000000-0000-0000-0000-000000000001"], + ] + + +def _make_symbol( + ref: Any, x: Any, y: Any, rotation: Any = 0, lib_id: str = "Device:R", mirror: Any = None +) -> Any: + """Build a minimal placed-symbol s-expression.""" + item = [ + _sym("symbol"), + [_sym("lib_id"), lib_id], + [_sym("at"), x, y, rotation], + [_sym("unit"), 1], + [_sym("property"), "Reference", ref, [_sym("at"), x + 2, y, 0]], + [_sym("property"), "Value", "10k", [_sym("at"), x, y, 0]], + ] + if mirror: + item.append([_sym("mirror"), _sym(mirror)]) + return item + + +def _make_lib_symbol_r() -> Any: + """Minimal Device:R lib_symbols entry — pins at (0, 3.81) and (0, -3.81).""" + return [ + _sym("symbol"), + "Device:R", + [ + _sym("symbol"), + "R_1_1", + [ + _sym("pin"), + _sym("passive"), + _sym("line"), + [_sym("at"), 0, 3.81, 270], + [_sym("length"), 1.27], + [ + _sym("name"), + "~", + [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]], + ], + [ + _sym("number"), + "1", + [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]], + ], + ], + [ + _sym("pin"), + _sym("passive"), + _sym("line"), + [_sym("at"), 0, -3.81, 90], + [_sym("length"), 1.27], + [ + _sym("name"), + "~", + [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]], + ], + [ + _sym("number"), + "2", + [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]], + ], + ], + ], + ] + + +def _make_sch_data(extra_items: Any = None) -> Any: + """Build a minimal sch_data list with lib_symbols and sheet_instances.""" + data = [ + _sym("kicad_sch"), + [_sym("lib_symbols"), _make_lib_symbol_r()], + [_sym("sheet_instances"), [_sym("path"), "/", [_sym("page"), "1"]]], + ] + if extra_items: + # Insert before sheet_instances (last item) + for item in extra_items: + data.insert(len(data) - 1, item) + return data + + +# --------------------------------------------------------------------------- +# TestRotatePoint +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestRotatePoint: + def test_zero_rotation(self) -> None: + assert _rotate(1.0, 2.0, 0) == (1.0, 2.0) + + def test_90_degrees(self) -> None: + rx, ry = _rotate(1.0, 0.0, 90) + assert abs(rx - 0.0) < 1e-9 + assert abs(ry - 1.0) < 1e-9 + + def test_180_degrees(self) -> None: + rx, ry = _rotate(1.0, 0.0, 180) + assert abs(rx - (-1.0)) < 1e-9 + assert abs(ry - 0.0) < 1e-9 + + def test_270_degrees(self) -> None: + rx, ry = _rotate(0.0, 1.0, 270) + assert abs(rx - 1.0) < 1e-6 + assert abs(ry - 0.0) < 1e-6 + + +# --------------------------------------------------------------------------- +# TestFindSymbol +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestFindSymbol: + def test_returns_none_for_missing_reference(self) -> None: + sch = _make_sch_data([_make_symbol("R1", 10, 20)]) + assert WireDragger.find_symbol(sch, "R99") is None + + def test_returns_item_and_position(self) -> None: + sch = _make_sch_data([_make_symbol("R1", 10.5, 20.5, rotation=90)]) + result = WireDragger.find_symbol(sch, "R1") + assert result is not None + _, old_x, old_y, rotation, lib_id, mirror_x, mirror_y = result + assert abs(old_x - 10.5) < EPS + assert abs(old_y - 20.5) < EPS + assert abs(rotation - 90) < EPS + assert lib_id == "Device:R" + assert mirror_x is False + assert mirror_y is False + + def test_detects_mirror_x(self) -> None: + sch = _make_sch_data([_make_symbol("R1", 0, 0, mirror="x")]) + result = WireDragger.find_symbol(sch, "R1") + assert result is not None + assert result[5] is True # mirror_x + assert result[6] is False # mirror_y + + def test_detects_mirror_y(self) -> None: + sch = _make_sch_data([_make_symbol("R1", 0, 0, mirror="y")]) + result = WireDragger.find_symbol(sch, "R1") + assert result is not None + assert result[5] is False # mirror_x + assert result[6] is True # mirror_y + + +# --------------------------------------------------------------------------- +# TestComputePinPositions +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestComputePinPositions: + def test_resistor_at_origin_no_rotation(self) -> None: + """Device:R at (0, 0) rot=0 — pins at (0, 3.81) and (0, -3.81).""" + sch = _make_sch_data([_make_symbol("R1", 0, 0)]) + positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20) + assert "1" in positions and "2" in positions + old1, new1 = positions["1"] + old2, new2 = positions["2"] + # Pin 1 old: (0 + 0, 0 + 3.81) + assert abs(old1[0] - 0) < 1e-4 + assert abs(old1[1] - 3.81) < 1e-4 + # Pin 2 old: (0 + 0, 0 - 3.81) + assert abs(old2[0] - 0) < 1e-4 + assert abs(old2[1] - (-3.81)) < 1e-4 + # New positions shifted by (10, 20) + assert abs(new1[0] - 10) < 1e-4 + assert abs(new1[1] - 23.81) < 1e-4 + assert abs(new2[0] - 10) < 1e-4 + assert abs(new2[1] - 16.19) < 1e-4 + + def test_resistor_rotated_90(self) -> None: + """Device:R at (100, 100) rot=90 — pins should be at (100+3.81, 100) and (100-3.81, 100).""" + sch = _make_sch_data([_make_symbol("R1", 100, 100, rotation=90)]) + positions = WireDragger.compute_pin_positions(sch, "R1", 100, 100) + old1, _ = positions["1"] + old2, _ = positions["2"] + # rotate(0, 3.81, 90) = (0*cos90 - 3.81*sin90, 0*sin90 + 3.81*cos90) = (-3.81, 0) + # Wait — pin 1 is at local (0, 3.81), rotated 90° CCW: + # x' = 0*cos90 - 3.81*sin90 = -3.81, y' = 0*sin90 + 3.81*cos90 ≈ 0 + # world: (100 - 3.81, 100 + 0) = (96.19, 100) + assert abs(old1[0] - 96.19) < 1e-3 + assert abs(old1[1] - 100) < 1e-3 + + def test_returns_empty_for_missing_component(self) -> None: + sch = _make_sch_data() + result = WireDragger.compute_pin_positions(sch, "MISSING", 0, 0) + assert result == {} + + def test_delta_is_consistent(self) -> None: + """new_xy - old_xy should equal (new_x - old_x, new_y - old_y) for any rotation.""" + sch = _make_sch_data([_make_symbol("R1", 50, 50, rotation=45)]) + positions = WireDragger.compute_pin_positions(sch, "R1", 60, 70) + for pin_num, (old_xy, new_xy) in positions.items(): + dx = new_xy[0] - old_xy[0] + dy = new_xy[1] - old_xy[1] + assert abs(dx - 10) < 1e-4, f"Pin {pin_num}: dx={dx}" + assert abs(dy - 20) < 1e-4, f"Pin {pin_num}: dy={dy}" + + +# --------------------------------------------------------------------------- +# TestDragWires +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestDragWires: + def test_no_wires_returns_zero_counts(self) -> None: + sch = _make_sch_data() + result = WireDragger.drag_wires(sch, {(0.0, 0.0): (10.0, 10.0)}) + assert result["endpoints_moved"] == 0 + assert result["wires_removed"] == 0 + + def test_wire_start_endpoint_moved(self) -> None: + wire = _make_wire(0, 3.81, 0, 10) + sch = _make_sch_data([wire]) + result = WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) + assert result["endpoints_moved"] == 1 + assert result["wires_removed"] == 0 + # Find the updated wire in sch_data + updated = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")) + pts = next(s for s in updated[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")) + xy1 = pts[1] + assert abs(xy1[1] - 10.0) < EPS + assert abs(xy1[2] - 23.81) < EPS + + def test_wire_end_endpoint_moved(self) -> None: + wire = _make_wire(0, 10, 0, -3.81) + sch = _make_sch_data([wire]) + result = WireDragger.drag_wires(sch, {(0.0, -3.81): (10.0, 16.19)}) + assert result["endpoints_moved"] == 1 + updated = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")) + pts = next(s for s in updated[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")) + xy2 = pts[2] + assert abs(xy2[1] - 10.0) < EPS + assert abs(xy2[2] - 16.19) < EPS + + def test_zero_length_wire_removed(self) -> None: + """When both endpoints of a wire are moved to the same point, wire is deleted.""" + wire = _make_wire(0, 3.81, 0, -3.81) + sch = _make_sch_data([wire]) + # Both pins land at same position (degenerate move) + result = WireDragger.drag_wires( + sch, + { + (0.0, 3.81): (5.0, 5.0), + (0.0, -3.81): (5.0, 5.0), + }, + ) + assert result["wires_removed"] == 1 + wires_remaining = [i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")] + assert len(wires_remaining) == 0 + + def test_unrelated_wire_not_touched(self) -> None: + """A wire whose endpoints don't match any old pin is not changed.""" + wire = _make_wire(50, 50, 60, 50) + sch = _make_sch_data([wire]) + original_start = (50.0, 50.0) + result = WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) + assert result["endpoints_moved"] == 0 + updated = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")) + pts = next(s for s in updated[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")) + xy1 = pts[1] + assert abs(xy1[1] - 50.0) < EPS + assert abs(xy1[2] - 50.0) < EPS + + def test_both_endpoints_on_moved_component(self) -> None: + """Wire connecting two pins of same component — both endpoints shift together.""" + wire = _make_wire(0, 3.81, 0, -3.81) + sch = _make_sch_data([wire]) + result = WireDragger.drag_wires( + sch, + { + (0.0, 3.81): (10.0, 23.81), + (0.0, -3.81): (10.0, 16.19), + }, + ) + assert result["endpoints_moved"] == 2 + assert result["wires_removed"] == 0 + + def test_junction_moved_with_endpoint(self) -> None: + junction = _make_junction(0, 3.81) + sch = _make_sch_data([junction]) + WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) + updated_j = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("junction")) + at_sub = next( + s for s in updated_j[1:] if isinstance(s, list) and s and s[0] == Symbol("at") + ) + assert abs(at_sub[1] - 10.0) < EPS + assert abs(at_sub[2] - 23.81) < EPS + + def test_junction_at_unrelated_position_not_touched(self) -> None: + junction = _make_junction(99, 99) + sch = _make_sch_data([junction]) + WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) + updated_j = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("junction")) + at_sub = next( + s for s in updated_j[1:] if isinstance(s, list) and s and s[0] == Symbol("at") + ) + assert abs(at_sub[1] - 99.0) < EPS + assert abs(at_sub[2] - 99.0) < EPS + + +# --------------------------------------------------------------------------- +# TestUpdateSymbolPosition +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestUpdateSymbolPosition: + def test_updates_position(self) -> None: + sch = _make_sch_data([_make_symbol("R1", 10, 20)]) + result = WireDragger.update_symbol_position(sch, "R1", 30, 40) + assert result is True + found = WireDragger.find_symbol(sch, "R1") + assert abs(found[1] - 30) < EPS + assert abs(found[2] - 40) < EPS + + def test_returns_false_for_missing(self) -> None: + sch = _make_sch_data() + assert WireDragger.update_symbol_position(sch, "MISSING", 0, 0) is False + + def test_preserves_rotation(self) -> None: + sch = _make_sch_data([_make_symbol("R1", 10, 20, rotation=90)]) + WireDragger.update_symbol_position(sch, "R1", 30, 40) + found = WireDragger.find_symbol(sch, "R1") + assert abs(found[3] - 90) < EPS # rotation preserved + + def test_property_labels_follow_symbol_move(self) -> None: + """Property (at ...) positions must shift by the same delta as the symbol.""" + sym = _make_symbol("R1", 100, 80) + sch = _make_sch_data([sym]) + + # Record initial property positions + prop_k = _sym("property") + at_k = _sym("at") + initial_positions = {} + for sub in sym[1:]: + if isinstance(sub, list) and sub and sub[0] == prop_k: + name = sub[1] + for psub in sub[2:]: + if isinstance(psub, list) and psub and psub[0] == at_k: + initial_positions[name] = (psub[1], psub[2]) + break + assert len(initial_positions) >= 2 # Reference and Value at minimum + + # Move component from (100, 80) to (120, 100) — delta (20, 20) + result = WireDragger.update_symbol_position(sch, "R1", 120, 100) + assert result is True + + # Verify each property shifted by (20, 20) + for sub in sym[1:]: + if isinstance(sub, list) and sub and sub[0] == prop_k: + name = sub[1] + for psub in sub[2:]: + if isinstance(psub, list) and psub and psub[0] == at_k: + expected_x = initial_positions[name][0] + 20 + expected_y = initial_positions[name][1] + 20 + assert ( + abs(psub[1] - expected_x) < EPS + ), f"{name} x: expected {expected_x}, got {psub[1]}" + assert ( + abs(psub[2] - expected_y) < EPS + ), f"{name} y: expected {expected_y}, got {psub[2]}" + break + + +# --------------------------------------------------------------------------- +# Integration tests +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestMoveWithWirePreservation: + """Integration tests using a real .kicad_sch file.""" + + def _make_schematic(self, extra_sexp: Any = "") -> Any: + """Copy empty.kicad_sch to a temp file and optionally append content.""" + tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" + shutil.copy(TEMPLATE_PATH, tmp) + if extra_sexp: + content = tmp.read_text(encoding="utf-8") + idx = content.rfind(")") + content = content[:idx] + "\n" + extra_sexp + "\n)" + tmp.write_text(content, encoding="utf-8") + return tmp + + def _add_resistor(self, path: Path, ref: str, x: float, y: float, rotation: float = 0) -> Path: + """Append a Device:R symbol to the schematic file.""" + import uuid + + u = str(uuid.uuid4()) + sexp = f""" + (symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1) + (in_bom yes) (on_board yes) (dnp no) + (uuid "{u}") + (property "Reference" "{ref}" (at {x + 2.032} {y} 90) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "10k" (at {x} {y} 90) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at {x - 1.778} {y} 90) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at {x} {y} 0) + (effects (font (size 1.27 1.27)) hide) + ) + (pin "1" (uuid "{uuid.uuid4()}")) + (pin "2" (uuid "{uuid.uuid4()}")) + (instances (project "test" (path "/" (reference "{ref}") (unit 1)))) + )""" + content = path.read_text(encoding="utf-8") + idx = content.rfind(")") + path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8") + return path + + def _add_wire(self, path: Path, x1: float, y1: float, x2: float, y2: float) -> Path: + """Append a wire to the schematic file.""" + import uuid + + wire_sexp = f""" + (wire (pts (xy {x1} {y1}) (xy {x2} {y2})) + (stroke (width 0) (type default)) + (uuid "{uuid.uuid4()}") + )""" + content = path.read_text(encoding="utf-8") + idx = content.rfind(")") + path.write_text(content[:idx] + "\n" + wire_sexp + "\n)", encoding="utf-8") + return path + + def _parse_wires(self, path: Path) -> Any: + """Return list of ((x1,y1),(x2,y2)) for every wire in the file.""" + content = path.read_text(encoding="utf-8") + data = sexpdata.loads(content) + wires = [] + for item in data: + if not (isinstance(item, list) and item and item[0] == Symbol("wire")): + continue + pts = next( + (s for s in item[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")), + None, + ) + if pts is None: + continue + xys = [ + p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy") + ] + if len(xys) >= 2: + wires.append( + ( + (float(xys[0][1]), float(xys[0][2])), + (float(xys[-1][1]), float(xys[-1][2])), + ) + ) + return wires + + def _get_symbol_pos(self, path: Path, ref: str) -> Any: + content = path.read_text(encoding="utf-8") + data = sexpdata.loads(content) + found = WireDragger.find_symbol(data, ref) + if found is None: + return None + return found[1], found[2] + + def test_symbol_position_updated(self) -> None: + sch = self._make_schematic() + self._add_resistor(sch, "R1", 100, 100) + # Call handler directly + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + result = iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 120, "y": 130}, + }, + ) + assert result["success"], result.get("message") + pos = self._get_symbol_pos(sch, "R1") + assert abs(pos[0] - 120) < EPS + assert abs(pos[1] - 130) < EPS + + def test_connected_wire_endpoint_follows_pin(self) -> None: + """Wire endpoint at pin 1 of R1 should move with the component.""" + sch = self._make_schematic() + # R1 at (100, 100) — pin 1 at (100, 103.81) + self._add_resistor(sch, "R1", 100, 100) + self._add_wire(sch, 100, 103.81, 100, 120) # wire from pin 1 upward + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + result = iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 110, "y": 100}, + }, + ) + assert result["success"], result.get("message") + assert result["wiresMoved"] >= 1 + + wires = self._parse_wires(sch) + assert len(wires) == 1 + # Pin 1 new world position: (110 + 0, 100 + 3.81) = (110, 103.81) + w = wires[0] + endpoints = {w[0], w[1]} + new_pin1 = (110.0, 103.81) + assert any( + abs(ep[0] - new_pin1[0]) < 0.01 and abs(ep[1] - new_pin1[1]) < 0.01 for ep in endpoints + ), f"Expected pin endpoint near {new_pin1}, got {endpoints}" + + def test_unrelated_wire_unchanged(self) -> None: + """A wire not connected to R1 must not be modified.""" + sch = self._make_schematic() + self._add_resistor(sch, "R1", 100, 100) + self._add_wire(sch, 50, 50, 60, 50) # unrelated wire + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 110, "y": 110}, + }, + ) + + wires = self._parse_wires(sch) + unrelated = [(s, e) for s, e in wires if abs(s[0] - 50) < 0.01 and abs(s[1] - 50) < 0.01] + assert len(unrelated) == 1 + + def test_no_zero_length_wires_after_move(self) -> None: + """No zero-length wires should appear in the file after a move.""" + sch = self._make_schematic() + self._add_resistor(sch, "R1", 100, 100) + # Wire from pin 1 to pin 2 of same component (intra-component wire) + self._add_wire(sch, 100, 103.81, 100, 96.19) + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 110, "y": 100}, + }, + ) + + wires = self._parse_wires(sch) + for start, end in wires: + assert not ( + abs(start[0] - end[0]) < EPS and abs(start[1] - end[1]) < EPS + ), f"Zero-length wire found at {start}" + + def test_preserve_wires_false_skips_wire_update(self) -> None: + """preserveWires=False should move the symbol but leave wires alone.""" + sch = self._make_schematic() + self._add_resistor(sch, "R1", 100, 100) + self._add_wire(sch, 100, 103.81, 100, 120) + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + result = iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 110, "y": 100}, + "preserveWires": False, + }, + ) + assert result["success"] + assert result["wiresMoved"] == 0 + + # Wire should still start at old pin position + wires = self._parse_wires(sch) + assert len(wires) == 1 + endpoints = {wires[0][0], wires[0][1]} + old_pin1 = (100.0, 103.81) + assert any( + abs(ep[0] - old_pin1[0]) < 0.01 and abs(ep[1] - old_pin1[1]) < 0.01 for ep in endpoints + ), f"Wire should still be at {old_pin1}, got {endpoints}" + + def test_missing_component_returns_error(self) -> None: + sch = self._make_schematic() + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + result = iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "NOTHERE", + "position": {"x": 0, "y": 0}, + }, + ) + assert not result["success"] + assert "not found" in result.get("message", "").lower() + + +# --------------------------------------------------------------------------- +# TestSynthesizeTouchingPinWires (unit) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestSynthesizeTouchingPinWires: + """Unit tests for WireDragger.synthesize_touching_pin_wires.""" + + def _make_two_resistors(self, r1_x: Any, r1_y: Any, r2_x: Any, r2_y: Any) -> Any: + """Build sch_data with R1 and R2, each Device:R.""" + return _make_sch_data( + [ + _make_symbol("R1", r1_x, r1_y), + _make_symbol("R2", r2_x, r2_y), + ] + ) + + def test_no_stationary_symbols_returns_zero(self) -> None: + """With only the moved component in sch_data, nothing is synthesized.""" + sch = _make_sch_data([_make_symbol("R1", 0, 0)]) + pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20) + count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) + assert count == 0 + + def test_touching_pin_gap_generates_wire(self) -> None: + """ + R1 at (0, 0) pin2 at (0, -3.81). + R2 at (0, -7.62) pin1 at (0, -3.81). ← pins touch + Moving R1 to (10, 0) causes pin2 to move to (10, -3.81). + A wire from (0, -3.81) to (10, -3.81) should be synthesized. + """ + # R2 pin1 is at (0, -7.62 + 3.81) = (0, -3.81) + sch = self._make_two_resistors(0, 0, 0, -7.62) + + # Verify the touching: R1 pin2 old = (0, -3.81), R2 pin1 = (0, -3.81) + pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0) + old2, new2 = pin_positions["2"] + assert abs(old2[0] - 0) < 1e-3 and abs(old2[1] - (-3.81)) < 1e-3 + assert abs(new2[0] - 10) < 1e-3 and abs(new2[1] - (-3.81)) < 1e-3 + + wire_count_before = sum( + 1 for item in sch if isinstance(item, list) and item and item[0] == _sym("wire") + ) + count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) + assert count == 1, f"Expected 1 synthesized wire, got {count}" + + wires = [ + item for item in sch if isinstance(item, list) and item and item[0] == _sym("wire") + ] + assert len(wires) == wire_count_before + 1 + + # The new wire should span (0, -3.81) → (10, -3.81) + new_wire = wires[-1] + pts = next(s for s in new_wire[1:] if isinstance(s, list) and s and s[0] == _sym("pts")) + xys = [p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == _sym("xy")] + assert len(xys) == 2 + endpoints = { + (round(float(xys[0][1]), 3), round(float(xys[0][2]), 3)), + (round(float(xys[1][1]), 3), round(float(xys[1][2]), 3)), + } + assert (0.0, -3.81) in endpoints, f"Expected (0, -3.81) in wire endpoints, got {endpoints}" + assert ( + 10.0, + -3.81, + ) in endpoints, f"Expected (10, -3.81) in wire endpoints, got {endpoints}" + + def test_no_wire_when_pin_didnt_move(self) -> None: + """ + If old_xy == new_xy for a touching pin (component moved but this pin stayed put), + no wire should be synthesized. + """ + # R1 at (0, 0), R2 at (0, -7.62) — pin2 of R1 and pin1 of R2 touch at (0, -3.81) + sch = self._make_two_resistors(0, 0, 0, -7.62) + # Moving R1 to (0, 0) — effectively no move, same position + pin_positions = WireDragger.compute_pin_positions(sch, "R1", 0, 0) + count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) + assert count == 0 + + def test_no_wire_when_rejoins_other_stationary_pin(self) -> None: + """ + If the moved pin's new position coincides with another stationary pin, + no wire should be synthesized (they touch again). + """ + # R1 at (0, 0), R2 at (0, -7.62), R3 at (10, -7.62) + # R1 pin2 was touching R2 pin1 at (0, -3.81). + # Moving R1 to (10, 0): pin2 lands at (10, -3.81) which is R3 pin1. + sch = _make_sch_data( + [ + _make_symbol("R1", 0, 0), + _make_symbol("R2", 0, -7.62), + _make_symbol("R3", 10, -7.62), + ] + ) + pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0) + count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) + assert count == 0, f"Expected 0 synthesized wires (rejoined), got {count}" + + def test_empty_pin_positions_returns_zero(self) -> None: + sch = _make_sch_data([_make_symbol("R1", 0, 0)]) + count = WireDragger.synthesize_touching_pin_wires(sch, "R1", {}) + assert count == 0 + + def test_non_touching_pins_not_affected(self) -> None: + """ + When R1 and R2 are NOT touching (different positions), no wire is synthesized. + """ + # R1 at (0, 0), R2 at (100, 100) — far apart + sch = self._make_two_resistors(0, 0, 100, 100) + pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0) + count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) + assert count == 0 + + +# --------------------------------------------------------------------------- +# TestOldToNewCollision (unit) — regression for the duplicate-pin-position bug +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestOldToNewCollision: + """Verify that coincident pins do not silently overwrite each other in old_to_new.""" + + def test_handler_logs_warning_on_collision(self, caplog: Any) -> None: + """ + When two pins share the same old position, a warning should be logged + and the *first* mapping should be kept (not overwritten by the second). + """ + import logging + + # Build a fake pin_positions dict with a deliberate collision + pin_positions = { + "1": ((0.0, 3.81), (10.0, 23.81)), + "2": ((0.0, 3.81), (10.0, 16.19)), # same old_xy as pin "1" + } + + old_to_new = {} + with caplog.at_level(logging.WARNING, logger="kicad_interface"): + for _pin, (old_xy, new_xy) in pin_positions.items(): + if old_xy in old_to_new: + import logging as _logging + + logger_inner = _logging.getLogger("kicad_interface") + logger_inner.warning( + f"move_schematic_component: pin {_pin!r} shares old position {old_xy} " + f"with another pin; keeping first entry, skipping duplicate" + ) + continue + old_to_new[old_xy] = new_xy + + # Only one entry should exist, and it should be the first one + assert len(old_to_new) == 1 + assert old_to_new[(0.0, 3.81)] == (10.0, 23.81) + # Warning should have been logged + assert any("skipping duplicate" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# TestTouchingPinIntegration (integration) +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestTouchingPinIntegration: + """Integration tests for pin-touching connection wire synthesis.""" + + def _make_schematic(self, extra_sexp: Any = "") -> Any: + """Copy empty.kicad_sch to a temp file.""" + tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" + shutil.copy(TEMPLATE_PATH, tmp) + if extra_sexp: + content = tmp.read_text(encoding="utf-8") + idx = content.rfind(")") + content = content[:idx] + "\n" + extra_sexp + "\n)" + tmp.write_text(content, encoding="utf-8") + return tmp + + def _add_resistor(self, path: Path, ref: str, x: float, y: float, rotation: float = 0) -> Path: + import uuid as _uuid + + u = str(_uuid.uuid4()) + sexp = f""" + (symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1) + (in_bom yes) (on_board yes) (dnp no) + (uuid "{u}") + (property "Reference" "{ref}" (at {x + 2.032} {y} 90) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "10k" (at {x} {y} 90) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at {x - 1.778} {y} 90) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at {x} {y} 0) + (effects (font (size 1.27 1.27)) hide) + ) + (pin "1" (uuid "{_uuid.uuid4()}")) + (pin "2" (uuid "{_uuid.uuid4()}")) + (instances (project "test" (path "/" (reference "{ref}") (unit 1)))) + )""" + content = path.read_text(encoding="utf-8") + idx = content.rfind(")") + path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8") + return path + + def _parse_wires(self, path: Path) -> Any: + content = path.read_text(encoding="utf-8") + data = sexpdata.loads(content) + wires = [] + for item in data: + if not (isinstance(item, list) and item and item[0] == Symbol("wire")): + continue + pts = next( + (s for s in item[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")), + None, + ) + if pts is None: + continue + xys = [ + p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy") + ] + if len(xys) >= 2: + wires.append( + ( + (float(xys[0][1]), float(xys[0][2])), + (float(xys[-1][1]), float(xys[-1][2])), + ) + ) + return wires + + def test_touching_pin_wire_created_on_move(self) -> None: + """ + R1 at (100, 100) and R2 at (100, 92.38) share a touching pin: + R1 pin2 = (100, 96.19), R2 pin1 = (100, 96.19). + Moving R1 to (110, 100) should synthesize a wire from (100, 96.19) to (110, 96.19). + """ + sch = self._make_schematic() + # R1 pin2 world position = 100 + (-3.81) = 96.19 + # R2 pin1 world position = 92.38 + 3.81 = 96.19 + self._add_resistor(sch, "R1", 100, 100) + self._add_resistor(sch, "R2", 100, 92.38) + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + result = iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 110, "y": 100}, + }, + ) + assert result["success"], result.get("message") + assert ( + result.get("wiresSynthesized", 0) >= 1 + ), f"Expected at least 1 synthesized wire, got {result}" + + wires = self._parse_wires(sch) + # There should be a wire bridging the old and new pin2 positions + old_pin2 = (100.0, 96.19) + new_pin2 = (110.0, 96.19) + bridging = [ + (s, e) + for s, e in wires + if ( + ( + abs(s[0] - old_pin2[0]) < 0.05 + and abs(s[1] - old_pin2[1]) < 0.05 + and abs(e[0] - new_pin2[0]) < 0.05 + and abs(e[1] - new_pin2[1]) < 0.05 + ) + or ( + abs(e[0] - old_pin2[0]) < 0.05 + and abs(e[1] - old_pin2[1]) < 0.05 + and abs(s[0] - new_pin2[0]) < 0.05 + and abs(s[1] - new_pin2[1]) < 0.05 + ) + ) + ] + assert ( + len(bridging) >= 1 + ), f"Expected a bridging wire from {old_pin2} to {new_pin2}, got wires: {wires}" + + def test_no_wire_synthesized_when_no_touching_pins(self) -> None: + """ + Two resistors with no pin overlap should not generate any synthesized wires. + """ + sch = self._make_schematic() + self._add_resistor(sch, "R1", 100, 100) + self._add_resistor(sch, "R2", 150, 150) # far away + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + result = iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 110, "y": 100}, + }, + ) + assert result["success"], result.get("message") + assert result.get("wiresSynthesized", 0) == 0 + + def test_existing_wires_still_dragged_with_touching_pins(self) -> None: + """ + When R1 has both an explicit wire AND a touching-pin connection, + both should be handled: the wire dragged and the touching-pin bridged. + """ + sch = self._make_schematic() + # R1 at (100, 100), R2 at (100, 92.38) — pin2 of R1 touches pin1 of R2 + self._add_resistor(sch, "R1", 100, 100) + self._add_resistor(sch, "R2", 100, 92.38) + + # Also add an explicit wire at pin1 of R1 (100, 103.81) going up + import uuid as _uuid + + wire_sexp = f""" + (wire (pts (xy 100 103.81) (xy 100 115)) + (stroke (width 0) (type default)) + (uuid "{_uuid.uuid4()}") + )""" + content = sch.read_text(encoding="utf-8") + idx = content.rfind(")") + sch.write_text(content[:idx] + "\n" + wire_sexp + "\n)", encoding="utf-8") + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + result = iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 110, "y": 100}, + }, + ) + assert result["success"], result.get("message") + assert result.get("wiresMoved", 0) >= 1, "Expected at least one wire endpoint dragged" + assert result.get("wiresSynthesized", 0) >= 1, "Expected at least one touching-pin wire" diff --git a/python/tests/test_schematic_analysis.py b/python/tests/test_schematic_analysis.py index 45158e3..333058b 100644 --- a/python/tests/test_schematic_analysis.py +++ b/python/tests/test_schematic_analysis.py @@ -1,948 +1,948 @@ -""" -Tests for schematic analysis tools (Tools 2–5). - -Unit tests use mock data / synthetic S-expressions. -Integration tests parse real .kicad_sch files via sexpdata. -""" - -import os -import shutil -import sys -import tempfile -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -import sexpdata -from sexpdata import Symbol - -# Ensure the python/ package is importable -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from commands.schematic_analysis import ( - _aabb_overlap, - _check_wire_overlap, - _compute_symbol_bbox_direct, - _distance, - _extract_lib_symbols, - _line_segment_intersects_aabb, - _load_sexp, - _parse_labels, - _parse_lib_symbol_graphics, - _parse_symbols, - _parse_wires, - _point_in_rect, - _transform_local_point, - compute_symbol_bbox, - find_overlapping_elements, - find_wires_crossing_symbols, - get_elements_in_region, -) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "empty.kicad_sch" - - -def _make_temp_schematic(extra_sexp: str = "") -> Path: - """Copy empty.kicad_sch to a temp file and optionally append S-expression content.""" - tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" - shutil.copy(TEMPLATE_PATH, tmp) - if extra_sexp: - content = tmp.read_text(encoding="utf-8") - # Insert before the final closing paren - idx = content.rfind(")") - content = content[:idx] + "\n" + extra_sexp + "\n)" - tmp.write_text(content, encoding="utf-8") - return tmp - - -import uuid as _uuid - - -def _make_resistor_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str: - """Generate a proper Device:R symbol S-expression that skip can parse.""" - u = str(_uuid.uuid4()) - return f""" - (symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1) - (in_bom yes) (on_board yes) (dnp no) - (uuid "{u}") - (property "Reference" "{ref}" (at {x + 2.032} {y} 90) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "10k" (at {x} {y} 90) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at {x - 1.778} {y} 90) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at {x} {y} 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid "{_uuid.uuid4()}")) - (pin "2" (uuid "{_uuid.uuid4()}")) - (instances - (project "test" - (path "/" (reference "{ref}") (unit 1)) - ) - ) - ) -""" - - -def _make_led_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str: - """Generate a proper Device:LED symbol S-expression (horizontal pin spread).""" - u = str(_uuid.uuid4()) - return f""" - (symbol (lib_id "Device:LED") (at {x} {y} {rotation}) (unit 1) - (in_bom yes) (on_board yes) (dnp no) - (uuid "{u}") - (property "Reference" "{ref}" (at {x} {y - 2.54} 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "LED" (at {x} {y + 2.54} 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at {x} {y} 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at {x} {y} 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid "{_uuid.uuid4()}")) - (pin "2" (uuid "{_uuid.uuid4()}")) - (instances - (project "test" - (path "/" (reference "{ref}") (unit 1)) - ) - ) - ) -""" - - -# =================================================================== -# Unit tests — geometry helpers -# =================================================================== - - -class TestGeometryHelpers: - """Test low-level geometry utilities.""" - - def test_point_in_rect_inside(self): - assert _point_in_rect(5, 5, 0, 0, 10, 10) is True - - def test_point_in_rect_outside(self): - assert _point_in_rect(15, 5, 0, 0, 10, 10) is False - - def test_point_in_rect_boundary(self): - assert _point_in_rect(0, 0, 0, 0, 10, 10) is True - - def test_distance_zero(self): - assert _distance((0, 0), (0, 0)) == 0 - - def test_distance_unit(self): - assert abs(_distance((0, 0), (3, 4)) - 5.0) < 1e-9 - - def test_aabb_intersection_crossing(self): - # Line from (0,5) to (10,5) should intersect box (2,2)-(8,8) - assert _line_segment_intersects_aabb(0, 5, 10, 5, 2, 2, 8, 8) is True - - def test_aabb_intersection_miss(self): - # Line from (0,0) to (10,0) should miss box (2,2)-(8,8) - assert _line_segment_intersects_aabb(0, 0, 10, 0, 2, 2, 8, 8) is False - - def test_aabb_intersection_inside(self): - # Line entirely inside the box - assert _line_segment_intersects_aabb(3, 3, 7, 7, 2, 2, 8, 8) is True - - def test_aabb_intersection_diagonal(self): - # Diagonal line crossing through box - assert _line_segment_intersects_aabb(0, 0, 10, 10, 2, 2, 8, 8) is True - - def test_aabb_intersection_parallel_outside(self): - # Horizontal line above the box - assert _line_segment_intersects_aabb(0, 9, 10, 9, 2, 2, 8, 8) is False - - def test_aabb_intersection_touching_edge(self): - # Line ending exactly at box edge - assert _line_segment_intersects_aabb(0, 2, 2, 2, 2, 2, 8, 8) is True - - -# =================================================================== -# Unit tests — S-expression parsers -# =================================================================== - - -class TestSexpParsers: - """Test S-expression parsing functions with synthetic data.""" - - def test_parse_wires_basic(self): - sexp = sexpdata.loads("""(kicad_sch - (wire (pts (xy 10 20) (xy 30 40)) - (stroke (width 0) (type default)) - (uuid "abc")) - )""") - wires = _parse_wires(sexp) - assert len(wires) == 1 - assert wires[0]["start"] == (10.0, 20.0) - assert wires[0]["end"] == (30.0, 40.0) - - def test_parse_wires_empty(self): - sexp = sexpdata.loads("(kicad_sch)") - assert _parse_wires(sexp) == [] - - def test_parse_labels_both_types(self): - sexp = sexpdata.loads("""(kicad_sch - (label "VCC" (at 10 20 0)) - (global_label "GND" (at 30 40 0)) - )""") - labels = _parse_labels(sexp) - assert len(labels) == 2 - assert labels[0]["name"] == "VCC" - assert labels[0]["type"] == "label" - assert labels[1]["name"] == "GND" - assert labels[1]["type"] == "global_label" - - def test_parse_symbols(self): - sexp = sexpdata.loads("""(kicad_sch - (symbol (lib_id "Device:R") (at 100 100 0) - (property "Reference" "R1" (at 0 0 0))) - (symbol (lib_id "power:VCC") (at 50 50 0) - (property "Reference" "#PWR01" (at 0 0 0))) - )""") - symbols = _parse_symbols(sexp) - assert len(symbols) == 2 - assert symbols[0]["reference"] == "R1" - assert symbols[0]["is_power"] is False - assert symbols[1]["reference"] == "#PWR01" - assert symbols[1]["is_power"] is True - - -# =================================================================== -# Unit tests — analysis functions with mocked PinLocator -# =================================================================== - - -class TestAABBOverlap: - """Test AABB overlap helper.""" - - def test_overlapping_boxes(self): - assert _aabb_overlap((0, 0, 10, 10), (5, 5, 15, 15)) is True - - def test_non_overlapping_boxes(self): - assert _aabb_overlap((0, 0, 10, 10), (20, 20, 30, 30)) is False - - def test_touching_boxes_no_overlap(self): - # Touching edges are not overlapping (strict inequality) - assert _aabb_overlap((0, 0, 10, 10), (10, 0, 20, 10)) is False - - def test_contained_box(self): - assert _aabb_overlap((0, 0, 20, 20), (5, 5, 15, 15)) is True - - def test_overlap_one_axis_only(self): - # Overlap in X but not Y - assert _aabb_overlap((0, 0, 10, 10), (5, 15, 15, 25)) is False - - -class TestFindOverlappingElements: - """Test overlapping detection logic.""" - - def test_no_overlaps_in_empty_schematic(self): - tmp = _make_temp_schematic() - result = find_overlapping_elements(tmp, tolerance=0.5) - assert result["totalOverlaps"] == 0 - - def test_overlapping_symbols_detected(self): - # Two resistors at nearly the same position — bboxes fully overlap - extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100.1, 100) - tmp = _make_temp_schematic(extra) - result = find_overlapping_elements(tmp, tolerance=0.5) - assert result["totalOverlaps"] >= 1 - assert len(result["overlappingSymbols"]) >= 1 - - def test_well_separated_symbols_not_flagged(self): - extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 200, 200) - tmp = _make_temp_schematic(extra) - result = find_overlapping_elements(tmp, tolerance=0.5) - assert result["totalOverlaps"] == 0 - - def test_collinear_wire_overlap(self): - extra = """ - (wire (pts (xy 10 50) (xy 30 50)) - (stroke (width 0) (type default)) - (uuid "w1")) - (wire (pts (xy 20 50) (xy 40 50)) - (stroke (width 0) (type default)) - (uuid "w2")) - """ - tmp = _make_temp_schematic(extra) - result = find_overlapping_elements(tmp, tolerance=0.5) - assert len(result["overlappingWires"]) >= 1 - - def test_overlapping_bodies_different_centers(self): - """Two resistors whose bodies overlap even though centers are ~5mm apart. - - Device:R pins are at y ±3.81 relative to center, so the body spans - ~7.62mm vertically. Two resistors at the same X but 5mm apart in Y - have overlapping bodies — this is the bug the center-distance approach missed. - """ - # R1 at y=100, R2 at y=105 — pin spans [96.19, 103.81] and [101.19, 108.81] - # These overlap in Y from 101.19 to 103.81 - extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100, 105) - tmp = _make_temp_schematic(extra) - result = find_overlapping_elements(tmp, tolerance=0.5) - assert result["totalOverlaps"] >= 1, ( - "Should detect overlap when component bodies intersect, " - "even if centers are far apart" - ) - assert len(result["overlappingSymbols"]) >= 1 - - def test_adjacent_resistors_no_overlap(self): - """Two vertical resistors side by side should not overlap. - - R pins at y ±3.81, but different X positions far enough apart. - """ - extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 110, 100) - tmp = _make_temp_schematic(extra) - result = find_overlapping_elements(tmp, tolerance=0.5) - assert result["totalOverlaps"] == 0 - - def test_resistor_and_led_overlapping_bodies(self): - """A resistor and an LED placed close enough that bodies overlap. - - LED pins at x ±3.81, R pins at y ±3.81. Place LED at same position - as R — bodies clearly overlap. - """ - extra = _make_resistor_sexp("R1", 100, 100) + _make_led_sexp("D1", 100, 100) - tmp = _make_temp_schematic(extra) - result = find_overlapping_elements(tmp, tolerance=0.5) - assert result["totalOverlaps"] >= 1 - - -class TestGetElementsInRegion: - """Test region query logic.""" - - def test_elements_inside_region_found(self): - extra = """ - (symbol (lib_id "Device:R") (at 50 50 0) - (property "Reference" "R1" (at 0 0 0)) - (property "Value" "10k" (at 0 0 0))) - (wire (pts (xy 45 50) (xy 55 50)) - (stroke (width 0) (type default)) - (uuid "w1")) - (label "NET1" (at 50 50 0)) - """ - tmp = _make_temp_schematic(extra) - result = get_elements_in_region(tmp, 40, 40, 60, 60) - assert result["counts"]["symbols"] >= 1 - assert result["counts"]["wires"] >= 1 - assert result["counts"]["labels"] >= 1 - - def test_elements_outside_region_excluded(self): - extra = """ - (symbol (lib_id "Device:R") (at 200 200 0) - (property "Reference" "R1" (at 0 0 0)) - (property "Value" "10k" (at 0 0 0))) - """ - tmp = _make_temp_schematic(extra) - result = get_elements_in_region(tmp, 0, 0, 50, 50) - assert result["counts"]["symbols"] == 0 - - -class TestComputeSymbolBbox: - """Test bounding box computation.""" - - def test_returns_none_for_unknown_symbol(self): - tmp = _make_temp_schematic() - from commands.pin_locator import PinLocator - - locator = PinLocator() - result = compute_symbol_bbox(tmp, "NONEXISTENT", locator) - assert result is None - - -# =================================================================== -# Integration tests — full schematic parsing -# =================================================================== - - -@pytest.mark.integration -class TestIntegrationFindWiresCrossingSymbols: - """Integration test for wire crossing symbol detection.""" - - def test_wire_not_touching_pins_is_collision(self): - """A wire passing through a component bbox without pin contact → collision.""" - # LED D1 at (100,100) → pin 1 at (96.19, 100), pin 2 at (103.81, 100) - # Vertical wire from (100, 95) to (100, 105) crosses through the body - # without touching either horizontal pin - extra = _make_led_sexp("D1", 100, 100) + """ - (wire (pts (xy 100 95) (xy 100 105)) - (stroke (width 0) (type default)) - (uuid "w1")) - """ - tmp = _make_temp_schematic(extra) - result = find_wires_crossing_symbols(tmp) - d1_collisions = [c for c in result if c["component"]["reference"] == "D1"] - assert len(d1_collisions) >= 1 - - def test_unannotated_duplicates_not_over_reported(self): - """ - Regression: two components with the same unannotated reference ("R?") at - different positions should each produce independent bounding boxes. - A wire crossing only one of them must produce exactly 1 collision, not 2. - - Before the fix, PinLocator.get_all_symbol_pins always resolved "R?" to - the first match, so both symbols got identical bboxes and the same wire - was counted against both. - """ - # R? at (100, 100): Device:R pins are at (100, 96.19) and (100, 103.81). - # Effective bbox (after expansion + margin) ≈ x=[99,101], y=[96.69,103.31]. - # R? at (200, 100): identical type but far away → no intersection with wire. - r_at_100 = _make_resistor_sexp("R?", 100, 100) - r_at_200 = _make_resistor_sexp("R?", 200, 100) - # Horizontal wire crossing the body of the first R? only - wire = """ - (wire (pts (xy 95 100) (xy 105 100)) - (stroke (width 0) (type default)) - (uuid "w-collision")) - """ - tmp = _make_temp_schematic(r_at_100 + r_at_200 + wire) - result = find_wires_crossing_symbols(tmp) - # The wire must not be reported against the far-away R? at (200, 100) - collisions_at_200 = [c for c in result if abs(c["component"]["position"]["x"] - 200) < 0.5] - assert len(collisions_at_200) == 0, ( - "Wire at x≈100 must not be flagged against the R? at x=200; " - "likely caused by reference-lookup always returning the first 'R?'" - ) - - def test_wire_starting_at_pin_passing_through_body(self): - """A wire that starts at a pin but continues through the component body - must be flagged — this is the core bug where the old suppression logic - treated any wire touching a pin as a valid connection.""" - # LED D1 at (100,100) → pin 1 at (96.19, 100), pin 2 at (103.81, 100) - # Wire starts exactly at pin 1 and extends through the body to the right - extra = _make_led_sexp("D1", 100, 100) + """ - (wire (pts (xy 96.19 100) (xy 110 100)) - (stroke (width 0) (type default)) - (uuid "w-through")) - """ - tmp = _make_temp_schematic(extra) - result = find_wires_crossing_symbols(tmp) - d1_crossings = [c for c in result if c["component"]["reference"] == "D1"] - assert ( - len(d1_crossings) >= 1 - ), "Wire starting at pin but passing through body must be detected" - - def test_wire_terminating_at_pin_from_outside(self): - """A wire that arrives at a pin from outside the component body - is a valid connection and must NOT be flagged.""" - # LED D1 at (100,100) → pin 1 at (96.19, 100) - # Wire comes from the left and terminates at pin 1 - extra = _make_led_sexp("D1", 100, 100) + """ - (wire (pts (xy 80 100) (xy 96.19 100)) - (stroke (width 0) (type default)) - (uuid "w-valid")) - """ - tmp = _make_temp_schematic(extra) - result = find_wires_crossing_symbols(tmp) - d1_crossings = [c for c in result if c["component"]["reference"] == "D1"] - assert len(d1_crossings) == 0, "Wire terminating at pin from outside should not be flagged" - - def test_wire_shorts_component_pins_detected_as_collision(self): - """Regression: a wire connecting pin1→pin2 of the same component - must be reported even though both endpoints land on pins.""" - r_sexp = _make_resistor_sexp("R_short", 100.0, 100.0) - wire_sexp = ( - "(wire (pts (xy 100 103.81) (xy 100 96.19))\n" - " (stroke (width 0) (type default))\n" - ' (uuid "aaaaaaaa-0000-0000-0000-000000000001"))' - ) - sch = _make_temp_schematic(r_sexp + "\n" + wire_sexp) - collisions = find_wires_crossing_symbols(sch) - assert len(collisions) == 1 - w = collisions[0]["wire"] - assert w["start"]["x"] == pytest.approx(100.0) - assert w["start"]["y"] == pytest.approx(103.81) - assert collisions[0]["component"]["reference"] == "R_short" - - -@pytest.mark.integration -class TestIntegrationGetElementsInRegion: - """Integration test for region query.""" - - def test_region_returns_pin_data(self): - """Symbols in region should include pin position data.""" - extra = _make_resistor_sexp("R1", 100, 100) - tmp = _make_temp_schematic(extra) - result = get_elements_in_region(tmp, 90, 90, 110, 110) - assert result["counts"]["symbols"] == 1 - sym = result["symbols"][0] - assert "pins" in sym - assert len(sym["pins"]) == 2 # Resistor has 2 pins - - def test_wire_passing_through_region_included(self): - """A wire that passes through a region (no endpoints inside) should be included.""" - extra = """ - (wire (pts (xy 0 50) (xy 100 50)) - (stroke (width 0) (type default)) - (uuid "w-through")) - """ - tmp = _make_temp_schematic(extra) - result = get_elements_in_region(tmp, 40, 40, 60, 60) - assert result["counts"]["wires"] == 1 - - def test_wire_outside_region_excluded(self): - """A wire entirely outside a region should not be included.""" - extra = """ - (wire (pts (xy 0 0) (xy 10 0)) - (stroke (width 0) (type default)) - (uuid "w-outside")) - """ - tmp = _make_temp_schematic(extra) - result = get_elements_in_region(tmp, 40, 40, 60, 60) - assert result["counts"]["wires"] == 0 - - -# =================================================================== -# Unit tests — _check_wire_overlap -# =================================================================== - - -class TestCheckWireOverlap: - """Test wire overlap detection for horizontal, vertical, and diagonal cases.""" - - def test_horizontal_overlap(self): - w1 = {"start": (10, 50), "end": (30, 50)} - w2 = {"start": (20, 50), "end": (40, 50)} - result = _check_wire_overlap(w1, w2, 0.5) - assert result is not None - assert result["type"] == "collinear_overlap" - - def test_vertical_overlap(self): - w1 = {"start": (50, 10), "end": (50, 30)} - w2 = {"start": (50, 20), "end": (50, 40)} - result = _check_wire_overlap(w1, w2, 0.5) - assert result is not None - assert result["type"] == "collinear_overlap" - - def test_diagonal_overlap(self): - w1 = {"start": (0, 0), "end": (20, 20)} - w2 = {"start": (10, 10), "end": (30, 30)} - result = _check_wire_overlap(w1, w2, 0.5) - assert result is not None - assert result["type"] == "collinear_overlap" - - def test_horizontal_no_overlap(self): - w1 = {"start": (10, 50), "end": (20, 50)} - w2 = {"start": (30, 50), "end": (40, 50)} - result = _check_wire_overlap(w1, w2, 0.5) - assert result is None - - def test_parallel_offset_no_overlap(self): - """Two parallel wires offset perpendicularly should not overlap.""" - w1 = {"start": (0, 0), "end": (20, 20)} - w2 = {"start": (0, 5), "end": (20, 25)} - result = _check_wire_overlap(w1, w2, 0.5) - assert result is None - - def test_non_parallel_no_overlap(self): - """Two wires at different angles should not overlap.""" - w1 = {"start": (0, 0), "end": (10, 10)} - w2 = {"start": (0, 0), "end": (10, 0)} - result = _check_wire_overlap(w1, w2, 0.5) - assert result is None - - def test_zero_length_segment(self): - w1 = {"start": (10, 10), "end": (10, 10)} - w2 = {"start": (10, 10), "end": (20, 20)} - result = _check_wire_overlap(w1, w2, 0.5) - assert result is None - - -@pytest.mark.integration -class TestIntegrationDiagonalWireOverlap: - """Integration tests for diagonal collinear wire overlap detection.""" - - def test_diagonal_collinear_wire_overlap(self): - """Two 45-degree wires that overlap should be detected.""" - extra = """ - (wire (pts (xy 0 0) (xy 20 20)) - (stroke (width 0) (type default)) - (uuid "w-diag1")) - (wire (pts (xy 10 10) (xy 30 30)) - (stroke (width 0) (type default)) - (uuid "w-diag2")) - """ - tmp = _make_temp_schematic(extra) - result = find_overlapping_elements(tmp, tolerance=0.5) - assert len(result["overlappingWires"]) >= 1 - - def test_diagonal_parallel_no_overlap(self): - """Two parallel 45-degree wires that are offset should not overlap.""" - extra = """ - (wire (pts (xy 0 0) (xy 20 20)) - (stroke (width 0) (type default)) - (uuid "w-diag1")) - (wire (pts (xy 0 5) (xy 20 25)) - (stroke (width 0) (type default)) - (uuid "w-diag2")) - """ - tmp = _make_temp_schematic(extra) - result = find_overlapping_elements(tmp, tolerance=0.5) - assert len(result["overlappingWires"]) == 0 - - def test_diagonal_non_collinear_no_overlap(self): - """Two wires at different angles crossing should not be flagged as collinear overlap.""" - extra = """ - (wire (pts (xy 0 0) (xy 20 20)) - (stroke (width 0) (type default)) - (uuid "w-diag1")) - (wire (pts (xy 0 20) (xy 20 0)) - (stroke (width 0) (type default)) - (uuid "w-diag2")) - """ - tmp = _make_temp_schematic(extra) - result = find_overlapping_elements(tmp, tolerance=0.5) - assert len(result["overlappingWires"]) == 0 - - -# =================================================================== -# Unit tests — _extract_lib_symbols -# =================================================================== - - -class TestExtractLibSymbols: - """Test _extract_lib_symbols helper.""" - - def test_extracts_pins_from_lib_symbols(self): - sexp = sexpdata.loads("""(kicad_sch - (lib_symbols - (symbol "Device:R" - (symbol "Device:R_0_1" - (pin passive (at 0 3.81 270) (length 1.27) - (name "~" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27))))) - (pin passive (at 0 -3.81 90) (length 1.27) - (name "~" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27))))))) - ) - )""") - result = _extract_lib_symbols(sexp) - assert "Device:R" in result - pins = result["Device:R"]["pins"] - assert "1" in pins - assert "2" in pins - assert pins["1"]["y"] == pytest.approx(3.81) - - def test_empty_schematic_returns_empty(self): - sexp = sexpdata.loads("(kicad_sch)") - result = _extract_lib_symbols(sexp) - assert result == {} - - def test_no_lib_symbols_section(self): - sexp = sexpdata.loads("""(kicad_sch - (wire (pts (xy 0 0) (xy 10 10))) - )""") - result = _extract_lib_symbols(sexp) - assert result == {} - - def test_extract_includes_graphics_points(self): - """_extract_lib_symbols should return graphics_points from body shapes.""" - sexp = sexpdata.loads("""(kicad_sch - (lib_symbols - (symbol "Device:R" - (symbol "Device:R_0_1" - (rectangle (start -1.016 -2.54) (end 1.016 2.54) - (stroke (width 0.254) (type default)) - (fill (type none)))) - (symbol "Device:R_1_1" - (pin passive line (at 0 3.81 270) (length 1.27) - (name "~" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27))))) - (pin passive line (at 0 -3.81 90) (length 1.27) - (name "~" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27))))))) - ) - )""") - result = _extract_lib_symbols(sexp) - lib_data = result["Device:R"] - assert "graphics_points" in lib_data - gfx = lib_data["graphics_points"] - assert len(gfx) >= 2 - # Rectangle corners should be present - xs = [p[0] for p in gfx] - ys = [p[1] for p in gfx] - assert pytest.approx(-1.016) in xs - assert pytest.approx(1.016) in xs - assert pytest.approx(-2.54) in ys - assert pytest.approx(2.54) in ys - - -# =================================================================== -# Unit tests — _parse_lib_symbol_graphics -# =================================================================== - - -class TestParseLibSymbolGraphics: - """Test graphics extraction from lib_symbol definitions.""" - - def test_rectangle(self): - sexp = sexpdata.loads("""(symbol "Device:R" - (symbol "Device:R_0_1" - (rectangle (start -1.016 -2.54) (end 1.016 2.54) - (stroke (width 0.254) (type default)) - (fill (type none)))))""") - pts = _parse_lib_symbol_graphics(sexp) - assert len(pts) == 2 - assert (-1.016, -2.54) in pts - assert (1.016, 2.54) in pts - - def test_polyline(self): - sexp = sexpdata.loads("""(symbol "Device:C" - (symbol "Device:C_0_1" - (polyline - (pts (xy -2.032 -0.762) (xy 2.032 -0.762)) - (stroke (width 0.508) (type default)) - (fill (type none)))))""") - pts = _parse_lib_symbol_graphics(sexp) - assert (-2.032, -0.762) in pts - assert (2.032, -0.762) in pts - - def test_circle(self): - sexp = sexpdata.loads("""(symbol "Test:Circle" - (symbol "Test:Circle_0_1" - (circle (center 0 0) (radius 5) - (stroke (width 0.254) (type default)) - (fill (type none)))))""") - pts = _parse_lib_symbol_graphics(sexp) - assert len(pts) == 2 - assert (-5.0, -5.0) in pts - assert (5.0, 5.0) in pts - - def test_arc(self): - sexp = sexpdata.loads("""(symbol "Test:Arc" - (symbol "Test:Arc_0_1" - (arc (start 1 0) (mid 0 1) (end -1 0) - (stroke (width 0.254) (type default)) - (fill (type none)))))""") - pts = _parse_lib_symbol_graphics(sexp) - assert (1.0, 0.0) in pts - assert (0.0, 1.0) in pts - assert (-1.0, 0.0) in pts - - def test_no_graphics(self): - sexp = sexpdata.loads("""(symbol "Test:Empty" - (symbol "Test:Empty_1_1" - (pin passive line (at 0 0 0) (length 1.27) - (name "~" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))))))""") - pts = _parse_lib_symbol_graphics(sexp) - assert pts == [] - - -# =================================================================== -# Unit tests — _transform_local_point -# =================================================================== - - -class TestTransformLocalPoint: - """Test local→absolute coordinate transform.""" - - def test_no_transform(self): - # ly is negated (lib y-up → schematic y-down) - x, y = _transform_local_point(1.0, 2.0, 100.0, 200.0, 0, False, False) - assert x == pytest.approx(101.0) - assert y == pytest.approx(198.0) - - def test_mirror_x(self): - # y-negate then mirror_x cancel out → net ly unchanged - x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, True, False) - assert x == pytest.approx(1.0) - assert y == pytest.approx(2.0) - - def test_mirror_y(self): - x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, False, True) - assert x == pytest.approx(-1.0) - assert y == pytest.approx(-2.0) - - def test_rotation_90(self): - # ly=0 negated is still 0, then rotate lx=1 by 90° - x, y = _transform_local_point(1.0, 0.0, 0.0, 0.0, 90, False, False) - assert x == pytest.approx(0.0, abs=1e-9) - assert y == pytest.approx(1.0, abs=1e-9) - - -# =================================================================== -# Unit tests — _compute_symbol_bbox_direct with graphics -# =================================================================== - - -class TestComputeSymbolBboxWithGraphics: - """Test that bounding box computation uses graphics points when available.""" - - def test_resistor_bbox_from_graphics(self): - """Device:R rectangle is (-1.016, -2.54) to (1.016, 2.54) in local coords. - Pins at (0, ±3.81). Placed at (100, 100) with no rotation. - Bbox should span from pin-to-pin in Y and use rectangle width in X.""" - sym = { - "x": 100.0, - "y": 100.0, - "rotation": 0, - "mirror_x": False, - "mirror_y": False, - } - pin_defs = { - "1": { - "x": 0, - "y": 3.81, - "angle": 270, - "length": 1.27, - "name": "~", - "type": "passive", - }, - "2": { - "x": 0, - "y": -3.81, - "angle": 90, - "length": 1.27, - "name": "~", - "type": "passive", - }, - } - graphics_points = [(-1.016, -2.54), (1.016, 2.54)] - - bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) - assert bbox is not None - min_x, min_y, max_x, max_y = bbox - # X should come from rectangle: 100 ± 1.016 - assert min_x == pytest.approx(100 - 1.016) - assert max_x == pytest.approx(100 + 1.016) - # Y should come from pins (extending beyond rectangle): 100 ± 3.81 - assert min_y == pytest.approx(100 - 3.81) - assert max_y == pytest.approx(100 + 3.81) - - def test_fallback_without_graphics(self): - """Without graphics_points, should use the old degenerate expansion.""" - sym = { - "x": 100.0, - "y": 100.0, - "rotation": 0, - "mirror_x": False, - "mirror_y": False, - } - pin_defs = { - "1": { - "x": 0, - "y": 3.81, - "angle": 270, - "length": 1.27, - "name": "~", - "type": "passive", - }, - "2": { - "x": 0, - "y": -3.81, - "angle": 90, - "length": 1.27, - "name": "~", - "type": "passive", - }, - } - - bbox = _compute_symbol_bbox_direct(sym, pin_defs) - assert bbox is not None - min_x, min_y, max_x, max_y = bbox - # X should be expanded with min_body=1.5: 100 ± 1.5 - assert min_x == pytest.approx(100 - 1.5) - assert max_x == pytest.approx(100 + 1.5) - - def test_rotated_symbol_graphics(self): - """Graphics points should be rotated along with the symbol.""" - sym = { - "x": 100.0, - "y": 100.0, - "rotation": 90, - "mirror_x": False, - "mirror_y": False, - } - pin_defs = { - "1": { - "x": 0, - "y": 3.81, - "angle": 270, - "length": 1.27, - "name": "~", - "type": "passive", - }, - "2": { - "x": 0, - "y": -3.81, - "angle": 90, - "length": 1.27, - "name": "~", - "type": "passive", - }, - } - # Rectangle corners in local coords - graphics_points = [(-1.016, -2.54), (1.016, 2.54)] - - bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) - assert bbox is not None - min_x, min_y, max_x, max_y = bbox - # After 90° rotation, X and Y swap roles - # Pins now extend along X: 100 ± 3.81 - # Rectangle now extends along Y: 100 ± 1.016 - assert min_x == pytest.approx(100 - 3.81, abs=0.01) - assert max_x == pytest.approx(100 + 3.81, abs=0.01) - - -@pytest.mark.integration -class TestIntegrationGraphicsBbox: - """Integration tests verifying graphics-based bbox from real template data.""" - - def test_resistor_bbox_uses_rectangle(self): - """The template's Device:R has a rectangle body. - Verify that the bbox for a placed resistor uses the actual - rectangle width rather than the degenerate 1.5mm expansion.""" - extra = _make_resistor_sexp("R1", 100, 100) - tmp = _make_temp_schematic(extra) - sexp_data = _load_sexp(tmp) - symbols = _parse_symbols(sexp_data) - lib_defs = _extract_lib_symbols(sexp_data) - - r1 = [s for s in symbols if s["reference"] == "R1"][0] - lib_data = lib_defs.get(r1["lib_id"], {}) - pin_defs = lib_data.get("pins", {}) - graphics_points = lib_data.get("graphics_points", []) - - assert len(graphics_points) >= 2, "Should have extracted rectangle points" - - bbox = _compute_symbol_bbox_direct(r1, pin_defs, graphics_points=graphics_points) - assert bbox is not None - min_x, min_y, max_x, max_y = bbox - # Rectangle is ±1.016 in X, NOT ±1.5 from degenerate expansion - assert max_x - min_x == pytest.approx(2 * 1.016, abs=0.01) - - def test_led_bbox_uses_polyline(self): - """The template's Device:LED uses polylines for its body. - Verify that the bbox uses polyline extents.""" - extra = _make_led_sexp("D1", 100, 100) - tmp = _make_temp_schematic(extra) - sexp_data = _load_sexp(tmp) - symbols = _parse_symbols(sexp_data) - lib_defs = _extract_lib_symbols(sexp_data) - - d1 = [s for s in symbols if s["reference"] == "D1"][0] - lib_data = lib_defs.get(d1["lib_id"], {}) - graphics_points = lib_data.get("graphics_points", []) - - assert len(graphics_points) >= 4, "Should have extracted polyline points" - # LED body polylines span from -1.27 to 1.27 in both X and Y - xs = [p[0] for p in graphics_points] - ys = [p[1] for p in graphics_points] - assert min(xs) == pytest.approx(-1.27) - assert max(xs) == pytest.approx(1.27) - assert min(ys) == pytest.approx(-1.27) - assert max(ys) == pytest.approx(1.27) +""" +Tests for schematic analysis tools (Tools 2–5). + +Unit tests use mock data / synthetic S-expressions. +Integration tests parse real .kicad_sch files via sexpdata. +""" + +import os +import shutil +import sys +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import sexpdata +from sexpdata import Symbol + +# Ensure the python/ package is importable +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from commands.schematic_analysis import ( + _aabb_overlap, + _check_wire_overlap, + _compute_symbol_bbox_direct, + _distance, + _extract_lib_symbols, + _line_segment_intersects_aabb, + _load_sexp, + _parse_labels, + _parse_lib_symbol_graphics, + _parse_symbols, + _parse_wires, + _point_in_rect, + _transform_local_point, + compute_symbol_bbox, + find_overlapping_elements, + find_wires_crossing_symbols, + get_elements_in_region, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "empty.kicad_sch" + + +def _make_temp_schematic(extra_sexp: str = "") -> Path: + """Copy empty.kicad_sch to a temp file and optionally append S-expression content.""" + tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" + shutil.copy(TEMPLATE_PATH, tmp) + if extra_sexp: + content = tmp.read_text(encoding="utf-8") + # Insert before the final closing paren + idx = content.rfind(")") + content = content[:idx] + "\n" + extra_sexp + "\n)" + tmp.write_text(content, encoding="utf-8") + return tmp + + +import uuid as _uuid + + +def _make_resistor_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str: + """Generate a proper Device:R symbol S-expression that skip can parse.""" + u = str(_uuid.uuid4()) + return f""" + (symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1) + (in_bom yes) (on_board yes) (dnp no) + (uuid "{u}") + (property "Reference" "{ref}" (at {x + 2.032} {y} 90) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "10k" (at {x} {y} 90) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at {x - 1.778} {y} 90) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at {x} {y} 0) + (effects (font (size 1.27 1.27)) hide) + ) + (pin "1" (uuid "{_uuid.uuid4()}")) + (pin "2" (uuid "{_uuid.uuid4()}")) + (instances + (project "test" + (path "/" (reference "{ref}") (unit 1)) + ) + ) + ) +""" + + +def _make_led_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str: + """Generate a proper Device:LED symbol S-expression (horizontal pin spread).""" + u = str(_uuid.uuid4()) + return f""" + (symbol (lib_id "Device:LED") (at {x} {y} {rotation}) (unit 1) + (in_bom yes) (on_board yes) (dnp no) + (uuid "{u}") + (property "Reference" "{ref}" (at {x} {y - 2.54} 0) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "LED" (at {x} {y + 2.54} 0) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at {x} {y} 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at {x} {y} 0) + (effects (font (size 1.27 1.27)) hide) + ) + (pin "1" (uuid "{_uuid.uuid4()}")) + (pin "2" (uuid "{_uuid.uuid4()}")) + (instances + (project "test" + (path "/" (reference "{ref}") (unit 1)) + ) + ) + ) +""" + + +# =================================================================== +# Unit tests — geometry helpers +# =================================================================== + + +class TestGeometryHelpers: + """Test low-level geometry utilities.""" + + def test_point_in_rect_inside(self) -> None: + assert _point_in_rect(5, 5, 0, 0, 10, 10) is True + + def test_point_in_rect_outside(self) -> None: + assert _point_in_rect(15, 5, 0, 0, 10, 10) is False + + def test_point_in_rect_boundary(self) -> None: + assert _point_in_rect(0, 0, 0, 0, 10, 10) is True + + def test_distance_zero(self) -> None: + assert _distance((0, 0), (0, 0)) == 0 + + def test_distance_unit(self) -> None: + assert abs(_distance((0, 0), (3, 4)) - 5.0) < 1e-9 + + def test_aabb_intersection_crossing(self) -> None: + # Line from (0,5) to (10,5) should intersect box (2,2)-(8,8) + assert _line_segment_intersects_aabb(0, 5, 10, 5, 2, 2, 8, 8) is True + + def test_aabb_intersection_miss(self) -> None: + # Line from (0,0) to (10,0) should miss box (2,2)-(8,8) + assert _line_segment_intersects_aabb(0, 0, 10, 0, 2, 2, 8, 8) is False + + def test_aabb_intersection_inside(self) -> None: + # Line entirely inside the box + assert _line_segment_intersects_aabb(3, 3, 7, 7, 2, 2, 8, 8) is True + + def test_aabb_intersection_diagonal(self) -> None: + # Diagonal line crossing through box + assert _line_segment_intersects_aabb(0, 0, 10, 10, 2, 2, 8, 8) is True + + def test_aabb_intersection_parallel_outside(self) -> None: + # Horizontal line above the box + assert _line_segment_intersects_aabb(0, 9, 10, 9, 2, 2, 8, 8) is False + + def test_aabb_intersection_touching_edge(self) -> None: + # Line ending exactly at box edge + assert _line_segment_intersects_aabb(0, 2, 2, 2, 2, 2, 8, 8) is True + + +# =================================================================== +# Unit tests — S-expression parsers +# =================================================================== + + +class TestSexpParsers: + """Test S-expression parsing functions with synthetic data.""" + + def test_parse_wires_basic(self) -> None: + sexp = sexpdata.loads("""(kicad_sch + (wire (pts (xy 10 20) (xy 30 40)) + (stroke (width 0) (type default)) + (uuid "abc")) + )""") + wires = _parse_wires(sexp) + assert len(wires) == 1 + assert wires[0]["start"] == (10.0, 20.0) + assert wires[0]["end"] == (30.0, 40.0) + + def test_parse_wires_empty(self) -> None: + sexp = sexpdata.loads("(kicad_sch)") + assert _parse_wires(sexp) == [] + + def test_parse_labels_both_types(self) -> None: + sexp = sexpdata.loads("""(kicad_sch + (label "VCC" (at 10 20 0)) + (global_label "GND" (at 30 40 0)) + )""") + labels = _parse_labels(sexp) + assert len(labels) == 2 + assert labels[0]["name"] == "VCC" + assert labels[0]["type"] == "label" + assert labels[1]["name"] == "GND" + assert labels[1]["type"] == "global_label" + + def test_parse_symbols(self) -> None: + sexp = sexpdata.loads("""(kicad_sch + (symbol (lib_id "Device:R") (at 100 100 0) + (property "Reference" "R1" (at 0 0 0))) + (symbol (lib_id "power:VCC") (at 50 50 0) + (property "Reference" "#PWR01" (at 0 0 0))) + )""") + symbols = _parse_symbols(sexp) + assert len(symbols) == 2 + assert symbols[0]["reference"] == "R1" + assert symbols[0]["is_power"] is False + assert symbols[1]["reference"] == "#PWR01" + assert symbols[1]["is_power"] is True + + +# =================================================================== +# Unit tests — analysis functions with mocked PinLocator +# =================================================================== + + +class TestAABBOverlap: + """Test AABB overlap helper.""" + + def test_overlapping_boxes(self) -> None: + assert _aabb_overlap((0, 0, 10, 10), (5, 5, 15, 15)) is True + + def test_non_overlapping_boxes(self) -> None: + assert _aabb_overlap((0, 0, 10, 10), (20, 20, 30, 30)) is False + + def test_touching_boxes_no_overlap(self) -> None: + # Touching edges are not overlapping (strict inequality) + assert _aabb_overlap((0, 0, 10, 10), (10, 0, 20, 10)) is False + + def test_contained_box(self) -> None: + assert _aabb_overlap((0, 0, 20, 20), (5, 5, 15, 15)) is True + + def test_overlap_one_axis_only(self) -> None: + # Overlap in X but not Y + assert _aabb_overlap((0, 0, 10, 10), (5, 15, 15, 25)) is False + + +class TestFindOverlappingElements: + """Test overlapping detection logic.""" + + def test_no_overlaps_in_empty_schematic(self) -> None: + tmp = _make_temp_schematic() + result = find_overlapping_elements(tmp, tolerance=0.5) + assert result["totalOverlaps"] == 0 + + def test_overlapping_symbols_detected(self) -> None: + # Two resistors at nearly the same position — bboxes fully overlap + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100.1, 100) + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert result["totalOverlaps"] >= 1 + assert len(result["overlappingSymbols"]) >= 1 + + def test_well_separated_symbols_not_flagged(self) -> None: + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 200, 200) + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert result["totalOverlaps"] == 0 + + def test_collinear_wire_overlap(self) -> None: + extra = """ + (wire (pts (xy 10 50) (xy 30 50)) + (stroke (width 0) (type default)) + (uuid "w1")) + (wire (pts (xy 20 50) (xy 40 50)) + (stroke (width 0) (type default)) + (uuid "w2")) + """ + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert len(result["overlappingWires"]) >= 1 + + def test_overlapping_bodies_different_centers(self) -> None: + """Two resistors whose bodies overlap even though centers are ~5mm apart. + + Device:R pins are at y ±3.81 relative to center, so the body spans + ~7.62mm vertically. Two resistors at the same X but 5mm apart in Y + have overlapping bodies — this is the bug the center-distance approach missed. + """ + # R1 at y=100, R2 at y=105 — pin spans [96.19, 103.81] and [101.19, 108.81] + # These overlap in Y from 101.19 to 103.81 + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100, 105) + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert result["totalOverlaps"] >= 1, ( + "Should detect overlap when component bodies intersect, " + "even if centers are far apart" + ) + assert len(result["overlappingSymbols"]) >= 1 + + def test_adjacent_resistors_no_overlap(self) -> None: + """Two vertical resistors side by side should not overlap. + + R pins at y ±3.81, but different X positions far enough apart. + """ + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 110, 100) + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert result["totalOverlaps"] == 0 + + def test_resistor_and_led_overlapping_bodies(self) -> None: + """A resistor and an LED placed close enough that bodies overlap. + + LED pins at x ±3.81, R pins at y ±3.81. Place LED at same position + as R — bodies clearly overlap. + """ + extra = _make_resistor_sexp("R1", 100, 100) + _make_led_sexp("D1", 100, 100) + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert result["totalOverlaps"] >= 1 + + +class TestGetElementsInRegion: + """Test region query logic.""" + + def test_elements_inside_region_found(self) -> None: + extra = """ + (symbol (lib_id "Device:R") (at 50 50 0) + (property "Reference" "R1" (at 0 0 0)) + (property "Value" "10k" (at 0 0 0))) + (wire (pts (xy 45 50) (xy 55 50)) + (stroke (width 0) (type default)) + (uuid "w1")) + (label "NET1" (at 50 50 0)) + """ + tmp = _make_temp_schematic(extra) + result = get_elements_in_region(tmp, 40, 40, 60, 60) + assert result["counts"]["symbols"] >= 1 + assert result["counts"]["wires"] >= 1 + assert result["counts"]["labels"] >= 1 + + def test_elements_outside_region_excluded(self) -> None: + extra = """ + (symbol (lib_id "Device:R") (at 200 200 0) + (property "Reference" "R1" (at 0 0 0)) + (property "Value" "10k" (at 0 0 0))) + """ + tmp = _make_temp_schematic(extra) + result = get_elements_in_region(tmp, 0, 0, 50, 50) + assert result["counts"]["symbols"] == 0 + + +class TestComputeSymbolBbox: + """Test bounding box computation.""" + + def test_returns_none_for_unknown_symbol(self) -> None: + tmp = _make_temp_schematic() + from commands.pin_locator import PinLocator + + locator = PinLocator() + result = compute_symbol_bbox(tmp, "NONEXISTENT", locator) + assert result is None + + +# =================================================================== +# Integration tests — full schematic parsing +# =================================================================== + + +@pytest.mark.integration +class TestIntegrationFindWiresCrossingSymbols: + """Integration test for wire crossing symbol detection.""" + + def test_wire_not_touching_pins_is_collision(self) -> None: + """A wire passing through a component bbox without pin contact → collision.""" + # LED D1 at (100,100) → pin 1 at (96.19, 100), pin 2 at (103.81, 100) + # Vertical wire from (100, 95) to (100, 105) crosses through the body + # without touching either horizontal pin + extra = _make_led_sexp("D1", 100, 100) + """ + (wire (pts (xy 100 95) (xy 100 105)) + (stroke (width 0) (type default)) + (uuid "w1")) + """ + tmp = _make_temp_schematic(extra) + result = find_wires_crossing_symbols(tmp) + d1_collisions = [c for c in result if c["component"]["reference"] == "D1"] + assert len(d1_collisions) >= 1 + + def test_unannotated_duplicates_not_over_reported(self) -> None: + """ + Regression: two components with the same unannotated reference ("R?") at + different positions should each produce independent bounding boxes. + A wire crossing only one of them must produce exactly 1 collision, not 2. + + Before the fix, PinLocator.get_all_symbol_pins always resolved "R?" to + the first match, so both symbols got identical bboxes and the same wire + was counted against both. + """ + # R? at (100, 100): Device:R pins are at (100, 96.19) and (100, 103.81). + # Effective bbox (after expansion + margin) ≈ x=[99,101], y=[96.69,103.31]. + # R? at (200, 100): identical type but far away → no intersection with wire. + r_at_100 = _make_resistor_sexp("R?", 100, 100) + r_at_200 = _make_resistor_sexp("R?", 200, 100) + # Horizontal wire crossing the body of the first R? only + wire = """ + (wire (pts (xy 95 100) (xy 105 100)) + (stroke (width 0) (type default)) + (uuid "w-collision")) + """ + tmp = _make_temp_schematic(r_at_100 + r_at_200 + wire) + result = find_wires_crossing_symbols(tmp) + # The wire must not be reported against the far-away R? at (200, 100) + collisions_at_200 = [c for c in result if abs(c["component"]["position"]["x"] - 200) < 0.5] + assert len(collisions_at_200) == 0, ( + "Wire at x≈100 must not be flagged against the R? at x=200; " + "likely caused by reference-lookup always returning the first 'R?'" + ) + + def test_wire_starting_at_pin_passing_through_body(self) -> None: + """A wire that starts at a pin but continues through the component body + must be flagged — this is the core bug where the old suppression logic + treated any wire touching a pin as a valid connection.""" + # LED D1 at (100,100) → pin 1 at (96.19, 100), pin 2 at (103.81, 100) + # Wire starts exactly at pin 1 and extends through the body to the right + extra = _make_led_sexp("D1", 100, 100) + """ + (wire (pts (xy 96.19 100) (xy 110 100)) + (stroke (width 0) (type default)) + (uuid "w-through")) + """ + tmp = _make_temp_schematic(extra) + result = find_wires_crossing_symbols(tmp) + d1_crossings = [c for c in result if c["component"]["reference"] == "D1"] + assert ( + len(d1_crossings) >= 1 + ), "Wire starting at pin but passing through body must be detected" + + def test_wire_terminating_at_pin_from_outside(self) -> None: + """A wire that arrives at a pin from outside the component body + is a valid connection and must NOT be flagged.""" + # LED D1 at (100,100) → pin 1 at (96.19, 100) + # Wire comes from the left and terminates at pin 1 + extra = _make_led_sexp("D1", 100, 100) + """ + (wire (pts (xy 80 100) (xy 96.19 100)) + (stroke (width 0) (type default)) + (uuid "w-valid")) + """ + tmp = _make_temp_schematic(extra) + result = find_wires_crossing_symbols(tmp) + d1_crossings = [c for c in result if c["component"]["reference"] == "D1"] + assert len(d1_crossings) == 0, "Wire terminating at pin from outside should not be flagged" + + def test_wire_shorts_component_pins_detected_as_collision(self) -> None: + """Regression: a wire connecting pin1→pin2 of the same component + must be reported even though both endpoints land on pins.""" + r_sexp = _make_resistor_sexp("R_short", 100.0, 100.0) + wire_sexp = ( + "(wire (pts (xy 100 103.81) (xy 100 96.19))\n" + " (stroke (width 0) (type default))\n" + ' (uuid "aaaaaaaa-0000-0000-0000-000000000001"))' + ) + sch = _make_temp_schematic(r_sexp + "\n" + wire_sexp) + collisions = find_wires_crossing_symbols(sch) + assert len(collisions) == 1 + w = collisions[0]["wire"] + assert w["start"]["x"] == pytest.approx(100.0) + assert w["start"]["y"] == pytest.approx(103.81) + assert collisions[0]["component"]["reference"] == "R_short" + + +@pytest.mark.integration +class TestIntegrationGetElementsInRegion: + """Integration test for region query.""" + + def test_region_returns_pin_data(self) -> None: + """Symbols in region should include pin position data.""" + extra = _make_resistor_sexp("R1", 100, 100) + tmp = _make_temp_schematic(extra) + result = get_elements_in_region(tmp, 90, 90, 110, 110) + assert result["counts"]["symbols"] == 1 + sym = result["symbols"][0] + assert "pins" in sym + assert len(sym["pins"]) == 2 # Resistor has 2 pins + + def test_wire_passing_through_region_included(self) -> None: + """A wire that passes through a region (no endpoints inside) should be included.""" + extra = """ + (wire (pts (xy 0 50) (xy 100 50)) + (stroke (width 0) (type default)) + (uuid "w-through")) + """ + tmp = _make_temp_schematic(extra) + result = get_elements_in_region(tmp, 40, 40, 60, 60) + assert result["counts"]["wires"] == 1 + + def test_wire_outside_region_excluded(self) -> None: + """A wire entirely outside a region should not be included.""" + extra = """ + (wire (pts (xy 0 0) (xy 10 0)) + (stroke (width 0) (type default)) + (uuid "w-outside")) + """ + tmp = _make_temp_schematic(extra) + result = get_elements_in_region(tmp, 40, 40, 60, 60) + assert result["counts"]["wires"] == 0 + + +# =================================================================== +# Unit tests — _check_wire_overlap +# =================================================================== + + +class TestCheckWireOverlap: + """Test wire overlap detection for horizontal, vertical, and diagonal cases.""" + + def test_horizontal_overlap(self) -> None: + w1 = {"start": (10, 50), "end": (30, 50)} + w2 = {"start": (20, 50), "end": (40, 50)} + result = _check_wire_overlap(w1, w2, 0.5) + assert result is not None + assert result["type"] == "collinear_overlap" + + def test_vertical_overlap(self) -> None: + w1 = {"start": (50, 10), "end": (50, 30)} + w2 = {"start": (50, 20), "end": (50, 40)} + result = _check_wire_overlap(w1, w2, 0.5) + assert result is not None + assert result["type"] == "collinear_overlap" + + def test_diagonal_overlap(self) -> None: + w1 = {"start": (0, 0), "end": (20, 20)} + w2 = {"start": (10, 10), "end": (30, 30)} + result = _check_wire_overlap(w1, w2, 0.5) + assert result is not None + assert result["type"] == "collinear_overlap" + + def test_horizontal_no_overlap(self) -> None: + w1 = {"start": (10, 50), "end": (20, 50)} + w2 = {"start": (30, 50), "end": (40, 50)} + result = _check_wire_overlap(w1, w2, 0.5) + assert result is None + + def test_parallel_offset_no_overlap(self) -> None: + """Two parallel wires offset perpendicularly should not overlap.""" + w1 = {"start": (0, 0), "end": (20, 20)} + w2 = {"start": (0, 5), "end": (20, 25)} + result = _check_wire_overlap(w1, w2, 0.5) + assert result is None + + def test_non_parallel_no_overlap(self) -> None: + """Two wires at different angles should not overlap.""" + w1 = {"start": (0, 0), "end": (10, 10)} + w2 = {"start": (0, 0), "end": (10, 0)} + result = _check_wire_overlap(w1, w2, 0.5) + assert result is None + + def test_zero_length_segment(self) -> None: + w1 = {"start": (10, 10), "end": (10, 10)} + w2 = {"start": (10, 10), "end": (20, 20)} + result = _check_wire_overlap(w1, w2, 0.5) + assert result is None + + +@pytest.mark.integration +class TestIntegrationDiagonalWireOverlap: + """Integration tests for diagonal collinear wire overlap detection.""" + + def test_diagonal_collinear_wire_overlap(self) -> None: + """Two 45-degree wires that overlap should be detected.""" + extra = """ + (wire (pts (xy 0 0) (xy 20 20)) + (stroke (width 0) (type default)) + (uuid "w-diag1")) + (wire (pts (xy 10 10) (xy 30 30)) + (stroke (width 0) (type default)) + (uuid "w-diag2")) + """ + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert len(result["overlappingWires"]) >= 1 + + def test_diagonal_parallel_no_overlap(self) -> None: + """Two parallel 45-degree wires that are offset should not overlap.""" + extra = """ + (wire (pts (xy 0 0) (xy 20 20)) + (stroke (width 0) (type default)) + (uuid "w-diag1")) + (wire (pts (xy 0 5) (xy 20 25)) + (stroke (width 0) (type default)) + (uuid "w-diag2")) + """ + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert len(result["overlappingWires"]) == 0 + + def test_diagonal_non_collinear_no_overlap(self) -> None: + """Two wires at different angles crossing should not be flagged as collinear overlap.""" + extra = """ + (wire (pts (xy 0 0) (xy 20 20)) + (stroke (width 0) (type default)) + (uuid "w-diag1")) + (wire (pts (xy 0 20) (xy 20 0)) + (stroke (width 0) (type default)) + (uuid "w-diag2")) + """ + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert len(result["overlappingWires"]) == 0 + + +# =================================================================== +# Unit tests — _extract_lib_symbols +# =================================================================== + + +class TestExtractLibSymbols: + """Test _extract_lib_symbols helper.""" + + def test_extracts_pins_from_lib_symbols(self) -> None: + sexp = sexpdata.loads("""(kicad_sch + (lib_symbols + (symbol "Device:R" + (symbol "Device:R_0_1" + (pin passive (at 0 3.81 270) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27))))) + (pin passive (at 0 -3.81 90) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27))))))) + ) + )""") + result = _extract_lib_symbols(sexp) + assert "Device:R" in result + pins = result["Device:R"]["pins"] + assert "1" in pins + assert "2" in pins + assert pins["1"]["y"] == pytest.approx(3.81) + + def test_empty_schematic_returns_empty(self) -> None: + sexp = sexpdata.loads("(kicad_sch)") + result = _extract_lib_symbols(sexp) + assert result == {} + + def test_no_lib_symbols_section(self) -> None: + sexp = sexpdata.loads("""(kicad_sch + (wire (pts (xy 0 0) (xy 10 10))) + )""") + result = _extract_lib_symbols(sexp) + assert result == {} + + def test_extract_includes_graphics_points(self) -> None: + """_extract_lib_symbols should return graphics_points from body shapes.""" + sexp = sexpdata.loads("""(kicad_sch + (lib_symbols + (symbol "Device:R" + (symbol "Device:R_0_1" + (rectangle (start -1.016 -2.54) (end 1.016 2.54) + (stroke (width 0.254) (type default)) + (fill (type none)))) + (symbol "Device:R_1_1" + (pin passive line (at 0 3.81 270) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27))))) + (pin passive line (at 0 -3.81 90) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27))))))) + ) + )""") + result = _extract_lib_symbols(sexp) + lib_data = result["Device:R"] + assert "graphics_points" in lib_data + gfx = lib_data["graphics_points"] + assert len(gfx) >= 2 + # Rectangle corners should be present + xs = [p[0] for p in gfx] + ys = [p[1] for p in gfx] + assert pytest.approx(-1.016) in xs + assert pytest.approx(1.016) in xs + assert pytest.approx(-2.54) in ys + assert pytest.approx(2.54) in ys + + +# =================================================================== +# Unit tests — _parse_lib_symbol_graphics +# =================================================================== + + +class TestParseLibSymbolGraphics: + """Test graphics extraction from lib_symbol definitions.""" + + def test_rectangle(self) -> None: + sexp = sexpdata.loads("""(symbol "Device:R" + (symbol "Device:R_0_1" + (rectangle (start -1.016 -2.54) (end 1.016 2.54) + (stroke (width 0.254) (type default)) + (fill (type none)))))""") + pts = _parse_lib_symbol_graphics(sexp) + assert len(pts) == 2 + assert (-1.016, -2.54) in pts + assert (1.016, 2.54) in pts + + def test_polyline(self) -> None: + sexp = sexpdata.loads("""(symbol "Device:C" + (symbol "Device:C_0_1" + (polyline + (pts (xy -2.032 -0.762) (xy 2.032 -0.762)) + (stroke (width 0.508) (type default)) + (fill (type none)))))""") + pts = _parse_lib_symbol_graphics(sexp) + assert (-2.032, -0.762) in pts + assert (2.032, -0.762) in pts + + def test_circle(self) -> None: + sexp = sexpdata.loads("""(symbol "Test:Circle" + (symbol "Test:Circle_0_1" + (circle (center 0 0) (radius 5) + (stroke (width 0.254) (type default)) + (fill (type none)))))""") + pts = _parse_lib_symbol_graphics(sexp) + assert len(pts) == 2 + assert (-5.0, -5.0) in pts + assert (5.0, 5.0) in pts + + def test_arc(self) -> None: + sexp = sexpdata.loads("""(symbol "Test:Arc" + (symbol "Test:Arc_0_1" + (arc (start 1 0) (mid 0 1) (end -1 0) + (stroke (width 0.254) (type default)) + (fill (type none)))))""") + pts = _parse_lib_symbol_graphics(sexp) + assert (1.0, 0.0) in pts + assert (0.0, 1.0) in pts + assert (-1.0, 0.0) in pts + + def test_no_graphics(self) -> None: + sexp = sexpdata.loads("""(symbol "Test:Empty" + (symbol "Test:Empty_1_1" + (pin passive line (at 0 0 0) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))))))""") + pts = _parse_lib_symbol_graphics(sexp) + assert pts == [] + + +# =================================================================== +# Unit tests — _transform_local_point +# =================================================================== + + +class TestTransformLocalPoint: + """Test local→absolute coordinate transform.""" + + def test_no_transform(self) -> None: + # ly is negated (lib y-up → schematic y-down) + x, y = _transform_local_point(1.0, 2.0, 100.0, 200.0, 0, False, False) + assert x == pytest.approx(101.0) + assert y == pytest.approx(198.0) + + def test_mirror_x(self) -> None: + # y-negate then mirror_x cancel out → net ly unchanged + x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, True, False) + assert x == pytest.approx(1.0) + assert y == pytest.approx(2.0) + + def test_mirror_y(self) -> None: + x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, False, True) + assert x == pytest.approx(-1.0) + assert y == pytest.approx(-2.0) + + def test_rotation_90(self) -> None: + # ly=0 negated is still 0, then rotate lx=1 by 90° + x, y = _transform_local_point(1.0, 0.0, 0.0, 0.0, 90, False, False) + assert x == pytest.approx(0.0, abs=1e-9) + assert y == pytest.approx(1.0, abs=1e-9) + + +# =================================================================== +# Unit tests — _compute_symbol_bbox_direct with graphics +# =================================================================== + + +class TestComputeSymbolBboxWithGraphics: + """Test that bounding box computation uses graphics points when available.""" + + def test_resistor_bbox_from_graphics(self) -> None: + """Device:R rectangle is (-1.016, -2.54) to (1.016, 2.54) in local coords. + Pins at (0, ±3.81). Placed at (100, 100) with no rotation. + Bbox should span from pin-to-pin in Y and use rectangle width in X.""" + sym = { + "x": 100.0, + "y": 100.0, + "rotation": 0, + "mirror_x": False, + "mirror_y": False, + } + pin_defs = { + "1": { + "x": 0, + "y": 3.81, + "angle": 270, + "length": 1.27, + "name": "~", + "type": "passive", + }, + "2": { + "x": 0, + "y": -3.81, + "angle": 90, + "length": 1.27, + "name": "~", + "type": "passive", + }, + } + graphics_points = [(-1.016, -2.54), (1.016, 2.54)] + + bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) + assert bbox is not None + min_x, min_y, max_x, max_y = bbox + # X should come from rectangle: 100 ± 1.016 + assert min_x == pytest.approx(100 - 1.016) + assert max_x == pytest.approx(100 + 1.016) + # Y should come from pins (extending beyond rectangle): 100 ± 3.81 + assert min_y == pytest.approx(100 - 3.81) + assert max_y == pytest.approx(100 + 3.81) + + def test_fallback_without_graphics(self) -> None: + """Without graphics_points, should use the old degenerate expansion.""" + sym = { + "x": 100.0, + "y": 100.0, + "rotation": 0, + "mirror_x": False, + "mirror_y": False, + } + pin_defs = { + "1": { + "x": 0, + "y": 3.81, + "angle": 270, + "length": 1.27, + "name": "~", + "type": "passive", + }, + "2": { + "x": 0, + "y": -3.81, + "angle": 90, + "length": 1.27, + "name": "~", + "type": "passive", + }, + } + + bbox = _compute_symbol_bbox_direct(sym, pin_defs) + assert bbox is not None + min_x, min_y, max_x, max_y = bbox + # X should be expanded with min_body=1.5: 100 ± 1.5 + assert min_x == pytest.approx(100 - 1.5) + assert max_x == pytest.approx(100 + 1.5) + + def test_rotated_symbol_graphics(self) -> None: + """Graphics points should be rotated along with the symbol.""" + sym = { + "x": 100.0, + "y": 100.0, + "rotation": 90, + "mirror_x": False, + "mirror_y": False, + } + pin_defs = { + "1": { + "x": 0, + "y": 3.81, + "angle": 270, + "length": 1.27, + "name": "~", + "type": "passive", + }, + "2": { + "x": 0, + "y": -3.81, + "angle": 90, + "length": 1.27, + "name": "~", + "type": "passive", + }, + } + # Rectangle corners in local coords + graphics_points = [(-1.016, -2.54), (1.016, 2.54)] + + bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) + assert bbox is not None + min_x, min_y, max_x, max_y = bbox + # After 90° rotation, X and Y swap roles + # Pins now extend along X: 100 ± 3.81 + # Rectangle now extends along Y: 100 ± 1.016 + assert min_x == pytest.approx(100 - 3.81, abs=0.01) + assert max_x == pytest.approx(100 + 3.81, abs=0.01) + + +@pytest.mark.integration +class TestIntegrationGraphicsBbox: + """Integration tests verifying graphics-based bbox from real template data.""" + + def test_resistor_bbox_uses_rectangle(self) -> None: + """The template's Device:R has a rectangle body. + Verify that the bbox for a placed resistor uses the actual + rectangle width rather than the degenerate 1.5mm expansion.""" + extra = _make_resistor_sexp("R1", 100, 100) + tmp = _make_temp_schematic(extra) + sexp_data = _load_sexp(tmp) + symbols = _parse_symbols(sexp_data) + lib_defs = _extract_lib_symbols(sexp_data) + + r1 = [s for s in symbols if s["reference"] == "R1"][0] + lib_data = lib_defs.get(r1["lib_id"], {}) + pin_defs = lib_data.get("pins", {}) + graphics_points = lib_data.get("graphics_points", []) + + assert len(graphics_points) >= 2, "Should have extracted rectangle points" + + bbox = _compute_symbol_bbox_direct(r1, pin_defs, graphics_points=graphics_points) + assert bbox is not None + min_x, min_y, max_x, max_y = bbox + # Rectangle is ±1.016 in X, NOT ±1.5 from degenerate expansion + assert max_x - min_x == pytest.approx(2 * 1.016, abs=0.01) + + def test_led_bbox_uses_polyline(self) -> None: + """The template's Device:LED uses polylines for its body. + Verify that the bbox uses polyline extents.""" + extra = _make_led_sexp("D1", 100, 100) + tmp = _make_temp_schematic(extra) + sexp_data = _load_sexp(tmp) + symbols = _parse_symbols(sexp_data) + lib_defs = _extract_lib_symbols(sexp_data) + + d1 = [s for s in symbols if s["reference"] == "D1"][0] + lib_data = lib_defs.get(d1["lib_id"], {}) + graphics_points = lib_data.get("graphics_points", []) + + assert len(graphics_points) >= 4, "Should have extracted polyline points" + # LED body polylines span from -1.27 to 1.27 in both X and Y + xs = [p[0] for p in graphics_points] + ys = [p[1] for p in graphics_points] + assert min(xs) == pytest.approx(-1.27) + assert max(xs) == pytest.approx(1.27) + assert min(ys) == pytest.approx(-1.27) + assert max(ys) == pytest.approx(1.27) diff --git a/python/tests/test_schematic_component_fields.py b/python/tests/test_schematic_component_fields.py index 2fb1c19..96d5d84 100644 --- a/python/tests/test_schematic_component_fields.py +++ b/python/tests/test_schematic_component_fields.py @@ -1,369 +1,370 @@ -""" -Tests for get_schematic_component and edit_schematic_component fieldPositions support. -""" - -import re -import shutil -import sys -import tempfile -from pathlib import Path - -import pytest - -# Ensure python/ directory is on path so kicad_interface can be imported -sys.path.insert(0, str(Path(__file__).parent.parent)) -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "python")) - - -# --------------------------------------------------------------------------- -# Helpers shared across tests -# --------------------------------------------------------------------------- - -TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch" - -# Minimal placed-symbol block we can embed into a schematic for testing -PLACED_RESISTOR_BLOCK = """\ - (symbol (lib_id "Device:R") (at 50 50 0) (unit 1) - (in_bom yes) (on_board yes) (dnp no) - (uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") - (property "Reference" "R1" (at 51.27 47.46 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "10k" (at 51.27 52.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at 50 50 0) - (effects (font (size 1.27 1.27)) hide) - ) - ) -""" - - -def _make_test_schematic(tmp_dir: Path, extra_block: str = "") -> Path: - """Copy empty.kicad_sch into tmp_dir, optionally appending a placed symbol block.""" - dest = tmp_dir / "test.kicad_sch" - src_content = TEMPLATE_SCH.read_text(encoding="utf-8") - # Insert placed symbol block before the closing paren of the top-level form - if extra_block: - src_content = src_content.rstrip() - if src_content.endswith(")"): - src_content = src_content[:-1] + "\n" + extra_block + ")\n" - dest.write_text(src_content, encoding="utf-8") - return dest - - -# --------------------------------------------------------------------------- -# Unit tests – regex / parsing logic only (no file I/O, no KiCAD imports) -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestGetSchematicComponentParsing: - """Unit tests for the regex logic used by _handle_get_schematic_component.""" - - def _parse_fields(self, block_text: str) -> dict: - """Mirrors the regex used in _handle_get_schematic_component.""" - prop_pattern = re.compile( - r'\(property\s+"([^"]*)"\s+"([^"]*)"\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)' - ) - fields = {} - for m in prop_pattern.finditer(block_text): - name, value, x, y, angle = ( - m.group(1), - m.group(2), - m.group(3), - m.group(4), - m.group(5), - ) - fields[name] = { - "value": value, - "x": float(x), - "y": float(y), - "angle": float(angle), - } - return fields - - def _parse_comp_pos(self, block_text: str): - """Mirrors the regex used to extract symbol position.""" - m = re.search( - r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)', - block_text, - ) - if m: - return { - "x": float(m.group(1)), - "y": float(m.group(2)), - "angle": float(m.group(3)), - } - return None - - def test_parses_reference_field(self): - fields = self._parse_fields(PLACED_RESISTOR_BLOCK) - assert "Reference" in fields - assert fields["Reference"]["value"] == "R1" - assert fields["Reference"]["x"] == pytest.approx(51.27) - assert fields["Reference"]["y"] == pytest.approx(47.46) - assert fields["Reference"]["angle"] == pytest.approx(0.0) - - def test_parses_value_field(self): - fields = self._parse_fields(PLACED_RESISTOR_BLOCK) - assert "Value" in fields - assert fields["Value"]["value"] == "10k" - assert fields["Value"]["x"] == pytest.approx(51.27) - assert fields["Value"]["y"] == pytest.approx(52.54) - - def test_parses_all_four_standard_fields(self): - fields = self._parse_fields(PLACED_RESISTOR_BLOCK) - assert set(fields.keys()) >= {"Reference", "Value", "Footprint", "Datasheet"} - - def test_parses_component_position(self): - pos = self._parse_comp_pos(PLACED_RESISTOR_BLOCK) - assert pos is not None - assert pos["x"] == pytest.approx(50.0) - assert pos["y"] == pytest.approx(50.0) - assert pos["angle"] == pytest.approx(0.0) - - def test_field_position_regex_replaces_correctly(self): - """Mirrors the regex used in _handle_edit_schematic_component for fieldPositions.""" - field_name = "Reference" - new_x, new_y, new_angle = 99.0, 88.0, 0 - block = PLACED_RESISTOR_BLOCK - block = re.sub( - r'(\(property\s+"' - + re.escape(field_name) - + r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)', - rf"\1(at {new_x} {new_y} {new_angle})", - block, - ) - fields = self._parse_fields(block) - assert fields["Reference"]["x"] == pytest.approx(99.0) - assert fields["Reference"]["y"] == pytest.approx(88.0) - # Value should be unchanged - assert fields["Value"]["x"] == pytest.approx(51.27) - - def test_field_position_regex_preserves_value(self): - """Replacing position must not change the field value string.""" - block = PLACED_RESISTOR_BLOCK - block = re.sub( - r'(\(property\s+"Value"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)', - r"\1(at 0.0 0.0 0)", - block, - ) - fields = self._parse_fields(block) - assert fields["Value"]["value"] == "10k" - - -# --------------------------------------------------------------------------- -# Integration tests – real file I/O using the empty.kicad_sch template -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -class TestGetSchematicComponentIntegration: - """Integration tests: write a real .kicad_sch and call the handler.""" - - @pytest.fixture - def sch_with_r1(self, tmp_path): - return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK) - - def _get_interface(self): - """Lazily import KiCADInterface to avoid pcbnew import at collection time.""" - from kicad_interface import KiCADInterface - - return KiCADInterface() - - def test_get_returns_success(self, sch_with_r1): - iface = self._get_interface() - result = iface.handle_command( - "get_schematic_component", - { - "schematicPath": str(sch_with_r1), - "reference": "R1", - }, - ) - assert result["success"] is True - - def test_get_returns_correct_reference(self, sch_with_r1): - iface = self._get_interface() - result = iface.handle_command( - "get_schematic_component", - { - "schematicPath": str(sch_with_r1), - "reference": "R1", - }, - ) - assert result["reference"] == "R1" - - def test_get_returns_component_position(self, sch_with_r1): - iface = self._get_interface() - result = iface.handle_command( - "get_schematic_component", - { - "schematicPath": str(sch_with_r1), - "reference": "R1", - }, - ) - assert result["position"] is not None - assert result["position"]["x"] == pytest.approx(50.0) - assert result["position"]["y"] == pytest.approx(50.0) - - def test_get_returns_reference_field_position(self, sch_with_r1): - iface = self._get_interface() - result = iface.handle_command( - "get_schematic_component", - { - "schematicPath": str(sch_with_r1), - "reference": "R1", - }, - ) - ref_field = result["fields"]["Reference"] - assert ref_field["value"] == "R1" - assert ref_field["x"] == pytest.approx(51.27) - assert ref_field["y"] == pytest.approx(47.46) - - def test_get_returns_value_field(self, sch_with_r1): - iface = self._get_interface() - result = iface.handle_command( - "get_schematic_component", - { - "schematicPath": str(sch_with_r1), - "reference": "R1", - }, - ) - val_field = result["fields"]["Value"] - assert val_field["value"] == "10k" - assert val_field["x"] == pytest.approx(51.27) - assert val_field["y"] == pytest.approx(52.54) - - def test_get_unknown_reference_returns_failure(self, sch_with_r1): - iface = self._get_interface() - result = iface.handle_command( - "get_schematic_component", - { - "schematicPath": str(sch_with_r1), - "reference": "R99", - }, - ) - assert result["success"] is False - assert "R99" in result["message"] - - def test_get_missing_path_returns_failure(self): - iface = self._get_interface() - result = iface.handle_command( - "get_schematic_component", - { - "reference": "R1", - }, - ) - assert result["success"] is False - - -@pytest.mark.integration -class TestEditSchematicComponentFieldPositions: - """Integration tests for the new fieldPositions parameter.""" - - @pytest.fixture - def sch_with_r1(self, tmp_path): - return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK) - - def _get_interface(self): - from kicad_interface import KiCADInterface - - return KiCADInterface() - - def test_reposition_reference_label(self, sch_with_r1): - iface = self._get_interface() - result = iface.handle_command( - "edit_schematic_component", - { - "schematicPath": str(sch_with_r1), - "reference": "R1", - "fieldPositions": {"Reference": {"x": 99.0, "y": 88.0, "angle": 0}}, - }, - ) - assert result["success"] is True - - # Verify the position was actually written - get_result = iface.handle_command( - "get_schematic_component", - { - "schematicPath": str(sch_with_r1), - "reference": "R1", - }, - ) - assert get_result["fields"]["Reference"]["x"] == pytest.approx(99.0) - assert get_result["fields"]["Reference"]["y"] == pytest.approx(88.0) - - def test_reposition_does_not_change_value(self, sch_with_r1): - iface = self._get_interface() - iface.handle_command( - "edit_schematic_component", - { - "schematicPath": str(sch_with_r1), - "reference": "R1", - "fieldPositions": {"Reference": {"x": 99.0, "y": 88.0}}, - }, - ) - get_result = iface.handle_command( - "get_schematic_component", - { - "schematicPath": str(sch_with_r1), - "reference": "R1", - }, - ) - # Value field position must be unchanged - assert get_result["fields"]["Value"]["x"] == pytest.approx(51.27) - assert get_result["fields"]["Value"]["y"] == pytest.approx(52.54) - - def test_reposition_multiple_fields(self, sch_with_r1): - iface = self._get_interface() - result = iface.handle_command( - "edit_schematic_component", - { - "schematicPath": str(sch_with_r1), - "reference": "R1", - "fieldPositions": { - "Reference": {"x": 10.0, "y": 20.0, "angle": 0}, - "Value": {"x": 10.0, "y": 30.0, "angle": 0}, - }, - }, - ) - assert result["success"] is True - - get_result = iface.handle_command( - "get_schematic_component", - { - "schematicPath": str(sch_with_r1), - "reference": "R1", - }, - ) - assert get_result["fields"]["Reference"]["x"] == pytest.approx(10.0) - assert get_result["fields"]["Value"]["y"] == pytest.approx(30.0) - - def test_fieldpositions_alone_is_valid(self, sch_with_r1): - """fieldPositions without value/footprint/newReference should succeed.""" - iface = self._get_interface() - result = iface.handle_command( - "edit_schematic_component", - { - "schematicPath": str(sch_with_r1), - "reference": "R1", - "fieldPositions": {"Value": {"x": 55.0, "y": 60.0}}, - }, - ) - assert result["success"] is True - - def test_no_params_still_fails(self, sch_with_r1): - """Providing no update params should return an error.""" - iface = self._get_interface() - result = iface.handle_command( - "edit_schematic_component", - { - "schematicPath": str(sch_with_r1), - "reference": "R1", - }, - ) - assert result["success"] is False +""" +Tests for get_schematic_component and edit_schematic_component fieldPositions support. +""" + +import re +import shutil +import sys +import tempfile +from pathlib import Path +from typing import Any + +import pytest + +# Ensure python/ directory is on path so kicad_interface can be imported +sys.path.insert(0, str(Path(__file__).parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "python")) + + +# --------------------------------------------------------------------------- +# Helpers shared across tests +# --------------------------------------------------------------------------- + +TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch" + +# Minimal placed-symbol block we can embed into a schematic for testing +PLACED_RESISTOR_BLOCK = """\ + (symbol (lib_id "Device:R") (at 50 50 0) (unit 1) + (in_bom yes) (on_board yes) (dnp no) + (uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + (property "Reference" "R1" (at 51.27 47.46 0) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "10k" (at 51.27 52.54 0) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 50 50 0) + (effects (font (size 1.27 1.27)) hide) + ) + ) +""" + + +def _make_test_schematic(tmp_dir: Path, extra_block: str = "") -> Path: + """Copy empty.kicad_sch into tmp_dir, optionally appending a placed symbol block.""" + dest = tmp_dir / "test.kicad_sch" + src_content = TEMPLATE_SCH.read_text(encoding="utf-8") + # Insert placed symbol block before the closing paren of the top-level form + if extra_block: + src_content = src_content.rstrip() + if src_content.endswith(")"): + src_content = src_content[:-1] + "\n" + extra_block + ")\n" + dest.write_text(src_content, encoding="utf-8") + return dest + + +# --------------------------------------------------------------------------- +# Unit tests – regex / parsing logic only (no file I/O, no KiCAD imports) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetSchematicComponentParsing: + """Unit tests for the regex logic used by _handle_get_schematic_component.""" + + def _parse_fields(self, block_text: str) -> dict: + """Mirrors the regex used in _handle_get_schematic_component.""" + prop_pattern = re.compile( + r'\(property\s+"([^"]*)"\s+"([^"]*)"\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)' + ) + fields = {} + for m in prop_pattern.finditer(block_text): + name, value, x, y, angle = ( + m.group(1), + m.group(2), + m.group(3), + m.group(4), + m.group(5), + ) + fields[name] = { + "value": value, + "x": float(x), + "y": float(y), + "angle": float(angle), + } + return fields + + def _parse_comp_pos(self, block_text: str) -> Any: + """Mirrors the regex used to extract symbol position.""" + m = re.search( + r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)', + block_text, + ) + if m: + return { + "x": float(m.group(1)), + "y": float(m.group(2)), + "angle": float(m.group(3)), + } + return None + + def test_parses_reference_field(self) -> None: + fields = self._parse_fields(PLACED_RESISTOR_BLOCK) + assert "Reference" in fields + assert fields["Reference"]["value"] == "R1" + assert fields["Reference"]["x"] == pytest.approx(51.27) + assert fields["Reference"]["y"] == pytest.approx(47.46) + assert fields["Reference"]["angle"] == pytest.approx(0.0) + + def test_parses_value_field(self) -> None: + fields = self._parse_fields(PLACED_RESISTOR_BLOCK) + assert "Value" in fields + assert fields["Value"]["value"] == "10k" + assert fields["Value"]["x"] == pytest.approx(51.27) + assert fields["Value"]["y"] == pytest.approx(52.54) + + def test_parses_all_four_standard_fields(self) -> None: + fields = self._parse_fields(PLACED_RESISTOR_BLOCK) + assert set(fields.keys()) >= {"Reference", "Value", "Footprint", "Datasheet"} + + def test_parses_component_position(self) -> None: + pos = self._parse_comp_pos(PLACED_RESISTOR_BLOCK) + assert pos is not None + assert pos["x"] == pytest.approx(50.0) + assert pos["y"] == pytest.approx(50.0) + assert pos["angle"] == pytest.approx(0.0) + + def test_field_position_regex_replaces_correctly(self) -> None: + """Mirrors the regex used in _handle_edit_schematic_component for fieldPositions.""" + field_name = "Reference" + new_x, new_y, new_angle = 99.0, 88.0, 0 + block = PLACED_RESISTOR_BLOCK + block = re.sub( + r'(\(property\s+"' + + re.escape(field_name) + + r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)', + rf"\1(at {new_x} {new_y} {new_angle})", + block, + ) + fields = self._parse_fields(block) + assert fields["Reference"]["x"] == pytest.approx(99.0) + assert fields["Reference"]["y"] == pytest.approx(88.0) + # Value should be unchanged + assert fields["Value"]["x"] == pytest.approx(51.27) + + def test_field_position_regex_preserves_value(self) -> None: + """Replacing position must not change the field value string.""" + block = PLACED_RESISTOR_BLOCK + block = re.sub( + r'(\(property\s+"Value"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)', + r"\1(at 0.0 0.0 0)", + block, + ) + fields = self._parse_fields(block) + assert fields["Value"]["value"] == "10k" + + +# --------------------------------------------------------------------------- +# Integration tests – real file I/O using the empty.kicad_sch template +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestGetSchematicComponentIntegration: + """Integration tests: write a real .kicad_sch and call the handler.""" + + @pytest.fixture + def sch_with_r1(self, tmp_path: Any) -> Any: + return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK) + + def _get_interface(self) -> Any: + """Lazily import KiCADInterface to avoid pcbnew import at collection time.""" + from kicad_interface import KiCADInterface + + return KiCADInterface() + + def test_get_returns_success(self, sch_with_r1: Any) -> None: + iface = self._get_interface() + result = iface.handle_command( + "get_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }, + ) + assert result["success"] is True + + def test_get_returns_correct_reference(self, sch_with_r1: Any) -> None: + iface = self._get_interface() + result = iface.handle_command( + "get_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }, + ) + assert result["reference"] == "R1" + + def test_get_returns_component_position(self, sch_with_r1: Any) -> None: + iface = self._get_interface() + result = iface.handle_command( + "get_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }, + ) + assert result["position"] is not None + assert result["position"]["x"] == pytest.approx(50.0) + assert result["position"]["y"] == pytest.approx(50.0) + + def test_get_returns_reference_field_position(self, sch_with_r1: Any) -> None: + iface = self._get_interface() + result = iface.handle_command( + "get_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }, + ) + ref_field = result["fields"]["Reference"] + assert ref_field["value"] == "R1" + assert ref_field["x"] == pytest.approx(51.27) + assert ref_field["y"] == pytest.approx(47.46) + + def test_get_returns_value_field(self, sch_with_r1: Any) -> None: + iface = self._get_interface() + result = iface.handle_command( + "get_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }, + ) + val_field = result["fields"]["Value"] + assert val_field["value"] == "10k" + assert val_field["x"] == pytest.approx(51.27) + assert val_field["y"] == pytest.approx(52.54) + + def test_get_unknown_reference_returns_failure(self, sch_with_r1: Any) -> None: + iface = self._get_interface() + result = iface.handle_command( + "get_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R99", + }, + ) + assert result["success"] is False + assert "R99" in result["message"] + + def test_get_missing_path_returns_failure(self) -> None: + iface = self._get_interface() + result = iface.handle_command( + "get_schematic_component", + { + "reference": "R1", + }, + ) + assert result["success"] is False + + +@pytest.mark.integration +class TestEditSchematicComponentFieldPositions: + """Integration tests for the new fieldPositions parameter.""" + + @pytest.fixture + def sch_with_r1(self, tmp_path: Any) -> Any: + return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK) + + def _get_interface(self) -> Any: + from kicad_interface import KiCADInterface + + return KiCADInterface() + + def test_reposition_reference_label(self, sch_with_r1: Any) -> None: + iface = self._get_interface() + result = iface.handle_command( + "edit_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + "fieldPositions": {"Reference": {"x": 99.0, "y": 88.0, "angle": 0}}, + }, + ) + assert result["success"] is True + + # Verify the position was actually written + get_result = iface.handle_command( + "get_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }, + ) + assert get_result["fields"]["Reference"]["x"] == pytest.approx(99.0) + assert get_result["fields"]["Reference"]["y"] == pytest.approx(88.0) + + def test_reposition_does_not_change_value(self, sch_with_r1: Any) -> None: + iface = self._get_interface() + iface.handle_command( + "edit_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + "fieldPositions": {"Reference": {"x": 99.0, "y": 88.0}}, + }, + ) + get_result = iface.handle_command( + "get_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }, + ) + # Value field position must be unchanged + assert get_result["fields"]["Value"]["x"] == pytest.approx(51.27) + assert get_result["fields"]["Value"]["y"] == pytest.approx(52.54) + + def test_reposition_multiple_fields(self, sch_with_r1: Any) -> None: + iface = self._get_interface() + result = iface.handle_command( + "edit_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + "fieldPositions": { + "Reference": {"x": 10.0, "y": 20.0, "angle": 0}, + "Value": {"x": 10.0, "y": 30.0, "angle": 0}, + }, + }, + ) + assert result["success"] is True + + get_result = iface.handle_command( + "get_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }, + ) + assert get_result["fields"]["Reference"]["x"] == pytest.approx(10.0) + assert get_result["fields"]["Value"]["y"] == pytest.approx(30.0) + + def test_fieldpositions_alone_is_valid(self, sch_with_r1: Any) -> None: + """fieldPositions without value/footprint/newReference should succeed.""" + iface = self._get_interface() + result = iface.handle_command( + "edit_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + "fieldPositions": {"Value": {"x": 55.0, "y": 60.0}}, + }, + ) + assert result["success"] is True + + def test_no_params_still_fails(self, sch_with_r1: Any) -> None: + """Providing no update params should return an error.""" + iface = self._get_interface() + result = iface.handle_command( + "edit_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }, + ) + assert result["success"] is False diff --git a/python/tests/test_schematic_tools.py b/python/tests/test_schematic_tools.py index d6814b5..ef89753 100644 --- a/python/tests/test_schematic_tools.py +++ b/python/tests/test_schematic_tools.py @@ -1,476 +1,477 @@ -""" -Tests for schematic inspection and editing tools added in the schematic_tools branch. - -Covers: - - WireManager.delete_wire (unit + integration) - - WireManager.delete_label (unit + integration) - - Handler-level parameter validation for the 11 new KiCADInterface handlers - (tested by calling _handle_* methods on a lightweight stub that avoids - importing the full kicad_interface module). -""" - -import shutil -import tempfile -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -import sexpdata - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -TEMPLATES_DIR = Path(__file__).parent.parent / "templates" -EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch" - -# Minimal schematic content used by integration tests -_WIRE_SCH = """\ -(kicad_sch (version 20250114) (generator "test") - (uuid aaaaaaaa-0000-0000-0000-000000000000) - (paper "A4") - (wire (pts (xy 10 20) (xy 30 20)) - (stroke (width 0) (type default)) - (uuid bbbbbbbb-0000-0000-0000-000000000001) - ) - (label "VCC" (at 50 50 0) - (effects (font (size 1.27 1.27)) (justify left bottom)) - (uuid cccccccc-0000-0000-0000-000000000002) - ) - (sheet_instances (path "/" (page "1"))) -) -""" - - -def _write_temp_sch(content: str) -> Path: - """Write *content* to a temp file and return its Path.""" - tmp = tempfile.NamedTemporaryFile(suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8") - tmp.write(content) - tmp.close() - return Path(tmp.name) - - -# --------------------------------------------------------------------------- -# Unit tests – WireManager.delete_wire -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestDeleteWireUnit: - """Unit-level tests for WireManager.delete_wire.""" - - def setup_method(self): - from commands.wire_manager import WireManager - - self.WireManager = WireManager - - def test_nonexistent_file_returns_false(self, tmp_path): - result = self.WireManager.delete_wire(tmp_path / "nope.kicad_sch", [0, 0], [10, 10]) - assert result is False - - def test_no_matching_wire_returns_false(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - shutil.copy(EMPTY_SCH, sch) - result = self.WireManager.delete_wire(sch, [99, 99], [100, 100]) - assert result is False - - def test_tolerance_argument_accepted(self, tmp_path): - """Ensure the tolerance kwarg doesn't raise a TypeError.""" - sch = tmp_path / "test.kicad_sch" - shutil.copy(EMPTY_SCH, sch) - result = self.WireManager.delete_wire(sch, [0, 0], [1, 1], tolerance=0.1) - assert result is False # no wire in empty sch - - -# --------------------------------------------------------------------------- -# Unit tests – WireManager.delete_label -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestDeleteLabelUnit: - """Unit-level tests for WireManager.delete_label.""" - - def setup_method(self): - from commands.wire_manager import WireManager - - self.WireManager = WireManager - - def test_nonexistent_file_returns_false(self, tmp_path): - result = self.WireManager.delete_label(tmp_path / "nope.kicad_sch", "VCC") - assert result is False - - def test_missing_label_returns_false(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - shutil.copy(EMPTY_SCH, sch) - result = self.WireManager.delete_label(sch, "NONEXISTENT") - assert result is False - - def test_position_kwarg_accepted(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - shutil.copy(EMPTY_SCH, sch) - result = self.WireManager.delete_label(sch, "VCC", position=[10.0, 20.0], tolerance=0.5) - assert result is False - - -# --------------------------------------------------------------------------- -# Integration tests – WireManager.delete_wire -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -class TestDeleteWireIntegration: - """Integration tests that read/write real .kicad_sch files.""" - - def setup_method(self): - from commands.wire_manager import WireManager - - self.WireManager = WireManager - - def test_exact_match_deletes_wire(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - - result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0]) - - assert result is True - data = sexpdata.loads(sch.read_text(encoding="utf-8")) - wire_items = [ - item - for item in data - if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire") - ] - assert wire_items == [], "Wire should have been removed from the file" - - def test_reverse_direction_match_deletes_wire(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - - # Pass end/start swapped – should still match - result = self.WireManager.delete_wire(sch, [30.0, 20.0], [10.0, 20.0]) - - assert result is True - - def test_within_tolerance_deletes_wire(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - - # Coordinates differ by 0.3 mm — within default tolerance of 0.5 - result = self.WireManager.delete_wire(sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5) - assert result is True - - def test_outside_tolerance_no_delete(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - - result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0) - # tolerance=0.0 means exact float equality — may still match on most - # platforms, but the key thing is that a *distant* miss is rejected - sch2 = tmp_path / "test2.kicad_sch" - sch2.write_text(_WIRE_SCH, encoding="utf-8") - result2 = self.WireManager.delete_wire(sch2, [10.6, 20.0], [30.0, 20.0], tolerance=0.5) - assert result2 is False, "Coordinate differs by 0.6 mm — outside 0.5 mm tolerance" - - def test_file_is_valid_sexp_after_deletion(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0]) - # Must parse without exception - sexpdata.loads(sch.read_text(encoding="utf-8")) - - def test_label_preserved_after_wire_deletion(self, tmp_path): - """Deleting a wire must not remove unrelated elements.""" - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0]) - data = sexpdata.loads(sch.read_text(encoding="utf-8")) - labels = [ - item - for item in data - if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label") - ] - assert len(labels) == 1 - - -# --------------------------------------------------------------------------- -# Integration tests – WireManager.delete_label -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -class TestDeleteLabelIntegration: - def setup_method(self): - from commands.wire_manager import WireManager - - self.WireManager = WireManager - - def test_deletes_label_by_name(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - - result = self.WireManager.delete_label(sch, "VCC") - - assert result is True - data = sexpdata.loads(sch.read_text(encoding="utf-8")) - labels = [ - item - for item in data - if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label") - ] - assert labels == [], "Label should have been removed" - - def test_deletes_label_with_matching_position(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - - result = self.WireManager.delete_label(sch, "VCC", position=[50.0, 50.0]) - assert result is True - - def test_position_mismatch_no_delete(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - - result = self.WireManager.delete_label(sch, "VCC", position=[99.0, 99.0], tolerance=0.5) - assert result is False - - def test_wire_preserved_after_label_deletion(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - self.WireManager.delete_label(sch, "VCC") - data = sexpdata.loads(sch.read_text(encoding="utf-8")) - wires = [ - item - for item in data - if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire") - ] - assert len(wires) == 1 - - def test_file_is_valid_sexp_after_deletion(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - self.WireManager.delete_label(sch, "VCC") - sexpdata.loads(sch.read_text(encoding="utf-8")) - - -# --------------------------------------------------------------------------- -# Unit tests – handler parameter validation (via lightweight handler stubs) -# --------------------------------------------------------------------------- -# We test the validation logic of the new _handle_* methods without importing -# the full kicad_interface module (which pulls in pcbnew and calls sys.exit). -# Each handler is extracted as a standalone function for testing. - - -def _make_handler_under_test(handler_name: str): - """ - Return the unbound handler method from kicad_interface by importing only - that method's source via exec, bypassing module-level side effects. - - This works because every _handle_* method starts with a params dict check - before doing any file I/O or heavy imports. - """ - import importlib.util - import types - - # We monkey-patch sys.modules to avoid pcbnew/skip side effects - stubs = {} - for mod in ("pcbnew", "skip", "commands.schematic"): - stubs[mod] = types.ModuleType(mod) - - # Provide a minimal SchematicManager stub so attribute lookups don't fail - schema_stub = types.ModuleType("commands.schematic") - schema_stub.SchematicManager = MagicMock() - stubs["commands.schematic"] = schema_stub - - with patch.dict("sys.modules", stubs): - # Import just the handlers module in isolation isn't feasible for - # kicad_interface.py (module-level sys.exit). Instead, we directly - # call the method on a MagicMock instance, binding the real function. - pass - - return None # Not used; see TestHandlerParamValidation below - - -@pytest.mark.unit -class TestHandlerParamValidation: - """ - Verify that each new handler returns success=False with an informative - message when required parameters are missing, without needing real files. - - We call the handler functions directly after building minimal stub objects - that satisfy the dependency chain up to the first parameter-check branch. - """ - - def _make_iface_stub(self): - """Return a stub that exposes only the handler methods under test.""" - import importlib - import types - - # Build a minimal namespace that satisfies the imports inside each handler - stub_mod = types.ModuleType("_handler_stubs") - stub_mod.os = __import__("os") - - class _Stub: - pass - - return _Stub() - - # --- delete_schematic_wire --- - - def test_delete_wire_missing_schematic_path(self): - from commands.wire_manager import WireManager - - with patch.object(WireManager, "delete_wire", return_value=False): - # Simulate the handler logic inline - params = {"start": {"x": 0, "y": 0}, "end": {"x": 10, "y": 10}} - schematic_path = params.get("schematicPath") - assert schematic_path is None - # Handler should short-circuit before calling WireManager - result = ( - {"success": False, "message": "schematicPath is required"} - if not schematic_path - else {} - ) - assert result["success"] is False - assert "schematicPath" in result["message"] - - # --- delete_schematic_net_label --- - - def test_delete_label_missing_net_name(self): - params = {"schematicPath": "/some/file.kicad_sch"} - net_name = params.get("netName") - result = ( - { - "success": False, - "message": "schematicPath and netName are required", - } - if not net_name - else {} - ) - assert result["success"] is False - - def test_delete_label_missing_schematic_path(self): - params = {"netName": "VCC"} - schematic_path = params.get("schematicPath") - result = ( - { - "success": False, - "message": "schematicPath and netName are required", - } - if not schematic_path - else {} - ) - assert result["success"] is False - - # --- list_schematic_components --- - - def test_list_components_missing_path(self): - params = {} - schematic_path = params.get("schematicPath") - result = ( - {"success": False, "message": "schematicPath is required"} if not schematic_path else {} - ) - assert result["success"] is False - - # --- list_schematic_nets --- - - def test_list_nets_missing_path(self): - params = {} - result = ( - {"success": False, "message": "schematicPath is required"} - if not params.get("schematicPath") - else {} - ) - assert result["success"] is False - - # --- list_schematic_wires --- - - def test_list_wires_missing_path(self): - params = {} - result = ( - {"success": False, "message": "schematicPath is required"} - if not params.get("schematicPath") - else {} - ) - assert result["success"] is False - - # --- list_schematic_labels --- - - def test_list_labels_missing_path(self): - params = {} - result = ( - {"success": False, "message": "schematicPath is required"} - if not params.get("schematicPath") - else {} - ) - assert result["success"] is False - - # --- move_schematic_component --- - - def test_move_component_missing_reference(self): - params = { - "schematicPath": "/some/file.kicad_sch", - "position": {"x": 10, "y": 20}, - } - result = ( - { - "success": False, - "message": "schematicPath and reference are required", - } - if not params.get("reference") - else {} - ) - assert result["success"] is False - - def test_move_component_missing_position(self): - params = { - "schematicPath": "/some/file.kicad_sch", - "reference": "R1", - "position": {}, - } - new_x = params["position"].get("x") - new_y = params["position"].get("y") - result = ( - {"success": False, "message": "position with x and y is required"} - if new_x is None or new_y is None - else {} - ) - assert result["success"] is False - - # --- rotate_schematic_component --- - - def test_rotate_component_missing_reference(self): - params = {"schematicPath": "/some/file.kicad_sch"} - result = ( - { - "success": False, - "message": "schematicPath and reference are required", - } - if not params.get("reference") - else {} - ) - assert result["success"] is False - - # --- annotate_schematic --- - - def test_annotate_missing_path(self): - params = {} - result = ( - {"success": False, "message": "schematicPath is required"} - if not params.get("schematicPath") - else {} - ) - assert result["success"] is False - - # --- export_schematic_svg --- - - def test_export_svg_missing_output_path(self): - params = {"schematicPath": "/some/file.kicad_sch"} - result = ( - { - "success": False, - "message": "schematicPath and outputPath are required", - } - if not params.get("outputPath") - else {} - ) - assert result["success"] is False +""" +Tests for schematic inspection and editing tools added in the schematic_tools branch. + +Covers: + - WireManager.delete_wire (unit + integration) + - WireManager.delete_label (unit + integration) + - Handler-level parameter validation for the 11 new KiCADInterface handlers + (tested by calling _handle_* methods on a lightweight stub that avoids + importing the full kicad_interface module). +""" + +import shutil +import tempfile +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +import sexpdata + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +TEMPLATES_DIR = Path(__file__).parent.parent / "templates" +EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch" + +# Minimal schematic content used by integration tests +_WIRE_SCH = """\ +(kicad_sch (version 20250114) (generator "test") + (uuid aaaaaaaa-0000-0000-0000-000000000000) + (paper "A4") + (wire (pts (xy 10 20) (xy 30 20)) + (stroke (width 0) (type default)) + (uuid bbbbbbbb-0000-0000-0000-000000000001) + ) + (label "VCC" (at 50 50 0) + (effects (font (size 1.27 1.27)) (justify left bottom)) + (uuid cccccccc-0000-0000-0000-000000000002) + ) + (sheet_instances (path "/" (page "1"))) +) +""" + + +def _write_temp_sch(content: str) -> Path: + """Write *content* to a temp file and return its Path.""" + tmp = tempfile.NamedTemporaryFile(suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8") + tmp.write(content) + tmp.close() + return Path(tmp.name) + + +# --------------------------------------------------------------------------- +# Unit tests – WireManager.delete_wire +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestDeleteWireUnit: + """Unit-level tests for WireManager.delete_wire.""" + + def setup_method(self) -> None: + from commands.wire_manager import WireManager + + self.WireManager = WireManager + + def test_nonexistent_file_returns_false(self, tmp_path: Any) -> None: + result = self.WireManager.delete_wire(tmp_path / "nope.kicad_sch", [0, 0], [10, 10]) + assert result is False + + def test_no_matching_wire_returns_false(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + shutil.copy(EMPTY_SCH, sch) + result = self.WireManager.delete_wire(sch, [99, 99], [100, 100]) + assert result is False + + def test_tolerance_argument_accepted(self, tmp_path: Any) -> None: + """Ensure the tolerance kwarg doesn't raise a TypeError.""" + sch = tmp_path / "test.kicad_sch" + shutil.copy(EMPTY_SCH, sch) + result = self.WireManager.delete_wire(sch, [0, 0], [1, 1], tolerance=0.1) + assert result is False # no wire in empty sch + + +# --------------------------------------------------------------------------- +# Unit tests – WireManager.delete_label +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestDeleteLabelUnit: + """Unit-level tests for WireManager.delete_label.""" + + def setup_method(self) -> None: + from commands.wire_manager import WireManager + + self.WireManager = WireManager + + def test_nonexistent_file_returns_false(self, tmp_path: Any) -> None: + result = self.WireManager.delete_label(tmp_path / "nope.kicad_sch", "VCC") + assert result is False + + def test_missing_label_returns_false(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + shutil.copy(EMPTY_SCH, sch) + result = self.WireManager.delete_label(sch, "NONEXISTENT") + assert result is False + + def test_position_kwarg_accepted(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + shutil.copy(EMPTY_SCH, sch) + result = self.WireManager.delete_label(sch, "VCC", position=[10.0, 20.0], tolerance=0.5) + assert result is False + + +# --------------------------------------------------------------------------- +# Integration tests – WireManager.delete_wire +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestDeleteWireIntegration: + """Integration tests that read/write real .kicad_sch files.""" + + def setup_method(self) -> None: + from commands.wire_manager import WireManager + + self.WireManager = WireManager + + def test_exact_match_deletes_wire(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + + result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0]) + + assert result is True + data = sexpdata.loads(sch.read_text(encoding="utf-8")) + wire_items = [ + item + for item in data + if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire") + ] + assert wire_items == [], "Wire should have been removed from the file" + + def test_reverse_direction_match_deletes_wire(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + + # Pass end/start swapped – should still match + result = self.WireManager.delete_wire(sch, [30.0, 20.0], [10.0, 20.0]) + + assert result is True + + def test_within_tolerance_deletes_wire(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + + # Coordinates differ by 0.3 mm — within default tolerance of 0.5 + result = self.WireManager.delete_wire(sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5) + assert result is True + + def test_outside_tolerance_no_delete(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + + result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0) + # tolerance=0.0 means exact float equality — may still match on most + # platforms, but the key thing is that a *distant* miss is rejected + sch2 = tmp_path / "test2.kicad_sch" + sch2.write_text(_WIRE_SCH, encoding="utf-8") + result2 = self.WireManager.delete_wire(sch2, [10.6, 20.0], [30.0, 20.0], tolerance=0.5) + assert result2 is False, "Coordinate differs by 0.6 mm — outside 0.5 mm tolerance" + + def test_file_is_valid_sexp_after_deletion(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0]) + # Must parse without exception + sexpdata.loads(sch.read_text(encoding="utf-8")) + + def test_label_preserved_after_wire_deletion(self, tmp_path: Any) -> None: + """Deleting a wire must not remove unrelated elements.""" + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0]) + data = sexpdata.loads(sch.read_text(encoding="utf-8")) + labels = [ + item + for item in data + if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label") + ] + assert len(labels) == 1 + + +# --------------------------------------------------------------------------- +# Integration tests – WireManager.delete_label +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestDeleteLabelIntegration: + def setup_method(self) -> None: + from commands.wire_manager import WireManager + + self.WireManager = WireManager + + def test_deletes_label_by_name(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + + result = self.WireManager.delete_label(sch, "VCC") + + assert result is True + data = sexpdata.loads(sch.read_text(encoding="utf-8")) + labels = [ + item + for item in data + if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label") + ] + assert labels == [], "Label should have been removed" + + def test_deletes_label_with_matching_position(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + + result = self.WireManager.delete_label(sch, "VCC", position=[50.0, 50.0]) + assert result is True + + def test_position_mismatch_no_delete(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + + result = self.WireManager.delete_label(sch, "VCC", position=[99.0, 99.0], tolerance=0.5) + assert result is False + + def test_wire_preserved_after_label_deletion(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + self.WireManager.delete_label(sch, "VCC") + data = sexpdata.loads(sch.read_text(encoding="utf-8")) + wires = [ + item + for item in data + if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire") + ] + assert len(wires) == 1 + + def test_file_is_valid_sexp_after_deletion(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + self.WireManager.delete_label(sch, "VCC") + sexpdata.loads(sch.read_text(encoding="utf-8")) + + +# --------------------------------------------------------------------------- +# Unit tests – handler parameter validation (via lightweight handler stubs) +# --------------------------------------------------------------------------- +# We test the validation logic of the new _handle_* methods without importing +# the full kicad_interface module (which pulls in pcbnew and calls sys.exit). +# Each handler is extracted as a standalone function for testing. + + +def _make_handler_under_test(handler_name: str) -> None: + """ + Return the unbound handler method from kicad_interface by importing only + that method's source via exec, bypassing module-level side effects. + + This works because every _handle_* method starts with a params dict check + before doing any file I/O or heavy imports. + """ + import importlib.util + import types + + # We monkey-patch sys.modules to avoid pcbnew/skip side effects + stubs = {} + for mod in ("pcbnew", "skip", "commands.schematic"): + stubs[mod] = types.ModuleType(mod) + + # Provide a minimal SchematicManager stub so attribute lookups don't fail + schema_stub = types.ModuleType("commands.schematic") + schema_stub.SchematicManager = MagicMock() + stubs["commands.schematic"] = schema_stub + + with patch.dict("sys.modules", stubs): + # Import just the handlers module in isolation isn't feasible for + # kicad_interface.py (module-level sys.exit). Instead, we directly + # call the method on a MagicMock instance, binding the real function. + pass + + return None # Not used; see TestHandlerParamValidation below + + +@pytest.mark.unit +class TestHandlerParamValidation: + """ + Verify that each new handler returns success=False with an informative + message when required parameters are missing, without needing real files. + + We call the handler functions directly after building minimal stub objects + that satisfy the dependency chain up to the first parameter-check branch. + """ + + def _make_iface_stub(self) -> Any: + """Return a stub that exposes only the handler methods under test.""" + import importlib + import types + + # Build a minimal namespace that satisfies the imports inside each handler + stub_mod = types.ModuleType("_handler_stubs") + stub_mod.os = __import__("os") + + class _Stub: + pass + + return _Stub() + + # --- delete_schematic_wire --- + + def test_delete_wire_missing_schematic_path(self) -> None: + from commands.wire_manager import WireManager + + with patch.object(WireManager, "delete_wire", return_value=False): + # Simulate the handler logic inline + params = {"start": {"x": 0, "y": 0}, "end": {"x": 10, "y": 10}} + schematic_path = params.get("schematicPath") + assert schematic_path is None + # Handler should short-circuit before calling WireManager + result: dict[str, Any] = ( + {"success": False, "message": "schematicPath is required"} + if not schematic_path + else {} + ) + assert result["success"] is False + assert "schematicPath" in result["message"] + + # --- delete_schematic_net_label --- + + def test_delete_label_missing_net_name(self) -> None: + params = {"schematicPath": "/some/file.kicad_sch"} + net_name = params.get("netName") + result = ( + { + "success": False, + "message": "schematicPath and netName are required", + } + if not net_name + else {} + ) + assert result["success"] is False + + def test_delete_label_missing_schematic_path(self) -> None: + params = {"netName": "VCC"} + schematic_path = params.get("schematicPath") + result = ( + { + "success": False, + "message": "schematicPath and netName are required", + } + if not schematic_path + else {} + ) + assert result["success"] is False + + # --- list_schematic_components --- + + def test_list_components_missing_path(self) -> None: + params = {} + schematic_path = params.get("schematicPath") + result = ( + {"success": False, "message": "schematicPath is required"} if not schematic_path else {} + ) + assert result["success"] is False + + # --- list_schematic_nets --- + + def test_list_nets_missing_path(self) -> None: + params = {} + result = ( + {"success": False, "message": "schematicPath is required"} + if not params.get("schematicPath") + else {} + ) + assert result["success"] is False + + # --- list_schematic_wires --- + + def test_list_wires_missing_path(self) -> None: + params = {} + result = ( + {"success": False, "message": "schematicPath is required"} + if not params.get("schematicPath") + else {} + ) + assert result["success"] is False + + # --- list_schematic_labels --- + + def test_list_labels_missing_path(self) -> None: + params = {} + result = ( + {"success": False, "message": "schematicPath is required"} + if not params.get("schematicPath") + else {} + ) + assert result["success"] is False + + # --- move_schematic_component --- + + def test_move_component_missing_reference(self) -> None: + params = { + "schematicPath": "/some/file.kicad_sch", + "position": {"x": 10, "y": 20}, + } + result = ( + { + "success": False, + "message": "schematicPath and reference are required", + } + if not params.get("reference") + else {} + ) + assert result["success"] is False + + def test_move_component_missing_position(self) -> None: + params = { + "schematicPath": "/some/file.kicad_sch", + "reference": "R1", + "position": {}, + } + new_x = params["position"].get("x") + new_y = params["position"].get("y") + result = ( + {"success": False, "message": "position with x and y is required"} + if new_x is None or new_y is None + else {} + ) + assert result["success"] is False + + # --- rotate_schematic_component --- + + def test_rotate_component_missing_reference(self) -> None: + params = {"schematicPath": "/some/file.kicad_sch"} + result = ( + { + "success": False, + "message": "schematicPath and reference are required", + } + if not params.get("reference") + else {} + ) + assert result["success"] is False + + # --- annotate_schematic --- + + def test_annotate_missing_path(self) -> None: + params = {} + result = ( + {"success": False, "message": "schematicPath is required"} + if not params.get("schematicPath") + else {} + ) + assert result["success"] is False + + # --- export_schematic_svg --- + + def test_export_svg_missing_output_path(self) -> None: + params = {"schematicPath": "/some/file.kicad_sch"} + result = ( + { + "success": False, + "message": "schematicPath and outputPath are required", + } + if not params.get("outputPath") + else {} + ) + assert result["success"] is False diff --git a/python/tests/test_wire_connectivity.py b/python/tests/test_wire_connectivity.py index 6df3c8c..06bb41a 100644 --- a/python/tests/test_wire_connectivity.py +++ b/python/tests/test_wire_connectivity.py @@ -1,332 +1,332 @@ -""" -Tests for the wire_connectivity module and the get_wire_connections handler. - -Covers: - - Schema shape (TestSchema) - - Handler dispatch registration (TestHandlerDispatch) - - Parameter validation in the handler (TestHandlerParamValidation) - - Core logic: _to_iu, _parse_wires, _build_adjacency, _find_connected_wires, - get_wire_connections (TestCoreLogic) -""" - -import sys -from pathlib import Path -from typing import Any -from unittest.mock import MagicMock, patch - -import pytest - -# Ensure the python package root is importable -sys.path.insert(0, str(Path(__file__).parent.parent)) - -# --------------------------------------------------------------------------- -# Module under test -# --------------------------------------------------------------------------- -from commands.wire_connectivity import ( - _build_adjacency, - _find_connected_wires, - _parse_wires, - _to_iu, - get_wire_connections, -) - -# --------------------------------------------------------------------------- -# Helpers to build minimal mock schematic objects -# --------------------------------------------------------------------------- - - -def _make_point(x: float, y: float) -> MagicMock: - pt = MagicMock() - pt.value = [x, y] - return pt - - -def _make_wire(x1: float, y1: float, x2: float, y2: float) -> MagicMock: - wire = MagicMock() - wire.pts = MagicMock() - wire.pts.xy = [_make_point(x1, y1), _make_point(x2, y2)] - return wire - - -def _make_schematic(*wires) -> MagicMock: - sch = MagicMock() - sch.wire = list(wires) - # No net labels, no symbols by default - del sch.label # make hasattr(..., "label") return False - del sch.symbol # make hasattr(..., "symbol") return False - return sch - - -# --------------------------------------------------------------------------- -# TestSchema -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestSchema: - """Verify the get_wire_connections tool schema is present and well-formed.""" - - def test_schema_registered(self): - from schemas.tool_schemas import TOOL_SCHEMAS - - assert "get_wire_connections" in TOOL_SCHEMAS - - def test_schema_required_fields(self): - from schemas.tool_schemas import TOOL_SCHEMAS - - schema = TOOL_SCHEMAS["get_wire_connections"] - required = schema["inputSchema"]["required"] - assert "schematicPath" in required - assert "x" in required - assert "y" in required - - def test_schema_has_title_and_description(self): - from schemas.tool_schemas import TOOL_SCHEMAS - - schema = TOOL_SCHEMAS["get_wire_connections"] - assert schema.get("title") - assert schema.get("description") - - -# --------------------------------------------------------------------------- -# TestHandlerDispatch -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestHandlerDispatch: - """Verify the handler is wired into KiCadInterface.command_routes.""" - - def test_get_wire_connections_in_routes(self): - # Import lazily to avoid heavy side-effects at collection time - with patch("kicad_interface.USE_IPC_BACKEND", False): - from kicad_interface import KiCADInterface - - iface = KiCADInterface.__new__(KiCADInterface) - iface.board = None - iface.project_filename = None - iface.use_ipc = False - iface.ipc_backend = MagicMock() - iface.ipc_board_api = None - iface.footprint_library = MagicMock() - iface.project_commands = MagicMock() - iface.board_commands = MagicMock() - iface.component_commands = MagicMock() - iface.routing_commands = MagicMock() - - # Build routes only (avoid full __init__ side-effects) - # The routes dict is built in __init__; we call it directly. - iface.__init__() - - assert "get_wire_connections" in iface.command_routes - assert callable(iface.command_routes["get_wire_connections"]) - - -# --------------------------------------------------------------------------- -# TestHandlerParamValidation -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestHandlerParamValidation: - """Handler returns error responses for bad or missing parameters.""" - - def _make_handler(self): - """Return a bound _handle_get_wire_connections without full init.""" - with patch("kicad_interface.USE_IPC_BACKEND", False): - from kicad_interface import KiCADInterface - - iface = KiCADInterface.__new__(KiCADInterface) - return iface._handle_get_wire_connections - - def test_missing_schematic_path(self): - handler = self._make_handler() - result = handler({"x": 1.0, "y": 2.0}) - assert result["success"] is False - assert "schematicPath" in result["message"] or "Missing" in result["message"] - - def test_missing_x(self): - handler = self._make_handler() - result = handler({"schematicPath": "/tmp/test.kicad_sch", "y": 2.0}) - assert result["success"] is False - - def test_missing_y(self): - handler = self._make_handler() - result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0}) - assert result["success"] is False - - def test_non_numeric_x(self): - handler = self._make_handler() - result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "bad", "y": 2.0}) - assert result["success"] is False - assert "numeric" in result["message"].lower() or "x" in result["message"] - - def test_non_numeric_y(self): - handler = self._make_handler() - result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0, "y": "bad"}) - assert result["success"] is False - - -# --------------------------------------------------------------------------- -# TestCoreLogic -# --------------------------------------------------------------------------- - -_IU = 10_000 # IU per mm - - -@pytest.mark.unit -class TestCoreLogic: - """Unit tests for the pure-logic functions in wire_connectivity.""" - - # --- _to_iu --- - - def test_to_iu_integer_mm(self): - assert _to_iu(1.0, 2.0) == (10_000, 20_000) - - def test_to_iu_fractional_mm(self): - assert _to_iu(0.5, 0.25) == (5_000, 2_500) - - def test_to_iu_zero(self): - assert _to_iu(0.0, 0.0) == (0, 0) - - def test_to_iu_negative(self): - assert _to_iu(-1.0, -2.0) == (-10_000, -20_000) - - # --- _parse_wires --- - - def test_parse_wires_single_wire(self): - sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) - result = _parse_wires(sch) - assert len(result) == 1 - assert result[0] == [(0, 0), (10_000, 0)] - - def test_parse_wires_empty_schematic(self): - sch = MagicMock() - sch.wire = [] - assert _parse_wires(sch) == [] - - def test_parse_wires_multiple_wires(self): - sch = _make_schematic( - _make_wire(0.0, 0.0, 1.0, 0.0), - _make_wire(1.0, 0.0, 2.0, 0.0), - ) - assert len(_parse_wires(sch)) == 2 - - def test_parse_wires_skips_wire_without_pts(self): - bad_wire = MagicMock(spec=[]) # no `pts` attribute - sch = MagicMock() - sch.wire = [bad_wire] - assert _parse_wires(sch) == [] - - # --- _build_adjacency --- - - def test_build_adjacency_two_connected_wires(self): - # wire0: (0,0)-(1,0), wire1: (1,0)-(2,0) — share endpoint (1,0) - wires = [ - [(0, 0), (10_000, 0)], - [(10_000, 0), (20_000, 0)], - ] - adjacency, iu_to_wires = _build_adjacency(wires) - assert 1 in adjacency[0] - assert 0 in adjacency[1] - - def test_build_adjacency_two_disconnected_wires(self): - wires = [ - [(0, 0), (10_000, 0)], - [(20_000, 0), (30_000, 0)], - ] - adjacency, _ = _build_adjacency(wires) - assert adjacency[0] == set() - assert adjacency[1] == set() - - def test_build_adjacency_iu_to_wires_maps_correctly(self): - wires = [ - [(0, 0), (10_000, 0)], - [(10_000, 0), (20_000, 0)], - ] - _, iu_to_wires = _build_adjacency(wires) - assert iu_to_wires[(10_000, 0)] == {0, 1} - assert iu_to_wires[(0, 0)] == {0} - - def test_build_adjacency_three_wires_at_junction(self): - # All three wires meet at (10,000, 0) - wires = [ - [(0, 0), (10_000, 0)], - [(10_000, 0), (20_000, 0)], - [(10_000, 0), (10_000, 10_000)], - ] - adjacency, _ = _build_adjacency(wires) - assert adjacency[0] == {1, 2} - assert adjacency[1] == {0, 2} - assert adjacency[2] == {0, 1} - - # --- _find_connected_wires --- - - def test_find_connected_wires_no_wire_at_point(self): - wires = [[(0, 0), (10_000, 0)]] - adjacency, iu_to_wires = _build_adjacency(wires) - visited, net_points = _find_connected_wires(5.0, 0.0, wires, iu_to_wires, adjacency) - assert visited is None - assert net_points is None - - def test_find_connected_wires_single_wire(self): - wires = [[(0, 0), (10_000, 0)]] - adjacency, iu_to_wires = _build_adjacency(wires) - visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) - assert visited == {0} - assert (0, 0) in net_points - assert (10_000, 0) in net_points - - def test_find_connected_wires_flood_fills_chain(self): - # Three wires in a chain: A-B-C-D - wires = [ - [(0, 0), (10_000, 0)], - [(10_000, 0), (20_000, 0)], - [(20_000, 0), (30_000, 0)], - ] - adjacency, iu_to_wires = _build_adjacency(wires) - visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) - assert visited == {0, 1, 2} - - def test_find_connected_wires_does_not_cross_gap(self): - # Two disconnected segments; query on segment 0 should not reach segment 1 - wires = [ - [(0, 0), (10_000, 0)], - [(20_000, 0), (30_000, 0)], - ] - adjacency, iu_to_wires = _build_adjacency(wires) - visited, _ = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) - assert visited == {0} - - # --- get_wire_connections (integration of internal functions) --- - - def test_get_wire_connections_no_wires(self): - sch = MagicMock() - sch.wire = [] - result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) - assert result == {"pins": [], "wires": []} - - def test_get_wire_connections_no_wire_at_point_returns_none(self): - sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) - result = get_wire_connections(sch, "/fake/path.kicad_sch", 5.0, 0.0) - assert result is None - - def test_get_wire_connections_returns_wire_data(self): - sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) - # Prevent _find_pins_on_net from iterating symbols - result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) - assert result is not None - assert result["pins"] == [] - assert len(result["wires"]) == 1 - wire = result["wires"][0] - assert wire["start"] == {"x": 0.0, "y": 0.0} - assert wire["end"] == {"x": 1.0, "y": 0.0} - - def test_get_wire_connections_chain_returns_all_wires(self): - sch = _make_schematic( - _make_wire(0.0, 0.0, 1.0, 0.0), - _make_wire(1.0, 0.0, 2.0, 0.0), - ) - result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) - assert result is not None - assert len(result["wires"]) == 2 +""" +Tests for the wire_connectivity module and the get_wire_connections handler. + +Covers: + - Schema shape (TestSchema) + - Handler dispatch registration (TestHandlerDispatch) + - Parameter validation in the handler (TestHandlerParamValidation) + - Core logic: _to_iu, _parse_wires, _build_adjacency, _find_connected_wires, + get_wire_connections (TestCoreLogic) +""" + +import sys +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +# Ensure the python package root is importable +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# --------------------------------------------------------------------------- +# Module under test +# --------------------------------------------------------------------------- +from commands.wire_connectivity import ( + _build_adjacency, + _find_connected_wires, + _parse_wires, + _to_iu, + get_wire_connections, +) + +# --------------------------------------------------------------------------- +# Helpers to build minimal mock schematic objects +# --------------------------------------------------------------------------- + + +def _make_point(x: float, y: float) -> MagicMock: + pt = MagicMock() + pt.value = [x, y] + return pt + + +def _make_wire(x1: float, y1: float, x2: float, y2: float) -> MagicMock: + wire = MagicMock() + wire.pts = MagicMock() + wire.pts.xy = [_make_point(x1, y1), _make_point(x2, y2)] + return wire + + +def _make_schematic(*wires: Any) -> MagicMock: + sch = MagicMock() + sch.wire = list(wires) + # No net labels, no symbols by default + del sch.label # make hasattr(..., "label") return False + del sch.symbol # make hasattr(..., "symbol") return False + return sch + + +# --------------------------------------------------------------------------- +# TestSchema +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestSchema: + """Verify the get_wire_connections tool schema is present and well-formed.""" + + def test_schema_registered(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + assert "get_wire_connections" in TOOL_SCHEMAS + + def test_schema_required_fields(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + schema = TOOL_SCHEMAS["get_wire_connections"] + required = schema["inputSchema"]["required"] + assert "schematicPath" in required + assert "x" in required + assert "y" in required + + def test_schema_has_title_and_description(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + schema = TOOL_SCHEMAS["get_wire_connections"] + assert schema.get("title") + assert schema.get("description") + + +# --------------------------------------------------------------------------- +# TestHandlerDispatch +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestHandlerDispatch: + """Verify the handler is wired into KiCadInterface.command_routes.""" + + def test_get_wire_connections_in_routes(self) -> None: + # Import lazily to avoid heavy side-effects at collection time + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + iface.board = None + iface.project_filename = None + iface.use_ipc = False + iface.ipc_backend = MagicMock() + iface.ipc_board_api = None + iface.footprint_library = MagicMock() + iface.project_commands = MagicMock() + iface.board_commands = MagicMock() + iface.component_commands = MagicMock() + iface.routing_commands = MagicMock() + + # Build routes only (avoid full __init__ side-effects) + # The routes dict is built in __init__; we call it directly. + KiCADInterface.__init__(iface) + + assert "get_wire_connections" in iface.command_routes + assert callable(iface.command_routes["get_wire_connections"]) + + +# --------------------------------------------------------------------------- +# TestHandlerParamValidation +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestHandlerParamValidation: + """Handler returns error responses for bad or missing parameters.""" + + def _make_handler(self) -> Any: + """Return a bound _handle_get_wire_connections without full init.""" + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + return iface._handle_get_wire_connections + + def test_missing_schematic_path(self) -> None: + handler = self._make_handler() + result = handler({"x": 1.0, "y": 2.0}) + assert result["success"] is False + assert "schematicPath" in result["message"] or "Missing" in result["message"] + + def test_missing_x(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "y": 2.0}) + assert result["success"] is False + + def test_missing_y(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0}) + assert result["success"] is False + + def test_non_numeric_x(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "bad", "y": 2.0}) + assert result["success"] is False + assert "numeric" in result["message"].lower() or "x" in result["message"] + + def test_non_numeric_y(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0, "y": "bad"}) + assert result["success"] is False + + +# --------------------------------------------------------------------------- +# TestCoreLogic +# --------------------------------------------------------------------------- + +_IU = 10_000 # IU per mm + + +@pytest.mark.unit +class TestCoreLogic: + """Unit tests for the pure-logic functions in wire_connectivity.""" + + # --- _to_iu --- + + def test_to_iu_integer_mm(self) -> None: + assert _to_iu(1.0, 2.0) == (10_000, 20_000) + + def test_to_iu_fractional_mm(self) -> None: + assert _to_iu(0.5, 0.25) == (5_000, 2_500) + + def test_to_iu_zero(self) -> None: + assert _to_iu(0.0, 0.0) == (0, 0) + + def test_to_iu_negative(self) -> None: + assert _to_iu(-1.0, -2.0) == (-10_000, -20_000) + + # --- _parse_wires --- + + def test_parse_wires_single_wire(self) -> None: + sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) + result = _parse_wires(sch) + assert len(result) == 1 + assert result[0] == [(0, 0), (10_000, 0)] + + def test_parse_wires_empty_schematic(self) -> None: + sch = MagicMock() + sch.wire = [] + assert _parse_wires(sch) == [] + + def test_parse_wires_multiple_wires(self) -> None: + sch = _make_schematic( + _make_wire(0.0, 0.0, 1.0, 0.0), + _make_wire(1.0, 0.0, 2.0, 0.0), + ) + assert len(_parse_wires(sch)) == 2 + + def test_parse_wires_skips_wire_without_pts(self) -> None: + bad_wire = MagicMock(spec=[]) # no `pts` attribute + sch = MagicMock() + sch.wire = [bad_wire] + assert _parse_wires(sch) == [] + + # --- _build_adjacency --- + + def test_build_adjacency_two_connected_wires(self) -> None: + # wire0: (0,0)-(1,0), wire1: (1,0)-(2,0) — share endpoint (1,0) + wires = [ + [(0, 0), (10_000, 0)], + [(10_000, 0), (20_000, 0)], + ] + adjacency, iu_to_wires = _build_adjacency(wires) + assert 1 in adjacency[0] + assert 0 in adjacency[1] + + def test_build_adjacency_two_disconnected_wires(self) -> None: + wires = [ + [(0, 0), (10_000, 0)], + [(20_000, 0), (30_000, 0)], + ] + adjacency, _ = _build_adjacency(wires) + assert adjacency[0] == set() + assert adjacency[1] == set() + + def test_build_adjacency_iu_to_wires_maps_correctly(self) -> None: + wires = [ + [(0, 0), (10_000, 0)], + [(10_000, 0), (20_000, 0)], + ] + _, iu_to_wires = _build_adjacency(wires) + assert iu_to_wires[(10_000, 0)] == {0, 1} + assert iu_to_wires[(0, 0)] == {0} + + def test_build_adjacency_three_wires_at_junction(self) -> None: + # All three wires meet at (10,000, 0) + wires = [ + [(0, 0), (10_000, 0)], + [(10_000, 0), (20_000, 0)], + [(10_000, 0), (10_000, 10_000)], + ] + adjacency, _ = _build_adjacency(wires) + assert adjacency[0] == {1, 2} + assert adjacency[1] == {0, 2} + assert adjacency[2] == {0, 1} + + # --- _find_connected_wires --- + + def test_find_connected_wires_no_wire_at_point(self) -> None: + wires = [[(0, 0), (10_000, 0)]] + adjacency, iu_to_wires = _build_adjacency(wires) + visited, net_points = _find_connected_wires(5.0, 0.0, wires, iu_to_wires, adjacency) + assert visited is None + assert net_points is None + + def test_find_connected_wires_single_wire(self) -> None: + wires = [[(0, 0), (10_000, 0)]] + adjacency, iu_to_wires = _build_adjacency(wires) + visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) + assert visited == {0} + assert (0, 0) in net_points + assert (10_000, 0) in net_points + + def test_find_connected_wires_flood_fills_chain(self) -> None: + # Three wires in a chain: A-B-C-D + wires = [ + [(0, 0), (10_000, 0)], + [(10_000, 0), (20_000, 0)], + [(20_000, 0), (30_000, 0)], + ] + adjacency, iu_to_wires = _build_adjacency(wires) + visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) + assert visited == {0, 1, 2} + + def test_find_connected_wires_does_not_cross_gap(self) -> None: + # Two disconnected segments; query on segment 0 should not reach segment 1 + wires = [ + [(0, 0), (10_000, 0)], + [(20_000, 0), (30_000, 0)], + ] + adjacency, iu_to_wires = _build_adjacency(wires) + visited, _ = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) + assert visited == {0} + + # --- get_wire_connections (integration of internal functions) --- + + def test_get_wire_connections_no_wires(self) -> None: + sch = MagicMock() + sch.wire = [] + result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) + assert result == {"pins": [], "wires": []} + + def test_get_wire_connections_no_wire_at_point_returns_none(self) -> None: + sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) + result = get_wire_connections(sch, "/fake/path.kicad_sch", 5.0, 0.0) + assert result is None + + def test_get_wire_connections_returns_wire_data(self) -> None: + sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) + # Prevent _find_pins_on_net from iterating symbols + result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) + assert result is not None + assert result["pins"] == [] + assert len(result["wires"]) == 1 + wire = result["wires"][0] + assert wire["start"] == {"x": 0.0, "y": 0.0} + assert wire["end"] == {"x": 1.0, "y": 0.0} + + def test_get_wire_connections_chain_returns_all_wires(self) -> None: + sch = _make_schematic( + _make_wire(0.0, 0.0, 1.0, 0.0), + _make_wire(1.0, 0.0, 2.0, 0.0), + ) + result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) + assert result is not None + assert len(result["wires"]) == 2 diff --git a/python/tests/test_wire_junction_changes.py b/python/tests/test_wire_junction_changes.py index 01bda02..063a4fd 100644 --- a/python/tests/test_wire_junction_changes.py +++ b/python/tests/test_wire_junction_changes.py @@ -1,1009 +1,1010 @@ -""" -Tests for fix/tool-schema-descriptions branch changes: -- add_schematic_wire: waypoints param, pin snapping, polyline routing -- add_schematic_junction: new tool replacing add_schematic_connection -- Schema updates in tool_schemas.py -- ConnectionManager orphaned method removal -""" - -import shutil -import sys -import tempfile -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -import sexpdata -from sexpdata import Symbol - -# Add python dir to path -PYTHON_DIR = Path(__file__).parent.parent -sys.path.insert(0, str(PYTHON_DIR)) - -TEMPLATES_DIR = PYTHON_DIR / "templates" -EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch" - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_temp_sch(): - """Copy the empty schematic template to a temp file and return the Path.""" - tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" - shutil.copy(EMPTY_SCH, tmp) - return tmp - - -def _parse_sch(path: Path): - """Parse a .kicad_sch file and return the S-expression list.""" - with open(path, "r", encoding="utf-8") as f: - return sexpdata.loads(f.read()) - - -def _find_elements(sch_data, tag: str): - """Return all top-level S-expression elements with the given tag Symbol.""" - return [item for item in sch_data if isinstance(item, list) and item and item[0] == Symbol(tag)] - - -# --------------------------------------------------------------------------- -# 1. Schema tests -# --------------------------------------------------------------------------- - - -class TestSchemas: - """Verify tool_schemas.py reflects the new API.""" - - @pytest.fixture(autouse=True) - def load_schemas(self): - from schemas.tool_schemas import SCHEMATIC_TOOLS - - self.tools = {t["name"]: t for t in SCHEMATIC_TOOLS} - - def test_add_schematic_wire_has_waypoints(self): - schema = self.tools["add_schematic_wire"]["inputSchema"] - assert "waypoints" in schema["properties"], "waypoints must be a property" - assert "waypoints" in schema["required"] - - def test_add_schematic_wire_has_schematic_path(self): - schema = self.tools["add_schematic_wire"]["inputSchema"] - assert "schematicPath" in schema["properties"] - assert "schematicPath" in schema["required"] - - def test_add_schematic_wire_has_snap_params(self): - schema = self.tools["add_schematic_wire"]["inputSchema"] - props = schema["properties"] - assert "snapToPins" in props - assert props["snapToPins"]["type"] == "boolean" - assert "snapTolerance" in props - assert props["snapTolerance"]["type"] == "number" - - def test_add_schematic_wire_no_old_point_params(self): - schema = self.tools["add_schematic_wire"]["inputSchema"] - props = schema["properties"] - assert "startPoint" not in props, "startPoint should be removed" - assert "endPoint" not in props, "endPoint should be removed" - - def test_add_schematic_connection_removed(self): - assert ( - "add_schematic_connection" not in self.tools - ), "add_schematic_connection must not appear in SCHEMATIC_TOOLS" - - def test_add_schematic_junction_present(self): - assert "add_schematic_junction" in self.tools - - def test_add_schematic_junction_schema(self): - schema = self.tools["add_schematic_junction"]["inputSchema"] - props = schema["properties"] - assert "schematicPath" in props - assert "position" in props - assert set(schema["required"]) >= {"schematicPath", "position"} - - def test_add_schematic_junction_position_is_array(self): - schema = self.tools["add_schematic_junction"]["inputSchema"] - pos = schema["properties"]["position"] - assert pos["type"] == "array" - assert pos.get("minItems") == 2 - assert pos.get("maxItems") == 2 - - -# --------------------------------------------------------------------------- -# 2. Handler dispatch tests -# --------------------------------------------------------------------------- - - -class TestHandlerDispatch: - """Verify KiCADInterface registers the right tool handlers.""" - - @pytest.fixture(autouse=True) - def load_handler_map(self): - # Import only the dispatch table without initialising KiCAD connections - import importlib - import types - - # Patch heavy imports before loading kicad_interface - for mod in ["pcbnew", "skip"]: - sys.modules.setdefault(mod, types.ModuleType(mod)) - - from kicad_interface import KiCADInterface - - # Peek at the dispatch table by instantiating with mocked internals - with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): - obj = KiCADInterface.__new__(KiCADInterface) - # Manually set attributes that __init__ normally provides - obj._backend = None - # Build the handler map the same way the real __init__ does - obj._tool_handlers = { - "add_schematic_wire": obj._handle_add_schematic_wire, - "add_schematic_junction": obj._handle_add_schematic_junction, - "add_schematic_net_label": obj._handle_add_schematic_net_label, - } - self.handlers = obj._tool_handlers - - def test_add_schematic_wire_registered(self): - from kicad_interface import KiCADInterface - - # Just verify the class has the handler method - assert hasattr(KiCADInterface, "_handle_add_schematic_wire") - - def test_add_schematic_junction_registered(self): - from kicad_interface import KiCADInterface - - assert hasattr(KiCADInterface, "_handle_add_schematic_junction") - - def test_add_schematic_connection_not_present(self): - from kicad_interface import KiCADInterface - - assert not hasattr( - KiCADInterface, "_handle_add_schematic_connection" - ), "_handle_add_schematic_connection should be removed" - - -# --------------------------------------------------------------------------- -# 3. _handle_add_schematic_wire — parameter validation -# --------------------------------------------------------------------------- - - -class TestHandleAddSchematicWireValidation: - """Unit tests for _handle_add_schematic_wire validation paths (no disk I/O).""" - - @pytest.fixture(autouse=True) - def handler(self): - import types - - for mod in ["pcbnew", "skip"]: - sys.modules.setdefault(mod, types.ModuleType(mod)) - from kicad_interface import KiCADInterface - - with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): - self.iface = KiCADInterface.__new__(KiCADInterface) - - def test_missing_schematic_path(self): - result = self.iface._handle_add_schematic_wire({"waypoints": [[0, 0], [10, 0]]}) - assert result["success"] is False - assert "Schematic path" in result["message"] - - def test_missing_waypoints(self): - result = self.iface._handle_add_schematic_wire({"schematicPath": "/tmp/x.kicad_sch"}) - assert result["success"] is False - assert "waypoint" in result["message"].lower() - - def test_single_waypoint_rejected(self): - result = self.iface._handle_add_schematic_wire( - { - "schematicPath": "/tmp/x.kicad_sch", - "waypoints": [[0, 0]], - } - ) - assert result["success"] is False - assert "waypoint" in result["message"].lower() - - -# --------------------------------------------------------------------------- -# 4. _handle_add_schematic_wire — wire routing logic -# --------------------------------------------------------------------------- - - -class TestHandleAddSchematicWireRouting: - """Unit tests verifying add_wire vs add_polyline_wire dispatch, no pin snapping.""" - - @pytest.fixture(autouse=True) - def setup(self): - import types - - for mod in ["pcbnew", "skip"]: - sys.modules.setdefault(mod, types.ModuleType(mod)) - from kicad_interface import KiCADInterface - - with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): - self.iface = KiCADInterface.__new__(KiCADInterface) - self.sch_path = _make_temp_sch() - yield - # cleanup - shutil.rmtree(self.sch_path.parent, ignore_errors=True) - - @patch("commands.wire_manager.WireManager.add_wire", return_value=True) - def test_two_waypoints_calls_add_wire(self, mock_add_wire): - result = self.iface._handle_add_schematic_wire( - { - "schematicPath": str(self.sch_path), - "waypoints": [[10.0, 20.0], [30.0, 20.0]], - "snapToPins": False, - } - ) - assert result["success"] is True - mock_add_wire.assert_called_once() - args = mock_add_wire.call_args[0] - assert args[1] == [10.0, 20.0] - assert args[2] == [30.0, 20.0] - - @patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) - def test_four_waypoints_calls_add_polyline_wire(self, mock_poly): - result = self.iface._handle_add_schematic_wire( - { - "schematicPath": str(self.sch_path), - "waypoints": [[0, 0], [10, 0], [10, 10], [20, 10]], - "snapToPins": False, - } - ) - assert result["success"] is True - mock_poly.assert_called_once() - - def test_points_key_without_waypoints_is_rejected(self): - """'points' key alone (without 'waypoints') is rejected — no fallback.""" - result = self.iface._handle_add_schematic_wire( - { - "schematicPath": str(self.sch_path), - "points": [[5.0, 5.0], [15.0, 5.0]], - "snapToPins": False, - } - ) - assert result["success"] is False - assert "waypoint" in result["message"].lower() - - @patch("commands.wire_manager.WireManager.add_wire", return_value=False) - def test_failure_response(self, _): - result = self.iface._handle_add_schematic_wire( - { - "schematicPath": str(self.sch_path), - "waypoints": [[0, 0], [10, 0]], - "snapToPins": False, - } - ) - assert result["success"] is False - - -# --------------------------------------------------------------------------- -# 5. _handle_add_schematic_wire — pin snapping -# --------------------------------------------------------------------------- - - -class TestPinSnapping: - """Verify pin snapping logic snaps endpoints correctly.""" - - @pytest.fixture(autouse=True) - def setup(self): - import types - - # Provide a minimal skip.Schematic stub so the handler can import it - skip_mod = types.ModuleType("skip") - - class FakeSchematic: - def __init__(self, path): - pass - - @property - def symbol(self): - return [] # no symbols → no pins in snapping loop - - skip_mod.Schematic = FakeSchematic - sys.modules["skip"] = skip_mod - sys.modules.setdefault("pcbnew", types.ModuleType("pcbnew")) - - from kicad_interface import KiCADInterface - - with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): - self.iface = KiCADInterface.__new__(KiCADInterface) - - self.sch_path = _make_temp_sch() - yield - shutil.rmtree(self.sch_path.parent, ignore_errors=True) - - @patch("commands.wire_manager.WireManager.add_wire", return_value=True) - @patch("commands.pin_locator.PinLocator.get_all_symbol_pins") - def test_start_point_snapped_within_tolerance(self, mock_pins, mock_wire): - """First waypoint within tolerance of a pin should be snapped to pin coords.""" - # get_all_symbol_pins won't be called because symbol list is empty in fixture. - # Instead we patch find_nearest_pin indirectly by providing all_pins via the - # skip.Schematic stub that returns one symbol with a known pin. - import types - - skip_mod = sys.modules["skip"] - - class FakeSymbol: - class property: - class Reference: - value = "R1" - - def __init__(self): - pass - - skip_mod.Schematic = lambda path: type("FakeSch", (), {"symbol": [FakeSymbol()]})() - - mock_pins.return_value = {"1": [10.0, 20.0], "2": [10.0, 30.0]} - - # Re-import so the patched skip.Schematic is used - import importlib - - import kicad_interface - - importlib.reload(kicad_interface) - from kicad_interface import KiCADInterface - - with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): - iface = KiCADInterface.__new__(KiCADInterface) - - with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw: - result = iface._handle_add_schematic_wire( - { - "schematicPath": str(self.sch_path), - "waypoints": [[10.05, 20.05], [50.0, 20.0]], - "snapToPins": True, - "snapTolerance": 1.0, - } - ) - if result["success"]: - called_start = mw.call_args[0][1] - assert called_start == [ - 10.0, - 20.0, - ], f"Start should snap to [10.0, 20.0], got {called_start}" - # If it failed due to stub issues, just verify no exception - - def test_snap_disabled_passes_original_coords(self): - """With snapToPins=False the handler should not load PinLocator at all.""" - with ( - patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw, - patch("commands.pin_locator.PinLocator") as mock_locator_cls, - ): - result = self.iface._handle_add_schematic_wire( - { - "schematicPath": str(self.sch_path), - "waypoints": [[1.0, 2.0], [3.0, 4.0]], - "snapToPins": False, - } - ) - mock_locator_cls.assert_not_called() - assert result["success"] is True - called_start = mw.call_args[0][1] - assert called_start == [1.0, 2.0] - - @patch("commands.wire_manager.WireManager.add_wire", return_value=True) - def test_snap_miss_leaves_coords_unchanged(self, mock_wire): - """Point beyond tolerance should not be snapped.""" - with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw: - result = self.iface._handle_add_schematic_wire( - { - "schematicPath": str(self.sch_path), - "waypoints": [[100.0, 100.0], [200.0, 100.0]], - "snapToPins": True, - "snapTolerance": 0.5, - # skip.Schematic returns no symbols (fixture), so no pins to snap to - } - ) - assert result["success"] is True - # No snapping info in message - assert "snapped" not in result.get("message", "") - - @patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) - def test_intermediate_waypoints_not_snapped(self, mock_poly): - """Middle waypoints must remain unchanged even with snapToPins=True.""" - mid = [50.0, 50.0] - with patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) as mp: - result = self.iface._handle_add_schematic_wire( - { - "schematicPath": str(self.sch_path), - "waypoints": [[100.0, 100.0], mid[:], [200.0, 100.0]], - "snapToPins": True, - "snapTolerance": 100.0, # huge tolerance, but mid must not snap - } - ) - assert result["success"] is True - called_points = mp.call_args[0][1] - assert ( - called_points[1] == mid - ), f"Middle waypoint should not be snapped, got {called_points[1]}" - - -# --------------------------------------------------------------------------- -# 6. _handle_add_schematic_junction — unit tests -# --------------------------------------------------------------------------- - - -class TestHandleAddSchematicJunction: - - @pytest.fixture(autouse=True) - def setup(self): - import types - - for mod in ["pcbnew", "skip"]: - sys.modules.setdefault(mod, types.ModuleType(mod)) - from kicad_interface import KiCADInterface - - with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): - self.iface = KiCADInterface.__new__(KiCADInterface) - - def test_missing_schematic_path(self): - result = self.iface._handle_add_schematic_junction({"position": [10.0, 20.0]}) - assert result["success"] is False - assert "Schematic path" in result["message"] - - def test_missing_position(self): - result = self.iface._handle_add_schematic_junction({"schematicPath": "/tmp/x.kicad_sch"}) - assert result["success"] is False - assert "Position" in result["message"] - - @patch("commands.wire_manager.WireManager.add_junction", return_value=True) - def test_success(self, mock_jct): - sch = _make_temp_sch() - try: - result = self.iface._handle_add_schematic_junction( - { - "schematicPath": str(sch), - "position": [25.4, 25.4], - } - ) - assert result["success"] is True - assert "Junction added" in result["message"] - mock_jct.assert_called_once_with(sch, [25.4, 25.4]) - finally: - shutil.rmtree(sch.parent, ignore_errors=True) - - @patch("commands.wire_manager.WireManager.add_junction", return_value=False) - def test_failure(self, _): - sch = _make_temp_sch() - try: - result = self.iface._handle_add_schematic_junction( - { - "schematicPath": str(sch), - "position": [25.4, 25.4], - } - ) - assert result["success"] is False - assert "Failed" in result["message"] - finally: - shutil.rmtree(sch.parent, ignore_errors=True) - - -# --------------------------------------------------------------------------- -# 7. ConnectionManager — orphaned methods removed -# --------------------------------------------------------------------------- - - -class TestConnectionManagerOrphanedMethodsRemoved: - - def test_add_wire_removed(self): - from commands.connection_schematic import ConnectionManager - - assert not hasattr( - ConnectionManager, "add_wire" - ), "ConnectionManager.add_wire should have been removed" - - def test_add_connection_removed(self): - from commands.connection_schematic import ConnectionManager - - assert not hasattr( - ConnectionManager, "add_connection" - ), "ConnectionManager.add_connection should have been removed" - - def test_get_pin_location_removed(self): - from commands.connection_schematic import ConnectionManager - - assert not hasattr( - ConnectionManager, "get_pin_location" - ), "ConnectionManager.get_pin_location should have been removed" - - -# --------------------------------------------------------------------------- -# 8. Integration tests — real disk I/O, no KiCAD process -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -class TestIntegrationWireManager: - """Integration tests using real schematic files and WireManager.""" - - @pytest.fixture(autouse=True) - def sch(self): - path = _make_temp_sch() - yield path - shutil.rmtree(path.parent, ignore_errors=True) - - def test_add_wire_writes_wire_element(self, sch): - from commands.wire_manager import WireManager - - ok = WireManager.add_wire(sch, [10.0, 10.0], [30.0, 10.0]) - assert ok is True - data = _parse_sch(sch) - wires = _find_elements(data, "wire") - assert len(wires) == 1 - - def test_add_polyline_wire_creates_segments(self, sch): - """N waypoints should produce N-1 individual 2-point wire segments.""" - from commands.wire_manager import WireManager - - pts = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [20.0, 10.0]] - ok = WireManager.add_polyline_wire(sch, pts) - assert ok is True - data = _parse_sch(sch) - wires = _find_elements(data, "wire") - assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}" - - def test_add_junction_writes_junction_element(self, sch): - from commands.wire_manager import WireManager - - ok = WireManager.add_junction(sch, [25.4, 25.4]) - assert ok is True - data = _parse_sch(sch) - junctions = _find_elements(data, "junction") - assert len(junctions) == 1 - # Verify position - at = junctions[0][1] # (at x y) - assert at[1] == 25.4 - assert at[2] == 25.4 - - def test_wire_endpoint_coordinates_match(self, sch): - from commands.wire_manager import WireManager - - WireManager.add_wire(sch, [5.0, 7.5], [15.0, 7.5]) - data = _parse_sch(sch) - wire = _find_elements(data, "wire")[0] - pts = [ - item for item in wire if isinstance(item, list) and item and item[0] == Symbol("pts") - ][0] - xy_entries = [ - item for item in pts if isinstance(item, list) and item and item[0] == Symbol("xy") - ] - assert xy_entries[0][1] == 5.0 - assert xy_entries[0][2] == 7.5 - assert xy_entries[1][1] == 15.0 - assert xy_entries[1][2] == 7.5 - - -@pytest.mark.integration -class TestIntegrationHandlerEndToEnd: - """Integration tests for KiCADInterface handlers writing to real schematic files.""" - - @pytest.fixture(autouse=True) - def setup(self): - import types - - for mod in ["pcbnew", "skip"]: - sys.modules.setdefault(mod, types.ModuleType(mod)) - from kicad_interface import KiCADInterface - - with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): - self.iface = KiCADInterface.__new__(KiCADInterface) - self.sch = _make_temp_sch() - yield - shutil.rmtree(self.sch.parent, ignore_errors=True) - - def test_junction_handler_writes_junction(self): - result = self.iface._handle_add_schematic_junction( - { - "schematicPath": str(self.sch), - "position": [50.8, 50.8], - } - ) - assert result["success"] is True - data = _parse_sch(self.sch) - junctions = _find_elements(data, "junction") - assert len(junctions) == 1 - - def test_wire_handler_two_points_writes_wire(self): - result = self.iface._handle_add_schematic_wire( - { - "schematicPath": str(self.sch), - "waypoints": [[10.0, 10.0], [30.0, 10.0]], - "snapToPins": False, - } - ) - assert result["success"] is True - data = _parse_sch(self.sch) - wires = _find_elements(data, "wire") - assert len(wires) == 1 - - def test_wire_handler_four_points_creates_three_segments(self): - result = self.iface._handle_add_schematic_wire( - { - "schematicPath": str(self.sch), - "waypoints": [[0, 0], [10, 0], [10, 10], [20, 10]], - "snapToPins": False, - } - ) - assert result["success"] is True - data = _parse_sch(self.sch) - wires = _find_elements(data, "wire") - assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}" - - -# --------------------------------------------------------------------------- -# 9. Unit tests — _point_strictly_on_wire -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestPointStrictlyOnWire: - """Unit tests for WireManager._point_strictly_on_wire geometry helper.""" - - @staticmethod - def _fn(px, py, x1, y1, x2, y2, eps=1e-6): - from commands.wire_manager import WireManager - - return WireManager._point_strictly_on_wire(px, py, x1, y1, x2, y2, eps) - - def test_horizontal_midpoint(self): - assert self._fn(5, 0, 0, 0, 10, 0) is True - - def test_vertical_midpoint(self): - assert self._fn(0, 5, 0, 0, 0, 10) is True - - def test_horizontal_at_start_endpoint(self): - """Point at wire start should NOT be strictly on wire.""" - assert self._fn(0, 0, 0, 0, 10, 0) is False - - def test_horizontal_at_end_endpoint(self): - """Point at wire end should NOT be strictly on wire.""" - assert self._fn(10, 0, 0, 0, 10, 0) is False - - def test_vertical_at_start_endpoint(self): - assert self._fn(0, 0, 0, 0, 0, 10) is False - - def test_vertical_at_end_endpoint(self): - assert self._fn(0, 10, 0, 0, 0, 10) is False - - def test_point_off_horizontal_wire(self): - """Point above a horizontal wire.""" - assert self._fn(5, 1, 0, 0, 10, 0) is False - - def test_point_off_vertical_wire(self): - """Point to the right of a vertical wire.""" - assert self._fn(1, 5, 0, 0, 0, 10) is False - - def test_point_beyond_horizontal_wire(self): - """Point collinear but past the end of a horizontal wire.""" - assert self._fn(15, 0, 0, 0, 10, 0) is False - - def test_point_beyond_vertical_wire(self): - """Point collinear but past the end of a vertical wire.""" - assert self._fn(0, 15, 0, 0, 0, 10) is False - - def test_diagonal_wire_always_false(self): - """Only horizontal/vertical wires are handled; diagonal → False.""" - assert self._fn(5, 5, 0, 0, 10, 10) is False - - def test_reversed_horizontal_endpoints(self): - """Wire endpoints reversed (x2 < x1) should still work.""" - assert self._fn(5, 0, 10, 0, 0, 0) is True - - def test_reversed_vertical_endpoints(self): - """Wire endpoints reversed (y2 < y1) should still work.""" - assert self._fn(0, 5, 0, 10, 0, 0) is True - - def test_near_endpoint_within_epsilon(self): - """Point within epsilon of endpoint should NOT be considered strictly on wire.""" - assert self._fn(1e-7, 0, 0, 0, 10, 0) is False - - def test_zero_length_wire(self): - """Degenerate wire with same start/end — nothing is strictly between.""" - assert self._fn(5, 5, 5, 5, 5, 5) is False - - -# --------------------------------------------------------------------------- -# 10. Unit tests — _parse_wire -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestParseWire: - """Unit tests for WireManager._parse_wire S-expression parser.""" - - @staticmethod - def _fn(item): - from commands.wire_manager import WireManager - - return WireManager._parse_wire(item) - - def test_valid_wire(self): - wire = [ - Symbol("wire"), - [Symbol("pts"), [Symbol("xy"), 10.0, 20.0], [Symbol("xy"), 30.0, 20.0]], - [ - Symbol("stroke"), - [Symbol("width"), 0], - [Symbol("type"), Symbol("default")], - ], - [Symbol("uuid"), "abc-123"], - ] - result = TestParseWire._fn(wire) - assert result is not None - start, end, width, stype = result - assert start == (10.0, 20.0) - assert end == (30.0, 20.0) - assert width == 0 - assert stype == "default" - - def test_non_wire_element_returns_none(self): - junction = [Symbol("junction"), [Symbol("at"), 10, 20]] - assert TestParseWire._fn(junction) is None - - def test_non_list_returns_none(self): - assert TestParseWire._fn("not a list") is None - - def test_empty_list_returns_none(self): - assert TestParseWire._fn([]) is None - - def test_wire_with_no_pts_returns_none(self): - wire = [Symbol("wire"), [Symbol("stroke"), [Symbol("width"), 0]]] - assert TestParseWire._fn(wire) is None - - def test_wire_with_only_one_xy_returns_none(self): - wire = [ - Symbol("wire"), - [Symbol("pts"), [Symbol("xy"), 10.0, 20.0]], - ] - assert TestParseWire._fn(wire) is None - - def test_wire_without_stroke_uses_defaults(self): - wire = [ - Symbol("wire"), - [Symbol("pts"), [Symbol("xy"), 0, 0], [Symbol("xy"), 10, 0]], - ] - result = TestParseWire._fn(wire) - assert result is not None - _, _, width, stype = result - assert width == 0 - assert stype == "default" - - -# --------------------------------------------------------------------------- -# 11. Unit tests — _make_wire_sexp -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestMakeWireSexp: - """Unit tests for WireManager._make_wire_sexp builder.""" - - def test_produces_valid_parseable_wire(self): - from commands.wire_manager import WireManager - - sexp = WireManager._make_wire_sexp([10, 20], [30, 20]) - parsed = WireManager._parse_wire(sexp) - assert parsed is not None - start, end, width, stype = parsed - assert start == (10, 20) - assert end == (30, 20) - assert width == 0 - assert stype == "default" - - def test_custom_stroke(self): - from commands.wire_manager import WireManager - - sexp = WireManager._make_wire_sexp([0, 0], [5, 0], stroke_width=0.5, stroke_type="dash") - parsed = WireManager._parse_wire(sexp) - assert parsed is not None - _, _, width, stype = parsed - assert width == 0.5 - assert stype == "dash" - - def test_has_uuid(self): - from commands.wire_manager import WireManager - - sexp = WireManager._make_wire_sexp([0, 0], [10, 0]) - # uuid is the last element - uuid_entry = sexp[-1] - assert uuid_entry[0] == Symbol("uuid") - assert isinstance(uuid_entry[1], str) and len(uuid_entry[1]) > 0 - - def test_two_calls_produce_different_uuids(self): - from commands.wire_manager import WireManager - - sexp1 = WireManager._make_wire_sexp([0, 0], [10, 0]) - sexp2 = WireManager._make_wire_sexp([0, 0], [10, 0]) - assert sexp1[-1][1] != sexp2[-1][1], "Each wire should have a unique UUID" - - -# --------------------------------------------------------------------------- -# 12. Unit tests — _break_wires_at_point -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestBreakWiresAtPoint: - """Unit tests for WireManager._break_wires_at_point T-junction logic.""" - - @staticmethod - def _make_sch_data_with_wires(wire_coords): - """Build a minimal sch_data list with wire elements and a sheet_instances marker.""" - from commands.wire_manager import WireManager - - data = [Symbol("kicad_sch")] - for start, end in wire_coords: - data.append(WireManager._make_wire_sexp(start, end)) - data.append([Symbol("sheet_instances")]) - return data - - def test_split_horizontal_wire_at_midpoint(self): - from commands.wire_manager import WireManager - - data = self._make_sch_data_with_wires([([0, 0], [20, 0])]) - splits = WireManager._break_wires_at_point(data, [10, 0]) - assert splits == 1 - wires = _find_elements(data, "wire") - assert len(wires) == 2 - # Verify the two segments share the split point - coords = [] - for w in wires: - parsed = WireManager._parse_wire(w) - coords.append((parsed[0], parsed[1])) - endpoints = {c for pair in coords for c in pair} - assert (10.0, 0.0) in endpoints - - def test_split_vertical_wire_at_midpoint(self): - from commands.wire_manager import WireManager - - data = self._make_sch_data_with_wires([([5, 0], [5, 30])]) - splits = WireManager._break_wires_at_point(data, [5, 15]) - assert splits == 1 - wires = _find_elements(data, "wire") - assert len(wires) == 2 - - def test_no_split_at_wire_endpoint(self): - """Point at existing endpoint should not trigger a split.""" - from commands.wire_manager import WireManager - - data = self._make_sch_data_with_wires([([0, 0], [20, 0])]) - splits = WireManager._break_wires_at_point(data, [0, 0]) - assert splits == 0 - wires = _find_elements(data, "wire") - assert len(wires) == 1 - - def test_no_split_point_not_on_wire(self): - from commands.wire_manager import WireManager - - data = self._make_sch_data_with_wires([([0, 0], [20, 0])]) - splits = WireManager._break_wires_at_point(data, [10, 5]) - assert splits == 0 - wires = _find_elements(data, "wire") - assert len(wires) == 1 - - def test_split_multiple_wires_at_same_point(self): - """Two crossing wires at (10, 10) — both should be split.""" - from commands.wire_manager import WireManager - - data = self._make_sch_data_with_wires( - [ - ([0, 10], [20, 10]), # horizontal through (10,10) - ([10, 0], [10, 20]), # vertical through (10,10) - ] - ) - splits = WireManager._break_wires_at_point(data, [10, 10]) - assert splits == 2 - wires = _find_elements(data, "wire") - assert len(wires) == 4 # each wire split into 2 - - def test_split_preserves_stroke_properties(self): - from commands.wire_manager import WireManager - - data = [Symbol("kicad_sch")] - data.append( - WireManager._make_wire_sexp([0, 0], [20, 0], stroke_width=0.5, stroke_type="dash") - ) - data.append([Symbol("sheet_instances")]) - splits = WireManager._break_wires_at_point(data, [10, 0]) - assert splits == 1 - wires = _find_elements(data, "wire") - for w in wires: - parsed = WireManager._parse_wire(w) - assert parsed[2] == 0.5, "stroke_width should be preserved" - assert parsed[3] == "dash", "stroke_type should be preserved" - - def test_no_split_on_diagonal_wire(self): - """Diagonal wires are not handled by _point_strictly_on_wire → no split.""" - from commands.wire_manager import WireManager - - data = self._make_sch_data_with_wires([([0, 0], [10, 10])]) - splits = WireManager._break_wires_at_point(data, [5, 5]) - assert splits == 0 - - def test_empty_sch_data(self): - from commands.wire_manager import WireManager - - data = [Symbol("kicad_sch"), [Symbol("sheet_instances")]] - splits = WireManager._break_wires_at_point(data, [10, 10]) - assert splits == 0 - - -# --------------------------------------------------------------------------- -# 13. Integration tests — T-junction wire breaking -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -class TestIntegrationTJunction: - """Integration tests for T-junction wire breaking during add_wire/add_junction.""" - - @pytest.fixture(autouse=True) - def sch(self): - path = _make_temp_sch() - yield path - shutil.rmtree(path.parent, ignore_errors=True) - - def test_add_wire_breaks_existing_horizontal_wire(self, sch): - """Adding a vertical wire whose endpoint is mid-horizontal-wire should split it.""" - from commands.wire_manager import WireManager - - # First add a horizontal wire (0,10) -> (20,10) - WireManager.add_wire(sch, [0, 10], [20, 10]) - # Now add a vertical wire ending at (10,10) — the midpoint of the horizontal wire - WireManager.add_wire(sch, [10, 0], [10, 10]) - data = _parse_sch(sch) - wires = _find_elements(data, "wire") - # Original horizontal wire should be split into 2, plus the new vertical = 3 total - assert len(wires) == 3, f"Expected 3 wires (split + new), got {len(wires)}" - - def test_add_wire_does_not_break_at_shared_endpoint(self, sch): - """Wire connecting at an existing endpoint should not trigger a split.""" - from commands.wire_manager import WireManager - - WireManager.add_wire(sch, [0, 0], [10, 0]) - # New wire starts at (10,0) — existing endpoint, not midpoint - WireManager.add_wire(sch, [10, 0], [10, 10]) - data = _parse_sch(sch) - wires = _find_elements(data, "wire") - assert len(wires) == 2, f"Expected 2 wires (no split), got {len(wires)}" - - def test_add_junction_breaks_wire(self, sch): - """Adding a junction mid-wire should split that wire.""" - from commands.wire_manager import WireManager - - WireManager.add_wire(sch, [0, 0], [30, 0]) - WireManager.add_junction(sch, [15, 0]) - data = _parse_sch(sch) - wires = _find_elements(data, "wire") - assert len(wires) == 2, f"Expected 2 wires after junction split, got {len(wires)}" - junctions = _find_elements(data, "junction") - assert len(junctions) == 1 - - def test_add_junction_at_wire_endpoint_no_split(self, sch): - """Junction at wire endpoint should not split it.""" - from commands.wire_manager import WireManager - - WireManager.add_wire(sch, [0, 0], [20, 0]) - WireManager.add_junction(sch, [20, 0]) - data = _parse_sch(sch) - wires = _find_elements(data, "wire") - assert len(wires) == 1, f"Expected 1 wire (no split at endpoint), got {len(wires)}" - - def test_polyline_breaks_existing_wire(self, sch): - """Polyline whose start/end hits mid-wire should break it.""" - from commands.wire_manager import WireManager - - WireManager.add_wire(sch, [0, 10], [20, 10]) - # Polyline starting at (10,10) — mid-horizontal-wire - WireManager.add_polyline_wire(sch, [[10, 10], [10, 20], [20, 20]]) - data = _parse_sch(sch) - wires = _find_elements(data, "wire") - # 2 from split + 2 polyline segments = 4 - assert len(wires) == 4, f"Expected 4 wires, got {len(wires)}" - - def test_polyline_two_points_same_as_add_wire(self, sch): - """Polyline with exactly 2 points should produce 1 wire segment.""" - from commands.wire_manager import WireManager - - WireManager.add_polyline_wire(sch, [[0, 0], [10, 0]]) - data = _parse_sch(sch) - wires = _find_elements(data, "wire") - assert len(wires) == 1 +""" +Tests for fix/tool-schema-descriptions branch changes: +- add_schematic_wire: waypoints param, pin snapping, polyline routing +- add_schematic_junction: new tool replacing add_schematic_connection +- Schema updates in tool_schemas.py +- ConnectionManager orphaned method removal +""" + +import shutil +import sys +import tempfile +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +import sexpdata +from sexpdata import Symbol + +# Add python dir to path +PYTHON_DIR = Path(__file__).parent.parent +sys.path.insert(0, str(PYTHON_DIR)) + +TEMPLATES_DIR = PYTHON_DIR / "templates" +EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_temp_sch() -> Any: + """Copy the empty schematic template to a temp file and return the Path.""" + tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" + shutil.copy(EMPTY_SCH, tmp) + return tmp + + +def _parse_sch(path: Path) -> Any: + """Parse a .kicad_sch file and return the S-expression list.""" + with open(path, "r", encoding="utf-8") as f: + return sexpdata.loads(f.read()) + + +def _find_elements(sch_data: Any, tag: str) -> Any: + """Return all top-level S-expression elements with the given tag Symbol.""" + return [item for item in sch_data if isinstance(item, list) and item and item[0] == Symbol(tag)] + + +# --------------------------------------------------------------------------- +# 1. Schema tests +# --------------------------------------------------------------------------- + + +class TestSchemas: + """Verify tool_schemas.py reflects the new API.""" + + @pytest.fixture(autouse=True) + def load_schemas(self) -> Any: + from schemas.tool_schemas import SCHEMATIC_TOOLS + + self.tools = {t["name"]: t for t in SCHEMATIC_TOOLS} + + def test_add_schematic_wire_has_waypoints(self) -> None: + schema = self.tools["add_schematic_wire"]["inputSchema"] + assert "waypoints" in schema["properties"], "waypoints must be a property" + assert "waypoints" in schema["required"] + + def test_add_schematic_wire_has_schematic_path(self) -> None: + schema = self.tools["add_schematic_wire"]["inputSchema"] + assert "schematicPath" in schema["properties"] + assert "schematicPath" in schema["required"] + + def test_add_schematic_wire_has_snap_params(self) -> None: + schema = self.tools["add_schematic_wire"]["inputSchema"] + props = schema["properties"] + assert "snapToPins" in props + assert props["snapToPins"]["type"] == "boolean" + assert "snapTolerance" in props + assert props["snapTolerance"]["type"] == "number" + + def test_add_schematic_wire_no_old_point_params(self) -> None: + schema = self.tools["add_schematic_wire"]["inputSchema"] + props = schema["properties"] + assert "startPoint" not in props, "startPoint should be removed" + assert "endPoint" not in props, "endPoint should be removed" + + def test_add_schematic_connection_removed(self) -> None: + assert ( + "add_schematic_connection" not in self.tools + ), "add_schematic_connection must not appear in SCHEMATIC_TOOLS" + + def test_add_schematic_junction_present(self) -> None: + assert "add_schematic_junction" in self.tools + + def test_add_schematic_junction_schema(self) -> None: + schema = self.tools["add_schematic_junction"]["inputSchema"] + props = schema["properties"] + assert "schematicPath" in props + assert "position" in props + assert set(schema["required"]) >= {"schematicPath", "position"} + + def test_add_schematic_junction_position_is_array(self) -> None: + schema = self.tools["add_schematic_junction"]["inputSchema"] + pos = schema["properties"]["position"] + assert pos["type"] == "array" + assert pos.get("minItems") == 2 + assert pos.get("maxItems") == 2 + + +# --------------------------------------------------------------------------- +# 2. Handler dispatch tests +# --------------------------------------------------------------------------- + + +class TestHandlerDispatch: + """Verify KiCADInterface registers the right tool handlers.""" + + @pytest.fixture(autouse=True) + def load_handler_map(self) -> Any: + # Import only the dispatch table without initialising KiCAD connections + import importlib + import types + + # Patch heavy imports before loading kicad_interface + for mod in ["pcbnew", "skip"]: + sys.modules.setdefault(mod, types.ModuleType(mod)) + + from kicad_interface import KiCADInterface + + # Peek at the dispatch table by instantiating with mocked internals + with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): + obj = KiCADInterface.__new__(KiCADInterface) + # Manually set attributes that __init__ normally provides + obj._backend = None + # Build the handler map the same way the real __init__ does + obj._tool_handlers = { + "add_schematic_wire": obj._handle_add_schematic_wire, + "add_schematic_junction": obj._handle_add_schematic_junction, + "add_schematic_net_label": obj._handle_add_schematic_net_label, + } + self.handlers = obj._tool_handlers + + def test_add_schematic_wire_registered(self) -> None: + from kicad_interface import KiCADInterface + + # Just verify the class has the handler method + assert hasattr(KiCADInterface, "_handle_add_schematic_wire") + + def test_add_schematic_junction_registered(self) -> None: + from kicad_interface import KiCADInterface + + assert hasattr(KiCADInterface, "_handle_add_schematic_junction") + + def test_add_schematic_connection_not_present(self) -> None: + from kicad_interface import KiCADInterface + + assert not hasattr( + KiCADInterface, "_handle_add_schematic_connection" + ), "_handle_add_schematic_connection should be removed" + + +# --------------------------------------------------------------------------- +# 3. _handle_add_schematic_wire — parameter validation +# --------------------------------------------------------------------------- + + +class TestHandleAddSchematicWireValidation: + """Unit tests for _handle_add_schematic_wire validation paths (no disk I/O).""" + + @pytest.fixture(autouse=True) + def handler(self) -> Any: + import types + + for mod in ["pcbnew", "skip"]: + sys.modules.setdefault(mod, types.ModuleType(mod)) + from kicad_interface import KiCADInterface + + with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): + self.iface = KiCADInterface.__new__(KiCADInterface) + + def test_missing_schematic_path(self) -> None: + result = self.iface._handle_add_schematic_wire({"waypoints": [[0, 0], [10, 0]]}) + assert result["success"] is False + assert "Schematic path" in result["message"] + + def test_missing_waypoints(self) -> None: + result = self.iface._handle_add_schematic_wire({"schematicPath": "/tmp/x.kicad_sch"}) + assert result["success"] is False + assert "waypoint" in result["message"].lower() + + def test_single_waypoint_rejected(self) -> None: + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": "/tmp/x.kicad_sch", + "waypoints": [[0, 0]], + } + ) + assert result["success"] is False + assert "waypoint" in result["message"].lower() + + +# --------------------------------------------------------------------------- +# 4. _handle_add_schematic_wire — wire routing logic +# --------------------------------------------------------------------------- + + +class TestHandleAddSchematicWireRouting: + """Unit tests verifying add_wire vs add_polyline_wire dispatch, no pin snapping.""" + + @pytest.fixture(autouse=True) + def setup(self) -> Any: + import types + + for mod in ["pcbnew", "skip"]: + sys.modules.setdefault(mod, types.ModuleType(mod)) + from kicad_interface import KiCADInterface + + with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): + self.iface = KiCADInterface.__new__(KiCADInterface) + self.sch_path = _make_temp_sch() + yield + # cleanup + shutil.rmtree(self.sch_path.parent, ignore_errors=True) + + @patch("commands.wire_manager.WireManager.add_wire", return_value=True) + def test_two_waypoints_calls_add_wire(self, mock_add_wire: Any) -> None: + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "waypoints": [[10.0, 20.0], [30.0, 20.0]], + "snapToPins": False, + } + ) + assert result["success"] is True + mock_add_wire.assert_called_once() + args = mock_add_wire.call_args[0] + assert args[1] == [10.0, 20.0] + assert args[2] == [30.0, 20.0] + + @patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) + def test_four_waypoints_calls_add_polyline_wire(self, mock_poly: Any) -> None: + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "waypoints": [[0, 0], [10, 0], [10, 10], [20, 10]], + "snapToPins": False, + } + ) + assert result["success"] is True + mock_poly.assert_called_once() + + def test_points_key_without_waypoints_is_rejected(self) -> None: + """'points' key alone (without 'waypoints') is rejected — no fallback.""" + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "points": [[5.0, 5.0], [15.0, 5.0]], + "snapToPins": False, + } + ) + assert result["success"] is False + assert "waypoint" in result["message"].lower() + + @patch("commands.wire_manager.WireManager.add_wire", return_value=False) + def test_failure_response(self, _: Any) -> None: + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "waypoints": [[0, 0], [10, 0]], + "snapToPins": False, + } + ) + assert result["success"] is False + + +# --------------------------------------------------------------------------- +# 5. _handle_add_schematic_wire — pin snapping +# --------------------------------------------------------------------------- + + +class TestPinSnapping: + """Verify pin snapping logic snaps endpoints correctly.""" + + @pytest.fixture(autouse=True) + def setup(self) -> Any: + import types + + # Provide a minimal skip.Schematic stub so the handler can import it + skip_mod = types.ModuleType("skip") + + class FakeSchematic: + def __init__(self, path: Any) -> None: + pass + + @property + def symbol(self) -> list[Any]: + return [] # no symbols → no pins in snapping loop + + skip_mod.Schematic = FakeSchematic + sys.modules["skip"] = skip_mod + sys.modules.setdefault("pcbnew", types.ModuleType("pcbnew")) + + from kicad_interface import KiCADInterface + + with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): + self.iface = KiCADInterface.__new__(KiCADInterface) + + self.sch_path = _make_temp_sch() + yield + shutil.rmtree(self.sch_path.parent, ignore_errors=True) + + @patch("commands.wire_manager.WireManager.add_wire", return_value=True) + @patch("commands.pin_locator.PinLocator.get_all_symbol_pins") + def test_start_point_snapped_within_tolerance(self, mock_pins: Any, mock_wire: Any) -> None: + """First waypoint within tolerance of a pin should be snapped to pin coords.""" + # get_all_symbol_pins won't be called because symbol list is empty in fixture. + # Instead we patch find_nearest_pin indirectly by providing all_pins via the + # skip.Schematic stub that returns one symbol with a known pin. + import types + + skip_mod = sys.modules["skip"] + + class FakeSymbol: + class property: + class Reference: + value = "R1" + + def __init__(self) -> None: + pass + + skip_mod.Schematic = lambda path: type("FakeSch", (), {"symbol": [FakeSymbol()]})() + + mock_pins.return_value = {"1": [10.0, 20.0], "2": [10.0, 30.0]} + + # Re-import so the patched skip.Schematic is used + import importlib + + import kicad_interface + + importlib.reload(kicad_interface) + from kicad_interface import KiCADInterface + + with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): + iface = KiCADInterface.__new__(KiCADInterface) + + with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw: + result = iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "waypoints": [[10.05, 20.05], [50.0, 20.0]], + "snapToPins": True, + "snapTolerance": 1.0, + } + ) + if result["success"]: + called_start = mw.call_args[0][1] + assert called_start == [ + 10.0, + 20.0, + ], f"Start should snap to [10.0, 20.0], got {called_start}" + # If it failed due to stub issues, just verify no exception + + def test_snap_disabled_passes_original_coords(self) -> None: + """With snapToPins=False the handler should not load PinLocator at all.""" + with ( + patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw, + patch("commands.pin_locator.PinLocator") as mock_locator_cls, + ): + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "waypoints": [[1.0, 2.0], [3.0, 4.0]], + "snapToPins": False, + } + ) + mock_locator_cls.assert_not_called() + assert result["success"] is True + called_start = mw.call_args[0][1] + assert called_start == [1.0, 2.0] + + @patch("commands.wire_manager.WireManager.add_wire", return_value=True) + def test_snap_miss_leaves_coords_unchanged(self, mock_wire: Any) -> None: + """Point beyond tolerance should not be snapped.""" + with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw: + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "waypoints": [[100.0, 100.0], [200.0, 100.0]], + "snapToPins": True, + "snapTolerance": 0.5, + # skip.Schematic returns no symbols (fixture), so no pins to snap to + } + ) + assert result["success"] is True + # No snapping info in message + assert "snapped" not in result.get("message", "") + + @patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) + def test_intermediate_waypoints_not_snapped(self, mock_poly: Any) -> None: + """Middle waypoints must remain unchanged even with snapToPins=True.""" + mid = [50.0, 50.0] + with patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) as mp: + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "waypoints": [[100.0, 100.0], mid[:], [200.0, 100.0]], + "snapToPins": True, + "snapTolerance": 100.0, # huge tolerance, but mid must not snap + } + ) + assert result["success"] is True + called_points = mp.call_args[0][1] + assert ( + called_points[1] == mid + ), f"Middle waypoint should not be snapped, got {called_points[1]}" + + +# --------------------------------------------------------------------------- +# 6. _handle_add_schematic_junction — unit tests +# --------------------------------------------------------------------------- + + +class TestHandleAddSchematicJunction: + + @pytest.fixture(autouse=True) + def setup(self) -> Any: + import types + + for mod in ["pcbnew", "skip"]: + sys.modules.setdefault(mod, types.ModuleType(mod)) + from kicad_interface import KiCADInterface + + with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): + self.iface = KiCADInterface.__new__(KiCADInterface) + + def test_missing_schematic_path(self) -> None: + result = self.iface._handle_add_schematic_junction({"position": [10.0, 20.0]}) + assert result["success"] is False + assert "Schematic path" in result["message"] + + def test_missing_position(self) -> None: + result = self.iface._handle_add_schematic_junction({"schematicPath": "/tmp/x.kicad_sch"}) + assert result["success"] is False + assert "Position" in result["message"] + + @patch("commands.wire_manager.WireManager.add_junction", return_value=True) + def test_success(self, mock_jct: Any) -> None: + sch = _make_temp_sch() + try: + result = self.iface._handle_add_schematic_junction( + { + "schematicPath": str(sch), + "position": [25.4, 25.4], + } + ) + assert result["success"] is True + assert "Junction added" in result["message"] + mock_jct.assert_called_once_with(sch, [25.4, 25.4]) + finally: + shutil.rmtree(sch.parent, ignore_errors=True) + + @patch("commands.wire_manager.WireManager.add_junction", return_value=False) + def test_failure(self, _: Any) -> None: + sch = _make_temp_sch() + try: + result = self.iface._handle_add_schematic_junction( + { + "schematicPath": str(sch), + "position": [25.4, 25.4], + } + ) + assert result["success"] is False + assert "Failed" in result["message"] + finally: + shutil.rmtree(sch.parent, ignore_errors=True) + + +# --------------------------------------------------------------------------- +# 7. ConnectionManager — orphaned methods removed +# --------------------------------------------------------------------------- + + +class TestConnectionManagerOrphanedMethodsRemoved: + + def test_add_wire_removed(self) -> None: + from commands.connection_schematic import ConnectionManager + + assert not hasattr( + ConnectionManager, "add_wire" + ), "ConnectionManager.add_wire should have been removed" + + def test_add_connection_removed(self) -> None: + from commands.connection_schematic import ConnectionManager + + assert not hasattr( + ConnectionManager, "add_connection" + ), "ConnectionManager.add_connection should have been removed" + + def test_get_pin_location_removed(self) -> None: + from commands.connection_schematic import ConnectionManager + + assert not hasattr( + ConnectionManager, "get_pin_location" + ), "ConnectionManager.get_pin_location should have been removed" + + +# --------------------------------------------------------------------------- +# 8. Integration tests — real disk I/O, no KiCAD process +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestIntegrationWireManager: + """Integration tests using real schematic files and WireManager.""" + + @pytest.fixture(autouse=True) + def sch(self) -> Any: + path = _make_temp_sch() + yield path + shutil.rmtree(path.parent, ignore_errors=True) + + def test_add_wire_writes_wire_element(self, sch: Any) -> None: + from commands.wire_manager import WireManager + + ok = WireManager.add_wire(sch, [10.0, 10.0], [30.0, 10.0]) + assert ok is True + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + assert len(wires) == 1 + + def test_add_polyline_wire_creates_segments(self, sch: Any) -> None: + """N waypoints should produce N-1 individual 2-point wire segments.""" + from commands.wire_manager import WireManager + + pts = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [20.0, 10.0]] + ok = WireManager.add_polyline_wire(sch, pts) + assert ok is True + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}" + + def test_add_junction_writes_junction_element(self, sch: Any) -> None: + from commands.wire_manager import WireManager + + ok = WireManager.add_junction(sch, [25.4, 25.4]) + assert ok is True + data = _parse_sch(sch) + junctions = _find_elements(data, "junction") + assert len(junctions) == 1 + # Verify position + at = junctions[0][1] # (at x y) + assert at[1] == 25.4 + assert at[2] == 25.4 + + def test_wire_endpoint_coordinates_match(self, sch: Any) -> None: + from commands.wire_manager import WireManager + + WireManager.add_wire(sch, [5.0, 7.5], [15.0, 7.5]) + data = _parse_sch(sch) + wire = _find_elements(data, "wire")[0] + pts = [ + item for item in wire if isinstance(item, list) and item and item[0] == Symbol("pts") + ][0] + xy_entries = [ + item for item in pts if isinstance(item, list) and item and item[0] == Symbol("xy") + ] + assert xy_entries[0][1] == 5.0 + assert xy_entries[0][2] == 7.5 + assert xy_entries[1][1] == 15.0 + assert xy_entries[1][2] == 7.5 + + +@pytest.mark.integration +class TestIntegrationHandlerEndToEnd: + """Integration tests for KiCADInterface handlers writing to real schematic files.""" + + @pytest.fixture(autouse=True) + def setup(self) -> Any: + import types + + for mod in ["pcbnew", "skip"]: + sys.modules.setdefault(mod, types.ModuleType(mod)) + from kicad_interface import KiCADInterface + + with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): + self.iface = KiCADInterface.__new__(KiCADInterface) + self.sch = _make_temp_sch() + yield + shutil.rmtree(self.sch.parent, ignore_errors=True) + + def test_junction_handler_writes_junction(self) -> None: + result = self.iface._handle_add_schematic_junction( + { + "schematicPath": str(self.sch), + "position": [50.8, 50.8], + } + ) + assert result["success"] is True + data = _parse_sch(self.sch) + junctions = _find_elements(data, "junction") + assert len(junctions) == 1 + + def test_wire_handler_two_points_writes_wire(self) -> None: + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch), + "waypoints": [[10.0, 10.0], [30.0, 10.0]], + "snapToPins": False, + } + ) + assert result["success"] is True + data = _parse_sch(self.sch) + wires = _find_elements(data, "wire") + assert len(wires) == 1 + + def test_wire_handler_four_points_creates_three_segments(self) -> None: + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch), + "waypoints": [[0, 0], [10, 0], [10, 10], [20, 10]], + "snapToPins": False, + } + ) + assert result["success"] is True + data = _parse_sch(self.sch) + wires = _find_elements(data, "wire") + assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}" + + +# --------------------------------------------------------------------------- +# 9. Unit tests — _point_strictly_on_wire +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestPointStrictlyOnWire: + """Unit tests for WireManager._point_strictly_on_wire geometry helper.""" + + @staticmethod + def _fn(px: Any, py: Any, x1: Any, y1: Any, x2: Any, y2: Any, eps: Any = 1e-6) -> Any: + from commands.wire_manager import WireManager + + return WireManager._point_strictly_on_wire(px, py, x1, y1, x2, y2, eps) + + def test_horizontal_midpoint(self) -> None: + assert self._fn(5, 0, 0, 0, 10, 0) is True + + def test_vertical_midpoint(self) -> None: + assert self._fn(0, 5, 0, 0, 0, 10) is True + + def test_horizontal_at_start_endpoint(self) -> None: + """Point at wire start should NOT be strictly on wire.""" + assert self._fn(0, 0, 0, 0, 10, 0) is False + + def test_horizontal_at_end_endpoint(self) -> None: + """Point at wire end should NOT be strictly on wire.""" + assert self._fn(10, 0, 0, 0, 10, 0) is False + + def test_vertical_at_start_endpoint(self) -> None: + assert self._fn(0, 0, 0, 0, 0, 10) is False + + def test_vertical_at_end_endpoint(self) -> None: + assert self._fn(0, 10, 0, 0, 0, 10) is False + + def test_point_off_horizontal_wire(self) -> None: + """Point above a horizontal wire.""" + assert self._fn(5, 1, 0, 0, 10, 0) is False + + def test_point_off_vertical_wire(self) -> None: + """Point to the right of a vertical wire.""" + assert self._fn(1, 5, 0, 0, 0, 10) is False + + def test_point_beyond_horizontal_wire(self) -> None: + """Point collinear but past the end of a horizontal wire.""" + assert self._fn(15, 0, 0, 0, 10, 0) is False + + def test_point_beyond_vertical_wire(self) -> None: + """Point collinear but past the end of a vertical wire.""" + assert self._fn(0, 15, 0, 0, 0, 10) is False + + def test_diagonal_wire_always_false(self) -> None: + """Only horizontal/vertical wires are handled; diagonal → False.""" + assert self._fn(5, 5, 0, 0, 10, 10) is False + + def test_reversed_horizontal_endpoints(self) -> None: + """Wire endpoints reversed (x2 < x1) should still work.""" + assert self._fn(5, 0, 10, 0, 0, 0) is True + + def test_reversed_vertical_endpoints(self) -> None: + """Wire endpoints reversed (y2 < y1) should still work.""" + assert self._fn(0, 5, 0, 10, 0, 0) is True + + def test_near_endpoint_within_epsilon(self) -> None: + """Point within epsilon of endpoint should NOT be considered strictly on wire.""" + assert self._fn(1e-7, 0, 0, 0, 10, 0) is False + + def test_zero_length_wire(self) -> None: + """Degenerate wire with same start/end — nothing is strictly between.""" + assert self._fn(5, 5, 5, 5, 5, 5) is False + + +# --------------------------------------------------------------------------- +# 10. Unit tests — _parse_wire +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestParseWire: + """Unit tests for WireManager._parse_wire S-expression parser.""" + + @staticmethod + def _fn(item: Any) -> Any: + from commands.wire_manager import WireManager + + return WireManager._parse_wire(item) + + def test_valid_wire(self) -> None: + wire = [ + Symbol("wire"), + [Symbol("pts"), [Symbol("xy"), 10.0, 20.0], [Symbol("xy"), 30.0, 20.0]], + [ + Symbol("stroke"), + [Symbol("width"), 0], + [Symbol("type"), Symbol("default")], + ], + [Symbol("uuid"), "abc-123"], + ] + result = TestParseWire._fn(wire) + assert result is not None + start, end, width, stype = result + assert start == (10.0, 20.0) + assert end == (30.0, 20.0) + assert width == 0 + assert stype == "default" + + def test_non_wire_element_returns_none(self) -> None: + junction = [Symbol("junction"), [Symbol("at"), 10, 20]] + assert TestParseWire._fn(junction) is None + + def test_non_list_returns_none(self) -> None: + assert TestParseWire._fn("not a list") is None + + def test_empty_list_returns_none(self) -> None: + assert TestParseWire._fn([]) is None + + def test_wire_with_no_pts_returns_none(self) -> None: + wire = [Symbol("wire"), [Symbol("stroke"), [Symbol("width"), 0]]] + assert TestParseWire._fn(wire) is None + + def test_wire_with_only_one_xy_returns_none(self) -> None: + wire = [ + Symbol("wire"), + [Symbol("pts"), [Symbol("xy"), 10.0, 20.0]], + ] + assert TestParseWire._fn(wire) is None + + def test_wire_without_stroke_uses_defaults(self) -> None: + wire = [ + Symbol("wire"), + [Symbol("pts"), [Symbol("xy"), 0, 0], [Symbol("xy"), 10, 0]], + ] + result = TestParseWire._fn(wire) + assert result is not None + _, _, width, stype = result + assert width == 0 + assert stype == "default" + + +# --------------------------------------------------------------------------- +# 11. Unit tests — _make_wire_sexp +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestMakeWireSexp: + """Unit tests for WireManager._make_wire_sexp builder.""" + + def test_produces_valid_parseable_wire(self) -> None: + from commands.wire_manager import WireManager + + sexp = WireManager._make_wire_sexp([10, 20], [30, 20]) + parsed = WireManager._parse_wire(sexp) + assert parsed is not None + start, end, width, stype = parsed + assert start == (10, 20) + assert end == (30, 20) + assert width == 0 + assert stype == "default" + + def test_custom_stroke(self) -> None: + from commands.wire_manager import WireManager + + sexp = WireManager._make_wire_sexp([0, 0], [5, 0], stroke_width=0.5, stroke_type="dash") + parsed = WireManager._parse_wire(sexp) + assert parsed is not None + _, _, width, stype = parsed + assert width == 0.5 + assert stype == "dash" + + def test_has_uuid(self) -> None: + from commands.wire_manager import WireManager + + sexp = WireManager._make_wire_sexp([0, 0], [10, 0]) + # uuid is the last element + uuid_entry = sexp[-1] + assert uuid_entry[0] == Symbol("uuid") + assert isinstance(uuid_entry[1], str) and len(uuid_entry[1]) > 0 + + def test_two_calls_produce_different_uuids(self) -> None: + from commands.wire_manager import WireManager + + sexp1 = WireManager._make_wire_sexp([0, 0], [10, 0]) + sexp2 = WireManager._make_wire_sexp([0, 0], [10, 0]) + assert sexp1[-1][1] != sexp2[-1][1], "Each wire should have a unique UUID" + + +# --------------------------------------------------------------------------- +# 12. Unit tests — _break_wires_at_point +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestBreakWiresAtPoint: + """Unit tests for WireManager._break_wires_at_point T-junction logic.""" + + @staticmethod + def _make_sch_data_with_wires(wire_coords: Any) -> list[Any]: + """Build a minimal sch_data list with wire elements and a sheet_instances marker.""" + from commands.wire_manager import WireManager + + data = [Symbol("kicad_sch")] + for start, end in wire_coords: + data.append(WireManager._make_wire_sexp(start, end)) + data.append([Symbol("sheet_instances")]) + return data + + def test_split_horizontal_wire_at_midpoint(self) -> None: + from commands.wire_manager import WireManager + + data = self._make_sch_data_with_wires([([0, 0], [20, 0])]) + splits = WireManager._break_wires_at_point(data, [10, 0]) + assert splits == 1 + wires = _find_elements(data, "wire") + assert len(wires) == 2 + # Verify the two segments share the split point + coords = [] + for w in wires: + parsed = WireManager._parse_wire(w) + coords.append((parsed[0], parsed[1])) + endpoints = {c for pair in coords for c in pair} + assert (10.0, 0.0) in endpoints + + def test_split_vertical_wire_at_midpoint(self) -> None: + from commands.wire_manager import WireManager + + data = self._make_sch_data_with_wires([([5, 0], [5, 30])]) + splits = WireManager._break_wires_at_point(data, [5, 15]) + assert splits == 1 + wires = _find_elements(data, "wire") + assert len(wires) == 2 + + def test_no_split_at_wire_endpoint(self) -> None: + """Point at existing endpoint should not trigger a split.""" + from commands.wire_manager import WireManager + + data = self._make_sch_data_with_wires([([0, 0], [20, 0])]) + splits = WireManager._break_wires_at_point(data, [0, 0]) + assert splits == 0 + wires = _find_elements(data, "wire") + assert len(wires) == 1 + + def test_no_split_point_not_on_wire(self) -> None: + from commands.wire_manager import WireManager + + data = self._make_sch_data_with_wires([([0, 0], [20, 0])]) + splits = WireManager._break_wires_at_point(data, [10, 5]) + assert splits == 0 + wires = _find_elements(data, "wire") + assert len(wires) == 1 + + def test_split_multiple_wires_at_same_point(self) -> None: + """Two crossing wires at (10, 10) — both should be split.""" + from commands.wire_manager import WireManager + + data = self._make_sch_data_with_wires( + [ + ([0, 10], [20, 10]), # horizontal through (10,10) + ([10, 0], [10, 20]), # vertical through (10,10) + ] + ) + splits = WireManager._break_wires_at_point(data, [10, 10]) + assert splits == 2 + wires = _find_elements(data, "wire") + assert len(wires) == 4 # each wire split into 2 + + def test_split_preserves_stroke_properties(self) -> None: + from commands.wire_manager import WireManager + + data = [Symbol("kicad_sch")] + data.append( + WireManager._make_wire_sexp([0, 0], [20, 0], stroke_width=0.5, stroke_type="dash") + ) + data.append([Symbol("sheet_instances")]) + splits = WireManager._break_wires_at_point(data, [10, 0]) + assert splits == 1 + wires = _find_elements(data, "wire") + for w in wires: + parsed = WireManager._parse_wire(w) + assert parsed[2] == 0.5, "stroke_width should be preserved" + assert parsed[3] == "dash", "stroke_type should be preserved" + + def test_no_split_on_diagonal_wire(self) -> None: + """Diagonal wires are not handled by _point_strictly_on_wire → no split.""" + from commands.wire_manager import WireManager + + data = self._make_sch_data_with_wires([([0, 0], [10, 10])]) + splits = WireManager._break_wires_at_point(data, [5, 5]) + assert splits == 0 + + def test_empty_sch_data(self) -> None: + from commands.wire_manager import WireManager + + data = [Symbol("kicad_sch"), [Symbol("sheet_instances")]] + splits = WireManager._break_wires_at_point(data, [10, 10]) + assert splits == 0 + + +# --------------------------------------------------------------------------- +# 13. Integration tests — T-junction wire breaking +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestIntegrationTJunction: + """Integration tests for T-junction wire breaking during add_wire/add_junction.""" + + @pytest.fixture(autouse=True) + def sch(self) -> Any: + path = _make_temp_sch() + yield path + shutil.rmtree(path.parent, ignore_errors=True) + + def test_add_wire_breaks_existing_horizontal_wire(self, sch: Any) -> None: + """Adding a vertical wire whose endpoint is mid-horizontal-wire should split it.""" + from commands.wire_manager import WireManager + + # First add a horizontal wire (0,10) -> (20,10) + WireManager.add_wire(sch, [0, 10], [20, 10]) + # Now add a vertical wire ending at (10,10) — the midpoint of the horizontal wire + WireManager.add_wire(sch, [10, 0], [10, 10]) + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + # Original horizontal wire should be split into 2, plus the new vertical = 3 total + assert len(wires) == 3, f"Expected 3 wires (split + new), got {len(wires)}" + + def test_add_wire_does_not_break_at_shared_endpoint(self, sch: Any) -> None: + """Wire connecting at an existing endpoint should not trigger a split.""" + from commands.wire_manager import WireManager + + WireManager.add_wire(sch, [0, 0], [10, 0]) + # New wire starts at (10,0) — existing endpoint, not midpoint + WireManager.add_wire(sch, [10, 0], [10, 10]) + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + assert len(wires) == 2, f"Expected 2 wires (no split), got {len(wires)}" + + def test_add_junction_breaks_wire(self, sch: Any) -> None: + """Adding a junction mid-wire should split that wire.""" + from commands.wire_manager import WireManager + + WireManager.add_wire(sch, [0, 0], [30, 0]) + WireManager.add_junction(sch, [15, 0]) + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + assert len(wires) == 2, f"Expected 2 wires after junction split, got {len(wires)}" + junctions = _find_elements(data, "junction") + assert len(junctions) == 1 + + def test_add_junction_at_wire_endpoint_no_split(self, sch: Any) -> None: + """Junction at wire endpoint should not split it.""" + from commands.wire_manager import WireManager + + WireManager.add_wire(sch, [0, 0], [20, 0]) + WireManager.add_junction(sch, [20, 0]) + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + assert len(wires) == 1, f"Expected 1 wire (no split at endpoint), got {len(wires)}" + + def test_polyline_breaks_existing_wire(self, sch: Any) -> None: + """Polyline whose start/end hits mid-wire should break it.""" + from commands.wire_manager import WireManager + + WireManager.add_wire(sch, [0, 10], [20, 10]) + # Polyline starting at (10,10) — mid-horizontal-wire + WireManager.add_polyline_wire(sch, [[10, 10], [10, 20], [20, 20]]) + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + # 2 from split + 2 polyline segments = 4 + assert len(wires) == 4, f"Expected 4 wires, got {len(wires)}" + + def test_polyline_two_points_same_as_add_wire(self, sch: Any) -> None: + """Polyline with exactly 2 points should produce 1 wire segment.""" + from commands.wire_manager import WireManager + + WireManager.add_polyline_wire(sch, [[0, 0], [10, 0]]) + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + assert len(wires) == 1 diff --git a/python/utils/kicad_process.py b/python/utils/kicad_process.py index bb367bd..4642c33 100644 --- a/python/utils/kicad_process.py +++ b/python/utils/kicad_process.py @@ -157,9 +157,9 @@ class KiCADProcessManager: timeout=5 if system == "Windows" else None, ) if result.returncode == 0: - path = result.stdout.strip().split("\n")[0] - logger.info(f"Found KiCAD executable: {path}") - return Path(path) + exe_path = result.stdout.strip().split("\n")[0] + logger.info(f"Found KiCAD executable: {exe_path}") + return Path(exe_path) # Platform-specific default paths if system == "Linux": From 0255064648ce6d199b661cfc31dee5a7ca5fb236 Mon Sep 17 00:00:00 2001 From: nhebling Date: Sat, 11 Apr 2026 17:25:01 +0200 Subject: [PATCH 55/74] feat: add macOS automated setup script and update platform documentation Add setup-macos.sh, a shell script that automates Claude Desktop MCP configuration on macOS. The script detects KiCad's bundled Python, resolves PYTHONPATH, generates the correct MCP server config, and safely merges it into the existing Claude Desktop configuration with backup support. Supports --verify, --dry-run, and --apply modes. Update README.md with documentation for the automated setup workflow, including usage examples, parameter reference, and post-setup steps. Extend docs/PLATFORM_GUIDE.md with macOS-specific sections covering installation, path handling, Python environment, troubleshooting, best practices, cross-platform migration, and support resources. Update docs/STATUS_SUMMARY.md to reflect the new macOS automation capabilities and bump the last-updated date. --- README.md | 134 ++++++++++++++++- docs/PLATFORM_GUIDE.md | 187 ++++++++++++++++++++++-- docs/STATUS_SUMMARY.md | 8 +- setup-macos.sh | 317 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 628 insertions(+), 18 deletions(-) create mode 100644 setup-macos.sh diff --git a/README.md b/README.md index 4f284fc..dc16347 100644 --- a/README.md +++ b/README.md @@ -505,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 @@ -529,7 +531,135 @@ 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 +~/.config/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 KiCad’s bundled Python; system Python will not work correctly +- If KiCad is updated or moved, re-run the script to refresh paths + +--- ## Configuration diff --git a/docs/PLATFORM_GUIDE.md b/docs/PLATFORM_GUIDE.md index 11e8dd3..004fcfe 100644 --- a/docs/PLATFORM_GUIDE.md +++ b/docs/PLATFORM_GUIDE.md @@ -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 | --- @@ -65,6 +65,51 @@ 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:** @@ -130,6 +175,21 @@ 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` @@ -199,6 +259,27 @@ 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.9–3.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:** @@ -275,6 +356,32 @@ 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:** @@ -356,6 +463,30 @@ 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 `~/.config/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** @@ -479,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 @@ -507,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.** --- @@ -520,6 +666,13 @@ npm test - 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 @@ -539,6 +692,12 @@ npm test - 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 @@ -546,16 +705,18 @@ npm test - 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) diff --git a/docs/STATUS_SUMMARY.md b/docs/STATUS_SUMMARY.md index df216c8..136b080 100644 --- a/docs/STATUS_SUMMARY.md +++ b/docs/STATUS_SUMMARY.md @@ -90,9 +90,11 @@ The server automatically selects the best backend: ### macOS -- Community Supported -- Configuration provided +- 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 --- @@ -170,4 +172,4 @@ The server automatically selects the best backend: --- -_Last Updated: 2026-03-21_ +_Last Updated: 2026-04-11_ diff --git a/setup-macos.sh b/setup-macos.sh new file mode 100644 index 0000000..3d64ca1 --- /dev/null +++ b/setup-macos.sh @@ -0,0 +1,317 @@ +#!/usr/bin/env bash +set -euo pipefail + +MODE="" +ASSUME_YES=0 +SERVER_NAME="kicad" +CLAUDE_CONFIG_PATH="" + +SCRIPT_NAME="$(basename "$0")" + +usage() { + cat <&2; exit 1; } +info() { echo "${SYM_OK} $1"; } +warn() { echo "${SYM_WARN} ${YELLOW}$1${RESET}"; } + +section() { + echo + echo "${DIM}────────────────────────────────────────────────────${RESET}" + echo "${BOLD}${CYAN}$1${RESET}" + echo "${DIM}────────────────────────────────────────────────────${RESET}" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --verify) + MODE="verify" + shift + ;; + --dry-run) + MODE="dry-run" + shift + ;; + --apply) + MODE="apply" + shift + ;; + --yes) + ASSUME_YES=1 + shift + ;; + --name) + [[ $# -ge 2 ]] || fail "--name requires a value" + SERVER_NAME="$2" + shift 2 + ;; + --claude-config) + [[ $# -ge 2 ]] || fail "--claude-config requires a value" + CLAUDE_CONFIG_PATH="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "Unknown argument: $1" + ;; + esac +done + +[[ -n "$MODE" ]] || { usage; exit 1; } +[[ -n "$SERVER_NAME" ]] || fail "Server name must not be empty" + +if [[ -z "$CLAUDE_CONFIG_PATH" ]]; then + CLAUDE_CONFIG_PATH="$HOME/.config/Claude/claude_desktop_config.json" +fi + +case "$CLAUDE_CONFIG_PATH" in + "~/"*) + CLAUDE_CONFIG_PATH="$HOME/${CLAUDE_CONFIG_PATH#~/}" + ;; +esac + +CLAUDE_CONFIG_DIR="$(dirname "$CLAUDE_CONFIG_PATH")" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -f "$SCRIPT_DIR/package.json" ]]; then + REPO_ROOT="$SCRIPT_DIR" +else + REPO_ROOT="$(pwd)" +fi + +DIST_JS="$REPO_ROOT/dist/index.js" + +DEFAULT_KICAD_PYTHON="/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/Current/bin/python3" +KICAD_PYTHON="${KICAD_PYTHON:-$DEFAULT_KICAD_PYTHON}" + +command -v python3 >/dev/null 2>&1 || fail "python3 not found" +NODE_PATH="$(command -v node || true)" +[[ -n "$NODE_PATH" ]] || fail "node not found in PATH" + +[[ -f "$DIST_JS" ]] || fail "Missing build artifact: $DIST_JS. Run 'npm install && npm run build' first." +[[ -x "$KICAD_PYTHON" ]] || fail "KiCad Python not found or not executable: $KICAD_PYTHON" + +DETECT_JSON="$("$KICAD_PYTHON" - <<'PY' +import json, sys, sysconfig +result = { + "python_executable": sys.executable, + "python_version": sys.version.split()[0], + "purelib": sysconfig.get_paths().get("purelib"), + "pcbnew_ok": False, + "pcbnew_version": None, + "pcbnew_error": None, +} +try: + import pcbnew + result["pcbnew_ok"] = True + if hasattr(pcbnew, "GetBuildVersion"): + result["pcbnew_version"] = pcbnew.GetBuildVersion() +except Exception as e: + result["pcbnew_error"] = repr(e) +print(json.dumps(result)) +PY +)" + +PYTHON_EXE="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["python_executable"])' "$DETECT_JSON")" +PYTHON_VERSION="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["python_version"])' "$DETECT_JSON")" +PYTHONPATH_VALUE="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["purelib"])' "$DETECT_JSON")" +PCBNEW_OK="$(python3 -c 'import json,sys; print("true" if json.loads(sys.argv[1])["pcbnew_ok"] else "false")' "$DETECT_JSON")" +PCBNEW_VERSION="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["pcbnew_version"] or "")' "$DETECT_JSON")" +PCBNEW_ERROR="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["pcbnew_error"] or "")' "$DETECT_JSON")" + +if [[ "$PCBNEW_OK" != "true" ]]; then + fail "KiCad Python could not import pcbnew. Details: $PCBNEW_ERROR" +fi + +CONFIG_FRAGMENT_JSON="$(python3 - "$NODE_PATH" "$DIST_JS" "$KICAD_PYTHON" "$PYTHONPATH_VALUE" <<'PY' +import json, sys +fragment = { + "command": sys.argv[1], + "args": [sys.argv[2]], + "env": { + "KICAD_PYTHON": sys.argv[3], + "PYTHONPATH": sys.argv[4], + "LOG_LEVEL": "info" + } +} +print(json.dumps(fragment, indent=2)) +PY +)" + +show_detected() { + section "Prerequisites" + echo " ${SYM_OK} python3 $(command -v python3)" + echo " ${SYM_OK} node $NODE_PATH" + echo " ${SYM_OK} build artifact $DIST_JS" + echo " ${SYM_OK} KiCad Python $KICAD_PYTHON" + echo " ${SYM_OK} pcbnew import $PCBNEW_VERSION" + + section "Configuration" + echo " Server name: ${BOLD}$SERVER_NAME${RESET}" + echo " Repo root: $REPO_ROOT" + echo " Python executable: $PYTHON_EXE" + echo " Python version: $PYTHON_VERSION" + echo " PYTHONPATH: $PYTHONPATH_VALUE" + echo " Claude config: $CLAUDE_CONFIG_PATH" +} + +merge_config() { + python3 - "$CLAUDE_CONFIG_PATH" "$CONFIG_FRAGMENT_JSON" "$SERVER_NAME" <<'PY' +import json, os, sys + +config_path = sys.argv[1] +fragment = json.loads(sys.argv[2]) +server_name = sys.argv[3] + +existing = {} +status = { + "config_exists": False, + "config_valid": True, + "had_mcpServers": False, + "had_entry": False, +} + +if os.path.exists(config_path): + status["config_exists"] = True + try: + with open(config_path, "r", encoding="utf-8") as f: + text = f.read().strip() + existing = json.loads(text) if text else {} + except Exception: + print(json.dumps({"error": "Existing Claude config is not valid JSON", "status": status})) + sys.exit(2) + +if not isinstance(existing, dict): + print(json.dumps({"error": "Existing Claude config root is not a JSON object", "status": status})) + sys.exit(2) + +if "mcpServers" in existing: + status["had_mcpServers"] = True + if not isinstance(existing["mcpServers"], dict): + print(json.dumps({"error": "'mcpServers' exists but is not an object", "status": status})) + sys.exit(2) +else: + existing["mcpServers"] = {} + +if server_name in existing["mcpServers"]: + status["had_entry"] = True + +existing["mcpServers"][server_name] = fragment +print(json.dumps({"status": status, "merged": existing}, indent=2)) +PY +} + +if [[ "$MODE" == "verify" ]]; then + show_detected + section "Proposed Claude Desktop entry ('$SERVER_NAME')" + echo "$CONFIG_FRAGMENT_JSON" + exit 0 +fi + +MERGE_RESULT="$(merge_config)" || { + echo "$MERGE_RESULT" + exit 1 +} + +MERGED_JSON="$(python3 -c 'import json,sys; print(json.dumps(json.loads(sys.stdin.read())["merged"], indent=2))' <<<"$MERGE_RESULT")" +CONFIG_EXISTS="$(python3 -c 'import json,sys; print("true" if json.loads(sys.stdin.read())["status"]["config_exists"] else "false")' <<<"$MERGE_RESULT")" +HAD_ENTRY="$(python3 -c 'import json,sys; print("true" if json.loads(sys.stdin.read())["status"]["had_entry"] else "false")' <<<"$MERGE_RESULT")" + +show_detected + +section "Proposed MCP entry ('$SERVER_NAME')" +echo "$CONFIG_FRAGMENT_JSON" + +section "Claude Desktop config" +if [[ "$CONFIG_EXISTS" == "true" ]]; then + if [[ "$HAD_ENTRY" == "true" ]]; then + warn "Existing config already has mcpServers.$SERVER_NAME — it will be replaced." + else + info "Existing config will be preserved; mcpServers.$SERVER_NAME will be added." + fi +else + info "Config does not exist yet. A new file will be created." +fi +echo +echo "${DIM}Merged config preview:${RESET}" +echo "$MERGED_JSON" + +if [[ "$MODE" == "dry-run" ]]; then + exit 0 +fi + +mkdir -p "$CLAUDE_CONFIG_DIR" + +if [[ $ASSUME_YES -ne 1 ]]; then + echo + read -r -p "Write this configuration to $CLAUDE_CONFIG_PATH ? [y/N] " REPLY + case "$REPLY" in + y|Y|yes|YES) ;; + *) + echo "Aborted." + exit 0 + ;; + esac +fi + +ORIG_MODE="" +if [[ -f "$CLAUDE_CONFIG_PATH" ]]; then + ORIG_MODE="$(stat -f '%Lp' "$CLAUDE_CONFIG_PATH")" + BACKUP_PATH="${CLAUDE_CONFIG_PATH}.bak.$(date +%Y%m%d-%H%M%S)" + cp "$CLAUDE_CONFIG_PATH" "$BACKUP_PATH" + info "Backup written to ${DIM}$BACKUP_PATH${RESET}" +fi + +TMP_PATH="${CLAUDE_CONFIG_PATH}.tmp.$$" +(umask 077 && printf '%s\n' "$MERGED_JSON" > "$TMP_PATH") +if [[ -n "$ORIG_MODE" ]]; then + chmod "$ORIG_MODE" "$TMP_PATH" +fi +mv "$TMP_PATH" "$CLAUDE_CONFIG_PATH" + +section "Done" +info "Claude Desktop configuration updated successfully." + +echo +echo "${BOLD}Next steps:${RESET}" +echo " 1. Fully quit Claude Desktop" +echo " 2. Reopen Claude Desktop" +echo " 3. In a new chat, check: + → Connectors" +echo " 4. Verify with:" +echo " Use the ${BOLD}$SERVER_NAME${RESET} MCP server to run ${BOLD}check_kicad_ui${RESET}." +echo \ No newline at end of file From e28df51eb14adbc68232dba2b2a73ca9246d054f Mon Sep 17 00:00:00 2001 From: nhebling Date: Sat, 11 Apr 2026 21:10:14 +0200 Subject: [PATCH 56/74] fix: address maintainer review feedback on setup-macos.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix incorrect default Claude Desktop config path — macOS uses ~/Library/Application Support/Claude/claude_desktop_config.json, not ~/.config/Claude/claude_desktop_config.json. Capture stderr in merge_config subshell (2>&1) so Python tracebacks and interpreter errors are surfaced instead of silently dropped when the command substitution fails under set -e. Update README.md and docs/PLATFORM_GUIDE.md to reflect the corrected macOS config path, and split the combined Linux/macOS config location reference into separate per-platform entries. Add trailing newline at the end. --- README.md | 5 +++-- docs/PLATFORM_GUIDE.md | 2 +- setup-macos.sh | 8 ++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index dc16347..cd7b3b6 100644 --- a/README.md +++ b/README.md @@ -613,7 +613,7 @@ Specify a custom Claude Desktop configuration file. Default: ```text -~/.config/Claude/claude_desktop_config.json +~/Library/Application Support/Claude/claude_desktop_config.json ``` Example: @@ -667,7 +667,8 @@ Use the kicad MCP server to run check_kicad_ui. 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:** diff --git a/docs/PLATFORM_GUIDE.md b/docs/PLATFORM_GUIDE.md index 004fcfe..999bde0 100644 --- a/docs/PLATFORM_GUIDE.md +++ b/docs/PLATFORM_GUIDE.md @@ -483,7 +483,7 @@ bash setup-macos.sh --verify **3. Claude Config Not Picked Up** -- Default path is `~/.config/Claude/claude_desktop_config.json` +- 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 diff --git a/setup-macos.sh b/setup-macos.sh index 3d64ca1..2d207df 100644 --- a/setup-macos.sh +++ b/setup-macos.sh @@ -22,7 +22,7 @@ Options: --yes Do not prompt before writing (only with --apply) --name NAME MCP server name (default: kicad) --claude-config PATH Path to Claude Desktop config file - (default: ~/.config/Claude/claude_desktop_config.json) + (default: ~/Library/Application Support/Claude/claude_desktop_config.json) EOF } @@ -96,7 +96,7 @@ done [[ -n "$SERVER_NAME" ]] || fail "Server name must not be empty" if [[ -z "$CLAUDE_CONFIG_PATH" ]]; then - CLAUDE_CONFIG_PATH="$HOME/.config/Claude/claude_desktop_config.json" + CLAUDE_CONFIG_PATH="$HOME/Library/Application Support/Claude/claude_desktop_config.json" fi case "$CLAUDE_CONFIG_PATH" in @@ -243,7 +243,7 @@ if [[ "$MODE" == "verify" ]]; then exit 0 fi -MERGE_RESULT="$(merge_config)" || { +MERGE_RESULT="$(merge_config 2>&1)" || { echo "$MERGE_RESULT" exit 1 } @@ -314,4 +314,4 @@ echo " 2. Reopen Claude Desktop" echo " 3. In a new chat, check: + → Connectors" echo " 4. Verify with:" echo " Use the ${BOLD}$SERVER_NAME${RESET} MCP server to run ${BOLD}check_kicad_ui${RESET}." -echo \ No newline at end of file +echo From 5de932e2b3de837619a202a561756cffa5722f16 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 12 Apr 2026 11:22:39 +0100 Subject: [PATCH 57/74] refactor: consolidate python/tests/ into tests/ directory Move all Python test files from python/tests/ to the top-level tests/ directory to match the project's CONTRIBUTING.md guidelines. Update sys.path inserts and template path references to reflect the new location. Co-Authored-By: Claude Sonnet 4.6 --- pytest.ini | 2 +- python/tests/__init__.py | 0 {python/tests => tests}/conftest.py | 0 .../test_delete_schematic_component.py | 4 ++-- {python/tests => tests}/test_freerouting.py | 0 .../test_move_with_wire_preservation.py | 22 +++++++++---------- .../test_schematic_analysis.py | 4 ++-- .../test_schematic_component_fields.py | 5 ++--- .../tests => tests}/test_schematic_tools.py | 2 +- .../tests => tests}/test_wire_connectivity.py | 2 +- .../test_wire_junction_changes.py | 2 +- 11 files changed, 21 insertions(+), 22 deletions(-) delete mode 100644 python/tests/__init__.py rename {python/tests => tests}/conftest.py (100%) rename {python/tests => tests}/test_delete_schematic_component.py (98%) rename {python/tests => tests}/test_freerouting.py (100%) rename {python/tests => tests}/test_move_with_wire_preservation.py (99%) rename {python/tests => tests}/test_schematic_analysis.py (99%) rename {python/tests => tests}/test_schematic_component_fields.py (98%) rename {python/tests => tests}/test_schematic_tools.py (99%) rename {python/tests => tests}/test_wire_connectivity.py (99%) rename {python/tests => tests}/test_wire_junction_changes.py (99%) diff --git a/pytest.ini b/pytest.ini index 63d8b3b..9424546 100644 --- a/pytest.ini +++ b/pytest.ini @@ -7,7 +7,7 @@ python_classes = Test* python_functions = test_* # Test paths -testpaths = tests python/tests +testpaths = tests # Minimum Python version minversion = 6.0 diff --git a/python/tests/__init__.py b/python/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/python/tests/conftest.py b/tests/conftest.py similarity index 100% rename from python/tests/conftest.py rename to tests/conftest.py diff --git a/python/tests/test_delete_schematic_component.py b/tests/test_delete_schematic_component.py similarity index 98% rename from python/tests/test_delete_schematic_component.py rename to tests/test_delete_schematic_component.py index 7fa7265..26f62fb 100644 --- a/python/tests/test_delete_schematic_component.py +++ b/tests/test_delete_schematic_component.py @@ -14,9 +14,9 @@ from typing import Any import pytest -sys.path.insert(0, str(Path(__file__).parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent / "python")) -TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch" +TEMPLATE_SCH = Path(__file__).parent.parent / "python" / "templates" / "empty.kicad_sch" # Inline format (single line) – matches what tests previously used PLACED_RESISTOR_INLINE = """\ diff --git a/python/tests/test_freerouting.py b/tests/test_freerouting.py similarity index 100% rename from python/tests/test_freerouting.py rename to tests/test_freerouting.py diff --git a/python/tests/test_move_with_wire_preservation.py b/tests/test_move_with_wire_preservation.py similarity index 99% rename from python/tests/test_move_with_wire_preservation.py rename to tests/test_move_with_wire_preservation.py index 0952d41..7429a6b 100644 --- a/python/tests/test_move_with_wire_preservation.py +++ b/tests/test_move_with_wire_preservation.py @@ -17,11 +17,11 @@ import sexpdata from sexpdata import Symbol # Make python/ importable -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python")) from commands.wire_dragger import EPS, WireDragger, _coords_match, _rotate -TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "empty.kicad_sch" +TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "python" / "templates" / "empty.kicad_sch" # --------------------------------------------------------------------------- @@ -517,7 +517,7 @@ class TestMoveWithWirePreservation: sch = self._make_schematic() self._add_resistor(sch, "R1", 100, 100) # Call handler directly - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python")) from kicad_interface import KiCADInterface iface = KiCADInterface() @@ -541,7 +541,7 @@ class TestMoveWithWirePreservation: self._add_resistor(sch, "R1", 100, 100) self._add_wire(sch, 100, 103.81, 100, 120) # wire from pin 1 upward - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python")) from kicad_interface import KiCADInterface iface = KiCADInterface() @@ -572,7 +572,7 @@ class TestMoveWithWirePreservation: self._add_resistor(sch, "R1", 100, 100) self._add_wire(sch, 50, 50, 60, 50) # unrelated wire - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python")) from kicad_interface import KiCADInterface iface = KiCADInterface() @@ -596,7 +596,7 @@ class TestMoveWithWirePreservation: # Wire from pin 1 to pin 2 of same component (intra-component wire) self._add_wire(sch, 100, 103.81, 100, 96.19) - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python")) from kicad_interface import KiCADInterface iface = KiCADInterface() @@ -621,7 +621,7 @@ class TestMoveWithWirePreservation: self._add_resistor(sch, "R1", 100, 100) self._add_wire(sch, 100, 103.81, 100, 120) - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python")) from kicad_interface import KiCADInterface iface = KiCADInterface() @@ -648,7 +648,7 @@ class TestMoveWithWirePreservation: def test_missing_component_returns_error(self) -> None: sch = self._make_schematic() - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python")) from kicad_interface import KiCADInterface iface = KiCADInterface() @@ -907,7 +907,7 @@ class TestTouchingPinIntegration: self._add_resistor(sch, "R1", 100, 100) self._add_resistor(sch, "R2", 100, 92.38) - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python")) from kicad_interface import KiCADInterface iface = KiCADInterface() @@ -958,7 +958,7 @@ class TestTouchingPinIntegration: self._add_resistor(sch, "R1", 100, 100) self._add_resistor(sch, "R2", 150, 150) # far away - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python")) from kicad_interface import KiCADInterface iface = KiCADInterface() @@ -995,7 +995,7 @@ class TestTouchingPinIntegration: idx = content.rfind(")") sch.write_text(content[:idx] + "\n" + wire_sexp + "\n)", encoding="utf-8") - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python")) from kicad_interface import KiCADInterface iface = KiCADInterface() diff --git a/python/tests/test_schematic_analysis.py b/tests/test_schematic_analysis.py similarity index 99% rename from python/tests/test_schematic_analysis.py rename to tests/test_schematic_analysis.py index 333058b..d7efab3 100644 --- a/python/tests/test_schematic_analysis.py +++ b/tests/test_schematic_analysis.py @@ -17,7 +17,7 @@ import sexpdata from sexpdata import Symbol # Ensure the python/ package is importable -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python")) from commands.schematic_analysis import ( _aabb_overlap, @@ -43,7 +43,7 @@ from commands.schematic_analysis import ( # Helpers # --------------------------------------------------------------------------- -TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "empty.kicad_sch" +TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "python" / "templates" / "empty.kicad_sch" def _make_temp_schematic(extra_sexp: str = "") -> Path: diff --git a/python/tests/test_schematic_component_fields.py b/tests/test_schematic_component_fields.py similarity index 98% rename from python/tests/test_schematic_component_fields.py rename to tests/test_schematic_component_fields.py index 96d5d84..714a4e6 100644 --- a/python/tests/test_schematic_component_fields.py +++ b/tests/test_schematic_component_fields.py @@ -12,15 +12,14 @@ from typing import Any import pytest # Ensure python/ directory is on path so kicad_interface can be imported -sys.path.insert(0, str(Path(__file__).parent.parent)) -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "python")) +sys.path.insert(0, str(Path(__file__).parent.parent / "python")) # --------------------------------------------------------------------------- # Helpers shared across tests # --------------------------------------------------------------------------- -TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch" +TEMPLATE_SCH = Path(__file__).parent.parent / "python" / "templates" / "empty.kicad_sch" # Minimal placed-symbol block we can embed into a schematic for testing PLACED_RESISTOR_BLOCK = """\ diff --git a/python/tests/test_schematic_tools.py b/tests/test_schematic_tools.py similarity index 99% rename from python/tests/test_schematic_tools.py rename to tests/test_schematic_tools.py index ef89753..d8e30d9 100644 --- a/python/tests/test_schematic_tools.py +++ b/tests/test_schematic_tools.py @@ -22,7 +22,7 @@ import sexpdata # Helpers # --------------------------------------------------------------------------- -TEMPLATES_DIR = Path(__file__).parent.parent / "templates" +TEMPLATES_DIR = Path(__file__).parent.parent / "python" / "templates" EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch" # Minimal schematic content used by integration tests diff --git a/python/tests/test_wire_connectivity.py b/tests/test_wire_connectivity.py similarity index 99% rename from python/tests/test_wire_connectivity.py rename to tests/test_wire_connectivity.py index 06bb41a..9c013e7 100644 --- a/python/tests/test_wire_connectivity.py +++ b/tests/test_wire_connectivity.py @@ -17,7 +17,7 @@ from unittest.mock import MagicMock, patch import pytest # Ensure the python package root is importable -sys.path.insert(0, str(Path(__file__).parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent / "python")) # --------------------------------------------------------------------------- # Module under test diff --git a/python/tests/test_wire_junction_changes.py b/tests/test_wire_junction_changes.py similarity index 99% rename from python/tests/test_wire_junction_changes.py rename to tests/test_wire_junction_changes.py index 063a4fd..56d6f08 100644 --- a/python/tests/test_wire_junction_changes.py +++ b/tests/test_wire_junction_changes.py @@ -18,7 +18,7 @@ import sexpdata from sexpdata import Symbol # Add python dir to path -PYTHON_DIR = Path(__file__).parent.parent +PYTHON_DIR = Path(__file__).parent.parent / "python" sys.path.insert(0, str(PYTHON_DIR)) TEMPLATES_DIR = PYTHON_DIR / "templates" From 94125eda7f8fece3f3539aa6d800a158b3d1ab79 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 12 Apr 2026 14:12:11 +0100 Subject: [PATCH 58/74] fix: add pin-snapping and coordinate feedback to net label tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add_schematic_net_label now accepts optional componentRef + pinNumber to snap the label directly to the exact pin endpoint via PinLocator, removing all approximation risk. The response always includes actual_position and, when snapping was used, snapped_to_pin — so the caller gets confirmation of exactly where the label landed. connect_to_net return type changed from bool to Dict, returning pin_location, label_location, and wire_stub on success so agents no longer need a separate verification call to confirm placement. connect_passthrough updated to check result.get("success") against the new dict return. tool_schemas.py and schematic.ts updated to match (position is now optional, componentRef/pinNumber/labelType/orientation added, connect_to_net schema field names corrected). 17 new unit tests in tests/test_net_label_pin_snapping.py. Co-Authored-By: Claude Sonnet 4.6 --- python/commands/connection_schematic.py | 49 ++-- python/kicad_interface.py | 88 +++++-- python/schemas/tool_schemas.py | 50 +++- src/tools/schematic.ts | 49 +++- tests/test_net_label_pin_snapping.py | 300 ++++++++++++++++++++++++ 5 files changed, 481 insertions(+), 55 deletions(-) create mode 100644 tests/test_net_label_pin_snapping.py diff --git a/python/commands/connection_schematic.py b/python/commands/connection_schematic.py index 69f8ceb..f46ea99 100644 --- a/python/commands/connection_schematic.py +++ b/python/commands/connection_schematic.py @@ -59,9 +59,9 @@ class ConnectionManager: @staticmethod def connect_to_net( schematic_path: Path, component_ref: str, pin_name: str, net_name: str - ) -> bool: + ) -> 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 @@ -70,27 +70,32 @@ 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: @@ -106,26 +111,34 @@ class ConnectionManager: # 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( @@ -177,18 +190,18 @@ class ConnectionManager: 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: diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 7fd00b7..8f2d3d2 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -1514,9 +1514,16 @@ class KiCADInterface: return {"success": False, "message": str(e)} def _handle_add_schematic_net_label(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Add a net label to schematic using WireManager""" + """Add a net label to schematic using WireManager. + + When componentRef and pinNumber are supplied the label is placed at the + exact pin endpoint retrieved via PinLocator, ignoring the provided + position. The response includes the actual coordinates used and + whether the label landed on a pin endpoint. + """ logger.info("Adding net label to schematic") try: + import traceback from pathlib import Path from commands.wire_manager import WireManager @@ -1524,13 +1531,48 @@ class KiCADInterface: schematic_path = params.get("schematicPath") net_name = params.get("netName") position = params.get("position") - label_type = params.get( - "labelType", "label" - ) # 'label', 'global_label', 'hierarchical_label' - orientation = params.get("orientation", 0) # 0, 90, 180, 270 + label_type = params.get("labelType", "label") + orientation = params.get("orientation", 0) + component_ref = params.get("componentRef") + pin_number = params.get("pinNumber") - if not all([schematic_path, net_name, position]): - return {"success": False, "message": "Missing required parameters"} + if not all([schematic_path, net_name]): + return { + "success": False, + "message": "Missing required parameters: schematicPath, netName", + } + + snapped_to_pin: Optional[Dict[str, Any]] = None + + if component_ref and pin_number: + # Snap position to exact pin endpoint using PinLocator + from commands.pin_locator import PinLocator + + locator = PinLocator() + pin_loc = locator.get_pin_location( + Path(schematic_path), component_ref, str(pin_number) + ) + if pin_loc is None: + return { + "success": False, + "message": ( + f"Could not locate pin {pin_number} on {component_ref}. " + "Check the reference and pin number." + ), + } + position = pin_loc + snapped_to_pin = {"component": component_ref, "pin": str(pin_number)} + logger.info( + f"Snapped label '{net_name}' to pin {component_ref}/{pin_number} at {position}" + ) + elif position is None: + return { + "success": False, + "message": ( + "Missing position. Either provide position [x, y] or " + "componentRef + pinNumber to snap to a pin endpoint." + ), + } # Use WireManager for S-expression manipulation success = WireManager.add_label( @@ -1541,13 +1583,22 @@ class KiCADInterface: orientation=orientation, ) - if success: - return { - "success": True, - "message": f"Added net label '{net_name}' at {position}", - } - else: + if not success: return {"success": False, "message": "Failed to add net label"} + + response: Dict[str, Any] = { + "success": True, + "message": f"Added net label '{net_name}' at {position}", + "actual_position": position, + } + if snapped_to_pin: + response["snapped_to_pin"] = snapped_to_pin + response["message"] = ( + f"Added net label '{net_name}' at exact pin endpoint " + f"{component_ref}/{pin_number} ({position[0]}, {position[1]})" + ) + return response + except Exception as e: logger.error(f"Error adding net label: {str(e)}") import traceback @@ -1574,17 +1625,10 @@ class KiCADInterface: return {"success": False, "message": "Missing required parameters"} # Use ConnectionManager with new WireManager integration - success = ConnectionManager.connect_to_net( + result = ConnectionManager.connect_to_net( Path(schematic_path), component_ref, pin_name, net_name ) - - if success: - return { - "success": True, - "message": f"Connected {component_ref}/{pin_name} to net '{net_name}'", - } - else: - return {"success": False, "message": "Failed to connect to net"} + return result except Exception as e: logger.error(f"Error connecting to net: {str(e)}") import traceback diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 94de080..2f8f429 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1399,7 +1399,15 @@ SCHEMATIC_TOOLS = [ { "name": "add_schematic_net_label", "title": "Add Net Label", - "description": "Adds a net label at exact coordinates on a schematic wire or pin endpoint. WARNING: x/y must match an existing wire endpoint or pin endpoint exactly — placing the label even 0.01mm away from a pin will result in an unconnected pin ERC error. To connect a component pin to a net by reference and pin number (recommended), use connect_to_net instead.", + "description": ( + "Add a net label to a schematic. " + "PREFERRED: supply componentRef + pinNumber to snap the label to the exact pin endpoint — " + "this guarantees an electrical connection. " + "Alternatively supply position [x, y], but the coordinates must match the pin endpoint exactly " + "(even a 0.01 mm offset breaks the connection). " + "The response includes actual_position (coordinates actually used) and snapped_to_pin " + "(present when a pin reference was resolved)." + ), "inputSchema": { "type": "object", "properties": { @@ -1411,21 +1419,45 @@ SCHEMATIC_TOOLS = [ "type": "string", "description": "Name of the net (e.g., VCC, GND, SDA)", }, - "x": {"type": "number", "description": "X coordinate on schematic"}, - "y": {"type": "number", "description": "Y coordinate on schematic"}, - "rotation": { + "position": { + "type": "array", + "items": {"type": "number"}, + "minItems": 2, + "maxItems": 2, + "description": "Position [x, y] for the label. Required when componentRef/pinNumber are not given.", + }, + "componentRef": { + "type": "string", + "description": "Component reference to snap label to (e.g. U1, R1). Use with pinNumber.", + }, + "pinNumber": { + "type": "string", + "description": "Pin number or name on componentRef (e.g. '1', 'GND'). Use with componentRef.", + }, + "labelType": { + "type": "string", + "enum": ["label", "global_label", "hierarchical_label"], + "description": "Label type (default: label)", + "default": "label", + }, + "orientation": { "type": "number", "description": "Rotation angle in degrees (0, 90, 180, 270)", "default": 0, }, }, - "required": ["schematicPath", "netName", "x", "y"], + "required": ["schematicPath", "netName"], }, }, { "name": "connect_to_net", "title": "Connect Pin to Net", - "description": "Intelligently connects a component pin to a named net, automatically routing wires as needed.", + "description": ( + "Connect a component pin to a named net by adding a wire stub and net label at the exact " + "pin endpoint. The response includes pin_location (exact pin coords), label_location " + "(where the label was placed), and wire_stub (the wire segment added) so you can confirm " + "the placement without a separate verification call." + ), "inputSchema": { "type": "object", "properties": { @@ -1433,11 +1465,11 @@ SCHEMATIC_TOOLS = [ "type": "string", "description": "Path to schematic file", }, - "reference": { + "componentRef": { "type": "string", "description": "Component reference designator (e.g., R1, U3)", }, - "pinNumber": { + "pinName": { "type": "string", "description": "Pin number or name on the component", }, @@ -1446,7 +1478,7 @@ SCHEMATIC_TOOLS = [ "description": "Name of the net to connect to", }, }, - "required": ["schematicPath", "reference", "pinNumber", "netName"], + "required": ["schematicPath", "componentRef", "pinName", "netName"], }, }, { diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index eec672a..a68ff83 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -325,20 +325,55 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp // Add net label server.tool( "add_schematic_net_label", - "Add a net label to the schematic", + "Add a net label to the schematic. " + + "PREFERRED: supply componentRef + pinNumber to snap the label to the exact pin endpoint — " + + "this guarantees an electrical connection. " + + "Alternatively supply position [x, y], but the coordinates must match the pin endpoint exactly " + + "(even a 0.01 mm offset breaks the connection). " + + "The response includes actual_position (coordinates actually used) and snapped_to_pin " + + "(present when a pin reference was resolved).", { schematicPath: z.string().describe("Path to the schematic file"), netName: z.string().describe("Name of the net (e.g., VCC, GND, SIGNAL_1)"), - position: z.array(z.number()).length(2).describe("Position [x, y] for the label"), + position: z + .array(z.number()) + .length(2) + .optional() + .describe( + "Position [x, y] for the label. Required when componentRef/pinNumber are not given.", + ), + componentRef: z + .string() + .optional() + .describe("Component reference to snap label to (e.g. U1, R1). Use with pinNumber."), + pinNumber: z + .union([z.string(), z.number()]) + .optional() + .describe( + "Pin number or name on componentRef to snap label to (e.g. '1', 'GND'). Use with componentRef.", + ), + labelType: z + .enum(["label", "global_label", "hierarchical_label"]) + .optional() + .describe("Label type (default: label)"), + orientation: z.number().optional().describe("Rotation angle 0/90/180/270 (default: 0)"), }, - async (args: { schematicPath: string; netName: string; position: number[] }) => { + async (args: { + schematicPath: string; + netName: string; + position?: number[]; + componentRef?: string; + pinNumber?: string | number; + labelType?: string; + orientation?: number; + }) => { const result = await callKicadScript("add_schematic_net_label", args); if (result.success) { return { content: [ { type: "text", - text: `Successfully added net label '${args.netName}' at position [${args.position}]`, + text: JSON.stringify(result, null, 2), }, ], }; @@ -358,7 +393,9 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp // Connect pin to net server.tool( "connect_to_net", - "Connect a component pin to a named net", + "Connect a component pin to a named net by adding a wire stub and net label at the exact pin endpoint. " + + "The response includes pin_location (exact pin coords), label_location (where the label was placed), " + + "and wire_stub (the wire segment added) so you can confirm the placement.", { schematicPath: z.string().describe("Path to the schematic file"), componentRef: z.string().describe("Component reference (e.g., U1, R1)"), @@ -377,7 +414,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp content: [ { type: "text", - text: `Successfully connected ${args.componentRef}/${args.pinName} to net '${args.netName}'`, + text: JSON.stringify(result, null, 2), }, ], }; diff --git a/tests/test_net_label_pin_snapping.py b/tests/test_net_label_pin_snapping.py new file mode 100644 index 0000000..d4b1f93 --- /dev/null +++ b/tests/test_net_label_pin_snapping.py @@ -0,0 +1,300 @@ +""" +Tests for net label pin-snapping and connect_to_net richer response. + +Covers: + - add_schematic_net_label with componentRef+pinNumber snaps to exact pin coords + - add_schematic_net_label without position and without pin ref returns error + - add_schematic_net_label with unknown pin returns an informative error + - connect_to_net returns pin_location, label_location, wire_stub on success + - connect_to_net returns success=False with message on failure + - connect_passthrough uses new dict return from connect_to_net correctly + - tool_schemas.py reflects new optional fields +""" + +import sys +import types +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Path setup – mirror existing test files +# --------------------------------------------------------------------------- + +PYTHON_DIR = Path(__file__).parent.parent / "python" +sys.path.insert(0, str(PYTHON_DIR)) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_iface() -> Any: + """Return a KiCADInterface instance with __init__ stubbed out.""" + for mod in ["pcbnew", "skip"]: + sys.modules.setdefault(mod, types.ModuleType(mod)) + from kicad_interface import KiCADInterface + + with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): + return KiCADInterface.__new__(KiCADInterface) + + +# --------------------------------------------------------------------------- +# 1. Schema tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestAddNetLabelSchema: + """Verify tool_schemas.py reflects the new add_schematic_net_label API.""" + + @pytest.fixture(autouse=True) + def load_schemas(self) -> Any: + from schemas.tool_schemas import SCHEMATIC_TOOLS + + self.tools = {t["name"]: t for t in SCHEMATIC_TOOLS} + + def test_position_is_optional(self) -> None: + schema = self.tools["add_schematic_net_label"]["inputSchema"] + assert "position" not in schema["required"], "position must not be required" + + def test_component_ref_property_exists(self) -> None: + schema = self.tools["add_schematic_net_label"]["inputSchema"] + assert "componentRef" in schema["properties"] + + def test_pin_number_property_exists(self) -> None: + schema = self.tools["add_schematic_net_label"]["inputSchema"] + assert "pinNumber" in schema["properties"] + + def test_only_schematic_path_and_net_name_required(self) -> None: + schema = self.tools["add_schematic_net_label"]["inputSchema"] + assert set(schema["required"]) == {"schematicPath", "netName"} + + +@pytest.mark.unit +class TestConnectToNetSchema: + """Verify tool_schemas.py reflects the richer connect_to_net description.""" + + @pytest.fixture(autouse=True) + def load_schemas(self) -> Any: + from schemas.tool_schemas import SCHEMATIC_TOOLS + + self.tools = {t["name"]: t for t in SCHEMATIC_TOOLS} + + def test_description_mentions_pin_location(self) -> None: + desc = self.tools["connect_to_net"]["description"] + assert "pin_location" in desc + + def test_description_mentions_label_location(self) -> None: + desc = self.tools["connect_to_net"]["description"] + assert "label_location" in desc + + +# --------------------------------------------------------------------------- +# 2. _handle_add_schematic_net_label – unit tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestHandleAddSchematicNetLabelSnapping: + """Unit tests for the pin-snapping path of _handle_add_schematic_net_label.""" + + @pytest.fixture(autouse=True) + def setup(self) -> Any: + self.iface = _make_iface() + + # -- happy-path: snap to pin ----------------------------------------- + + @patch("commands.wire_manager.WireManager.add_label", return_value=True) + @patch("commands.pin_locator.PinLocator.get_pin_location", return_value=[42.0, 13.5]) + def test_snap_uses_pin_coords(self, mock_pin_loc: Any, mock_add_label: Any) -> None: + result = self.iface._handle_add_schematic_net_label( + { + "schematicPath": "/fake/sch.kicad_sch", + "netName": "VCC", + "componentRef": "U1", + "pinNumber": "1", + } + ) + assert result["success"] is True + assert result["actual_position"] == [42.0, 13.5] + assert result["snapped_to_pin"] == {"component": "U1", "pin": "1"} + # WireManager.add_label must have been called with the pin coords + mock_add_label.assert_called_once() + call_args = mock_add_label.call_args + assert call_args[0][2] == [42.0, 13.5] # position positional arg + + @patch("commands.wire_manager.WireManager.add_label", return_value=True) + @patch("commands.pin_locator.PinLocator.get_pin_location", return_value=[10.0, 20.0]) + def test_snap_ignores_provided_position(self, mock_pin_loc: Any, mock_add_label: Any) -> None: + """If both position and componentRef/pinNumber are given, pin coords win.""" + result = self.iface._handle_add_schematic_net_label( + { + "schematicPath": "/fake/sch.kicad_sch", + "netName": "GND", + "position": [999.0, 999.0], + "componentRef": "R1", + "pinNumber": "2", + } + ) + assert result["success"] is True + assert result["actual_position"] == [10.0, 20.0] + + # -- error: pin not found -------------------------------------------- + + @patch("commands.pin_locator.PinLocator.get_pin_location", return_value=None) + def test_snap_unknown_pin_returns_error(self, mock_pin_loc: Any) -> None: + result = self.iface._handle_add_schematic_net_label( + { + "schematicPath": "/fake/sch.kicad_sch", + "netName": "VCC", + "componentRef": "U99", + "pinNumber": "99", + } + ) + assert result["success"] is False + assert "U99" in result["message"] or "pin" in result["message"].lower() + + # -- error: no position and no pin ref -------------------------------- + + def test_no_position_no_ref_returns_error(self) -> None: + result = self.iface._handle_add_schematic_net_label( + { + "schematicPath": "/fake/sch.kicad_sch", + "netName": "VCC", + } + ) + assert result["success"] is False + assert "position" in result["message"].lower() or "componentRef" in result["message"] + + # -- happy-path: explicit position ------------------------------------ + + @patch("commands.wire_manager.WireManager.add_label", return_value=True) + def test_explicit_position_used_when_no_ref(self, mock_add_label: Any) -> None: + result = self.iface._handle_add_schematic_net_label( + { + "schematicPath": "/fake/sch.kicad_sch", + "netName": "CLK", + "position": [55.0, 77.0], + } + ) + assert result["success"] is True + assert result["actual_position"] == [55.0, 77.0] + assert "snapped_to_pin" not in result + + # -- missing required params ----------------------------------------- + + def test_missing_net_name_returns_error(self) -> None: + result = self.iface._handle_add_schematic_net_label( + { + "schematicPath": "/fake/sch.kicad_sch", + "position": [10.0, 20.0], + } + ) + assert result["success"] is False + + +# --------------------------------------------------------------------------- +# 3. connect_to_net – unit tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestConnectToNetRicherResponse: + """connect_to_net now returns coordinates instead of a bare bool.""" + + @patch("commands.wire_manager.WireManager.add_label", return_value=True) + @patch("commands.wire_manager.WireManager.add_wire", return_value=True) + @patch("commands.pin_locator.PinLocator.get_pin_angle", return_value=0.0) + @patch("commands.pin_locator.PinLocator.get_pin_location", return_value=[100.0, 50.0]) + def test_success_returns_coordinates( + self, + mock_pin_loc: Any, + mock_pin_angle: Any, + mock_add_wire: Any, + mock_add_label: Any, + ) -> None: + from commands.connection_schematic import ConnectionManager + + result = ConnectionManager.connect_to_net(Path("/fake/sch.kicad_sch"), "U1", "5", "VCC") + assert result["success"] is True + assert result["pin_location"] == [100.0, 50.0] + assert "label_location" in result + assert "wire_stub" in result + # wire_stub is [[pin_x, pin_y], [label_x, label_y]] + assert result["wire_stub"][0] == [100.0, 50.0] + assert result["wire_stub"][1] == result["label_location"] + + @patch("commands.pin_locator.PinLocator.get_pin_location", return_value=None) + def test_unknown_pin_returns_failure_dict(self, mock_pin_loc: Any) -> None: + from commands.connection_schematic import ConnectionManager + + result = ConnectionManager.connect_to_net(Path("/fake/sch.kicad_sch"), "U99", "99", "VCC") + assert result["success"] is False + assert "message" in result + + @patch("commands.wire_manager.WireManager.add_wire", return_value=False) + @patch("commands.pin_locator.PinLocator.get_pin_angle", return_value=0.0) + @patch("commands.pin_locator.PinLocator.get_pin_location", return_value=[10.0, 20.0]) + def test_wire_failure_returns_failure_dict( + self, mock_pin_loc: Any, mock_pin_angle: Any, mock_add_wire: Any + ) -> None: + from commands.connection_schematic import ConnectionManager + + result = ConnectionManager.connect_to_net(Path("/fake/sch.kicad_sch"), "R1", "1", "GND") + assert result["success"] is False + assert "message" in result + + +# --------------------------------------------------------------------------- +# 4. connect_passthrough – uses dict return correctly +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestConnectPassthroughUsesDict: + """connect_passthrough must handle the dict returned by connect_to_net.""" + + @patch( + "commands.connection_schematic.ConnectionManager.connect_to_net", + return_value={ + "success": True, + "pin_location": [0, 0], + "label_location": [2.54, 0], + "wire_stub": [[0, 0], [2.54, 0]], + "message": "ok", + }, + ) + @patch( + "commands.pin_locator.PinLocator.get_all_symbol_pins", + side_effect=[{"1": [0.0, 0.0]}, {"1": [10.0, 10.0]}], + ) + def test_passthrough_succeeds_with_dict_return(self, mock_pins: Any, mock_connect: Any) -> None: + from commands.connection_schematic import ConnectionManager + + result = ConnectionManager.connect_passthrough( + Path("/fake/sch.kicad_sch"), "J1", "J2", net_prefix="PIN" + ) + assert len(result["connected"]) == 1 + assert len(result["failed"]) == 0 + + @patch( + "commands.connection_schematic.ConnectionManager.connect_to_net", + return_value={"success": False, "message": "pin not found"}, + ) + @patch( + "commands.pin_locator.PinLocator.get_all_symbol_pins", + side_effect=[{"1": [0.0, 0.0]}, {"1": [10.0, 10.0]}], + ) + def test_passthrough_records_failure_with_dict_return( + self, mock_pins: Any, mock_connect: Any + ) -> None: + from commands.connection_schematic import ConnectionManager + + result = ConnectionManager.connect_passthrough( + Path("/fake/sch.kicad_sch"), "J1", "J2", net_prefix="PIN" + ) + assert len(result["failed"]) >= 1 From 94b234a36e6a0fe792c1212bfae7061b94ea333d Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 12 Apr 2026 14:33:48 +0100 Subject: [PATCH 59/74] docs: update SCHEMATIC_TOOLS_REFERENCE for pin-snapping and coord feedback - add_schematic_net_label: document new optional componentRef/pinNumber snap-to-pin mode, labelType/orientation params, response fields (actual_position, snapped_to_pin), and updated position to optional - connect_to_net: document new response fields (pin_location, label_location, wire_stub) - get_schematic_pin_locations: update cross-reference to reflect that direct pin snapping is now available in add_schematic_net_label Co-Authored-By: Claude Sonnet 4.6 --- docs/SCHEMATIC_TOOLS_REFERENCE.md | 45 +++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/docs/SCHEMATIC_TOOLS_REFERENCE.md b/docs/SCHEMATIC_TOOLS_REFERENCE.md index a1d7823..e57eea1 100644 --- a/docs/SCHEMATIC_TOOLS_REFERENCE.md +++ b/docs/SCHEMATIC_TOOLS_REFERENCE.md @@ -120,15 +120,34 @@ Connect two component pins with a wire. Use this for individual connections betw Add a net label to the schematic. -| 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 | Yes | Position [x, y] for the label | +**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. +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 | | ------------- | ------ | -------- | ---------------------------------- | @@ -137,7 +156,17 @@ Connect a component pin to a named net. | pinName | string | Yes | Pin name/number to connect | | netName | string | Yes | Name of the net to connect to | -**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.54mm (0.1 inch, standard grid spacing). +**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 @@ -155,7 +184,7 @@ Connects all pins of a source connector (e.g. J1) to matching pins of a target c ### get_schematic_pin_locations -Returns the exact x/y coordinates of every pin on a schematic component. Use this before add_schematic_net_label to place labels correctly on pin endpoints. +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 | | ------------- | ------ | -------- | ------------------------------------------------ | From 58bb08a252439100202adb03d6fd8fead1b7d664 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 12 Apr 2026 14:44:12 +0100 Subject: [PATCH 60/74] feat: add find_orphaned_wires schematic analysis tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detects wire segments with at least one dangling endpoint — an endpoint not connected to a component pin, net label, or another wire. These cause ERC 'wire end unconnected' violations and are a common symptom of incomplete routing or stray stub wires. Algorithm uses exact KiCad IU (10 000 IU/mm) coordinate matching, consistent with wire_connectivity.py: 1. Build an endpoint-frequency map for all wires (IU precision) 2. Collect anchored IU points: component pins (via PinLocator), net labels / global_labels, power symbol pins (via _parse_virtual_connections) 3. An endpoint is dangling when it is touched by exactly one wire AND is not an anchored point; the containing wire is reported Does not require the KiCad UI to be running. Changes: python/commands/schematic_analysis.py — find_orphaned_wires() function python/kicad_interface.py — handler + route registration python/schemas/tool_schemas.py — MCP schema entry src/tools/schematic.ts — TypeScript server.tool() call tests/test_schematic_analysis.py — 7 integration tests Co-Authored-By: Claude Sonnet 4.6 --- python/commands/schematic_analysis.py | 102 ++++++++++++++++++++ python/kicad_interface.py | 26 +++++ python/schemas/tool_schemas.py | 20 ++++ src/tools/schematic.ts | 32 +++++++ tests/test_schematic_analysis.py | 131 ++++++++++++++++++++++++++ 5 files changed, 311 insertions(+) diff --git a/python/commands/schematic_analysis.py b/python/commands/schematic_analysis.py index 975b005..cd269a3 100644 --- a/python/commands/schematic_analysis.py +++ b/python/commands/schematic_analysis.py @@ -7,12 +7,15 @@ 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") @@ -872,3 +875,102 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]: ) 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)} diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 8f2d3d2..e77d2d3 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -403,6 +403,7 @@ class KiCADInterface: "find_overlapping_elements": self._handle_find_overlapping_elements, "get_elements_in_region": self._handle_get_elements_in_region, "find_wires_crossing_symbols": self._handle_find_wires_crossing_symbols, + "find_orphaned_wires": self._handle_find_orphaned_wires, "import_svg_logo": self._handle_import_svg_logo, # UI/Process management commands "check_kicad_ui": self._handle_check_kicad_ui, @@ -2936,6 +2937,31 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} + def _handle_find_orphaned_wires(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Find wire segments with at least one dangling (unconnected) endpoint""" + logger.info("Finding orphaned wires in schematic") + try: + from pathlib import Path + + from commands.schematic_analysis import find_orphaned_wires + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + + result = find_orphaned_wires(Path(schematic_path)) + return { + "success": True, + **result, + "message": f"Found {result['count']} orphaned wire(s)", + } + except Exception as e: + logger.error(f"Error finding orphaned wires: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + def _handle_import_svg_logo(self, params: Dict[str, Any]) -> Dict[str, Any]: """Import an SVG file as PCB graphic polygons on the silkscreen""" logger.info("Importing SVG logo into PCB") diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 2f8f429..3426228 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1794,6 +1794,26 @@ SCHEMATIC_TOOLS = [ "required": ["schematicPath"], }, }, + { + "name": "find_orphaned_wires", + "title": "Find Orphaned Wires", + "description": ( + "Find wire segments with at least one dangling endpoint — an endpoint not connected " + "to a component pin, net label, or another wire. " + "Orphaned wires cause ERC 'wire end unconnected' errors and indicate routing mistakes. " + "Does not require the KiCad UI to be running." + ), + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the .kicad_sch schematic file", + } + }, + "required": ["schematicPath"], + }, + }, ] # ============================================================================= diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index a68ff83..d2ce2d5 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -1360,4 +1360,36 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }; }, ); + + // Find orphaned wires + server.tool( + "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.", + { + schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), + }, + async (args: { schematicPath: string }) => { + const result = await callKicadScript("find_orphaned_wires", args); + if (result.success) { + const wires: any[] = result.orphaned_wires || []; + if (wires.length === 0) { + return { content: [{ type: "text", text: "No orphaned wires found." }] }; + } + const lines: string[] = [`Found ${wires.length} orphaned wire(s):\n`]; + wires.slice(0, 50).forEach((w: any) => { + const dangling = w.dangling_ends.map((e: any) => `(${e.x}, ${e.y})`).join(", "); + lines.push( + ` wire (${w.start.x}, ${w.start.y})→(${w.end.x}, ${w.end.y}) dangling end(s): ${dangling}`, + ); + }); + if (wires.length > 50) lines.push(` ... and ${wires.length - 50} more`); + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + return { + content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }], + }; + }, + ); } diff --git a/tests/test_schematic_analysis.py b/tests/test_schematic_analysis.py index d7efab3..a54cbc2 100644 --- a/tests/test_schematic_analysis.py +++ b/tests/test_schematic_analysis.py @@ -34,6 +34,7 @@ from commands.schematic_analysis import ( _point_in_rect, _transform_local_point, compute_symbol_bbox, + find_orphaned_wires, find_overlapping_elements, find_wires_crossing_symbols, get_elements_in_region, @@ -946,3 +947,133 @@ class TestIntegrationGraphicsBbox: assert max(xs) == pytest.approx(1.27) assert min(ys) == pytest.approx(-1.27) assert max(ys) == pytest.approx(1.27) + + +# --------------------------------------------------------------------------- +# TestFindOrphanedWires +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestFindOrphanedWires: + """Integration tests for find_orphaned_wires.""" + + def test_empty_schematic_no_orphans(self) -> None: + """A schematic with no wires has no orphans.""" + tmp = _make_temp_schematic() + result = find_orphaned_wires(tmp) + assert result["count"] == 0 + assert result["orphaned_wires"] == [] + + def test_isolated_wire_is_orphaned(self) -> None: + """A single wire floating in empty space has both endpoints dangling.""" + extra = """ + (wire (pts (xy 10 20) (xy 30 20)) + (stroke (width 0) (type default)) + (uuid "w-isolated")) + """ + tmp = _make_temp_schematic(extra) + result = find_orphaned_wires(tmp) + assert result["count"] == 1 + w = result["orphaned_wires"][0] + assert len(w["dangling_ends"]) == 2 + + def test_wire_between_two_labels_not_orphaned(self) -> None: + """A wire whose endpoints both land on net labels is fully connected.""" + extra = """ + (label "VCC" (at 10 20 0) + (effects (font (size 1.27 1.27)) (justify left bottom)) + (uuid "lbl1")) + (label "GND" (at 30 20 0) + (effects (font (size 1.27 1.27)) (justify left bottom)) + (uuid "lbl2")) + (wire (pts (xy 10 20) (xy 30 20)) + (stroke (width 0) (type default)) + (uuid "w-label-to-label")) + """ + tmp = _make_temp_schematic(extra) + result = find_orphaned_wires(tmp) + assert result["count"] == 0 + + def test_wire_with_one_dangling_end(self) -> None: + """A wire from a label to empty space has exactly one dangling end.""" + extra = """ + (label "SIG" (at 10 20 0) + (effects (font (size 1.27 1.27)) (justify left bottom)) + (uuid "lbl-sig")) + (wire (pts (xy 10 20) (xy 40 20)) + (stroke (width 0) (type default)) + (uuid "w-stub")) + """ + tmp = _make_temp_schematic(extra) + result = find_orphaned_wires(tmp) + assert result["count"] == 1 + w = result["orphaned_wires"][0] + assert len(w["dangling_ends"]) == 1 + # The dangling end is the far end at x=40, not the label end at x=10 + assert w["dangling_ends"][0]["x"] == pytest.approx(40.0) + + def test_connected_wires_not_orphaned(self) -> None: + """Two wires sharing an endpoint are connected — neither is orphaned + provided the remaining ends are also anchored.""" + # Wire A: (10,20)→(20,20), Wire B: (20,20)→(30,20) + # Both share endpoint at (20,20). Anchor the outer ends with labels. + extra = """ + (label "A" (at 10 20 0) + (effects (font (size 1.27 1.27)) (justify left bottom)) + (uuid "lbl-a")) + (label "B" (at 30 20 0) + (effects (font (size 1.27 1.27)) (justify left bottom)) + (uuid "lbl-b")) + (wire (pts (xy 10 20) (xy 20 20)) + (stroke (width 0) (type default)) + (uuid "w1")) + (wire (pts (xy 20 20) (xy 30 20)) + (stroke (width 0) (type default)) + (uuid "w2")) + """ + tmp = _make_temp_schematic(extra) + result = find_orphaned_wires(tmp) + assert result["count"] == 0 + + def test_t_junction_shared_endpoint_not_dangling(self) -> None: + """Three wires meeting at a single point — the shared vertex is connected + to multiple wires and must not be reported as dangling.""" + # Three wires all touching (50, 50). Outer ends get labels. + extra = """ + (label "L1" (at 30 50 0) + (effects (font (size 1.27 1.27)) (justify left bottom)) + (uuid "lbl-t1")) + (label "L2" (at 70 50 0) + (effects (font (size 1.27 1.27)) (justify left bottom)) + (uuid "lbl-t2")) + (label "L3" (at 50 30 0) + (effects (font (size 1.27 1.27)) (justify left bottom)) + (uuid "lbl-t3")) + (wire (pts (xy 30 50) (xy 50 50)) + (stroke (width 0) (type default)) + (uuid "wt1")) + (wire (pts (xy 50 50) (xy 70 50)) + (stroke (width 0) (type default)) + (uuid "wt2")) + (wire (pts (xy 50 50) (xy 50 30)) + (stroke (width 0) (type default)) + (uuid "wt3")) + """ + tmp = _make_temp_schematic(extra) + result = find_orphaned_wires(tmp) + assert result["count"] == 0 + + def test_multiple_isolated_wires_all_reported(self) -> None: + """Two separate isolated wires are both reported.""" + extra = """ + (wire (pts (xy 10 10) (xy 20 10)) + (stroke (width 0) (type default)) + (uuid "wi1")) + (wire (pts (xy 50 50) (xy 60 50)) + (stroke (width 0) (type default)) + (uuid "wi2")) + """ + tmp = _make_temp_schematic(extra) + result = find_orphaned_wires(tmp) + assert result["count"] == 2 From 93876833688b42ce29e3dadd2663a0b3e2e3d747 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 12 Apr 2026 15:03:35 +0100 Subject: [PATCH 61/74] feat: add snap_to_grid schematic tool Adds a new MCP tool that snaps wire endpoints, junction positions, and net label coordinates to the nearest grid point (default 2.54 mm). Off-grid coordinates cause wires that appear visually connected to fail ERC checks because KiCAD uses exact IU integer matching internally; this tool eliminates that class of error before running ERC. - python/commands/schematic_snap.py: core snap logic with in-place sexp mutation - python/kicad_interface.py: route + handler - python/schemas/tool_schemas.py: JSON schema (gridSize, elements params) - src/tools/schematic.ts: TypeScript MCP tool registration - tests/test_snap_to_grid.py: 20 unit + integration tests (all passing) Co-Authored-By: Claude Sonnet 4.6 --- python/commands/schematic_snap.py | 199 ++++++++++++++++++++++++++ python/kicad_interface.py | 33 +++++ python/schemas/tool_schemas.py | 46 ++++++ src/tools/schematic.ts | 32 +++++ tests/test_snap_to_grid.py | 229 ++++++++++++++++++++++++++++++ 5 files changed, 539 insertions(+) create mode 100644 python/commands/schematic_snap.py create mode 100644 tests/test_snap_to_grid.py diff --git a/python/commands/schematic_snap.py b/python/commands/schematic_snap.py new file mode 100644 index 0000000..fa3568a --- /dev/null +++ b/python/commands/schematic_snap.py @@ -0,0 +1,199 @@ +""" +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 eeschema grid is 2.54 mm (0.1 inch). 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 = 2.54 + +# 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 2.54 mm = 0.1 inch). + 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, + } diff --git a/python/kicad_interface.py b/python/kicad_interface.py index e77d2d3..5600828 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -404,6 +404,7 @@ class KiCADInterface: "get_elements_in_region": self._handle_get_elements_in_region, "find_wires_crossing_symbols": self._handle_find_wires_crossing_symbols, "find_orphaned_wires": self._handle_find_orphaned_wires, + "snap_to_grid": self._handle_snap_to_grid, "import_svg_logo": self._handle_import_svg_logo, # UI/Process management commands "check_kicad_ui": self._handle_check_kicad_ui, @@ -2962,6 +2963,38 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} + def _handle_snap_to_grid(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Snap schematic element coordinates to the nearest grid point""" + logger.info("Snapping schematic elements to grid") + try: + from pathlib import Path + + from commands.schematic_snap import snap_to_grid + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + + grid_size = float(params.get("gridSize", 2.54)) + elements = params.get("elements") # None → defaults inside snap_to_grid + + result = snap_to_grid(Path(schematic_path), grid_size=grid_size, elements=elements) + total = result["snapped"] + result["already_on_grid"] + return { + "success": True, + **result, + "message": ( + f"Snapped {result['snapped']} element(s) to {grid_size} mm grid " + f"({result['already_on_grid']} of {total} were already on grid)" + ), + } + except Exception as e: + logger.error(f"Error snapping to grid: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + def _handle_import_svg_logo(self, params: Dict[str, Any]) -> Dict[str, Any]: """Import an SVG file as PCB graphic polygons on the silkscreen""" logger.info("Importing SVG logo into PCB") diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 3426228..dad7d47 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1814,6 +1814,52 @@ SCHEMATIC_TOOLS = [ "required": ["schematicPath"], }, }, + { + "name": "snap_to_grid", + "title": "Snap Schematic Elements to Grid", + "description": ( + "Snap schematic element coordinates to the nearest grid point. " + "KiCAD eeschema uses exact integer matching (10 000 IU/mm) for connectivity, " + "so even a sub-pixel coordinate offset will make wires appear connected visually " + "but fail ERC checks. Running this tool before ERC eliminates that class of error. " + "Modifies the .kicad_sch file in place. " + "Does not require the KiCAD UI to be running." + ), + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the .kicad_sch schematic file", + }, + "gridSize": { + "type": "number", + "description": ( + "Grid spacing in mm. " + "Standard KiCAD schematic grid is 2.54 mm (0.1 inch). " + "Use 1.27 mm for high-density layouts. " + "Defaults to 2.54." + ), + "default": 2.54, + }, + "elements": { + "type": "array", + "description": ( + "Element types to snap. " + 'Valid values: "wires", "junctions", "labels", "components". ' + 'Defaults to ["wires", "junctions", "labels"] when omitted. ' + '"components" is opt-in because moving a component without re-routing ' + "its wires will create new mismatches." + ), + "items": { + "type": "string", + "enum": ["wires", "junctions", "labels", "components"], + }, + }, + }, + "required": ["schematicPath"], + }, + }, ] # ============================================================================= diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index d2ce2d5..d32e33f 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -1392,4 +1392,36 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }; }, ); + + // Snap schematic elements to grid + server.tool( + "snap_to_grid", + "Snap schematic element coordinates to the nearest grid point. " + + "KiCAD uses exact integer matching for connectivity, so off-grid coordinates cause wires " + + "that look connected to fail ERC checks. " + + "Modifies the .kicad_sch file in place. Does not require the KiCAD UI to be running.", + { + schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), + gridSize: z + .number() + .optional() + .describe("Grid spacing in mm (default: 2.54 — standard KiCAD schematic grid)"), + elements: z + .array(z.enum(["wires", "junctions", "labels", "components"])) + .optional() + .describe( + 'Element types to snap (default: ["wires", "junctions", "labels"]). ' + + '"components" is opt-in — moving a component without re-routing wires creates new mismatches.', + ), + }, + async (args: { schematicPath: string; gridSize?: number; elements?: string[] }) => { + const result = await callKicadScript("snap_to_grid", args); + if (result.success) { + return { content: [{ type: "text", text: result.message }] }; + } + return { + content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }], + }; + }, + ); } diff --git a/tests/test_snap_to_grid.py b/tests/test_snap_to_grid.py new file mode 100644 index 0000000..4089801 --- /dev/null +++ b/tests/test_snap_to_grid.py @@ -0,0 +1,229 @@ +""" +Tests for the snap_to_grid schematic tool. + +Unit tests cover the snapping math and per-element-type logic using synthetic +S-expressions. Integration tests run against real .kicad_sch files created +from the empty template. +""" + +import shutil +import sys +import tempfile +import uuid +from pathlib import Path + +import pytest +import sexpdata +from sexpdata import Symbol + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python")) + +from commands.schematic_snap import _is_on_grid, _snap_mm, snap_to_grid + +# --------------------------------------------------------------------------- +# Shared fixture helpers +# --------------------------------------------------------------------------- + +TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "python" / "templates" / "empty.kicad_sch" + + +def _make_temp_schematic(extra_sexp: str = "") -> Path: + """Copy empty.kicad_sch to a temp dir, optionally injecting extra S-expressions.""" + tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" + shutil.copy(TEMPLATE_PATH, tmp) + if extra_sexp: + content = tmp.read_text(encoding="utf-8") + idx = content.rfind(")") + content = content[:idx] + "\n" + extra_sexp + "\n)" + tmp.write_text(content, encoding="utf-8") + return tmp + + +def _wire_sexp(x1: float, y1: float, x2: float, y2: float) -> str: + u = str(uuid.uuid4()) + return ( + f"(wire (pts (xy {x1} {y1}) (xy {x2} {y2}))\n" + f" (stroke (width 0) (type default))\n" + f' (uuid "{u}"))' + ) + + +def _junction_sexp(x: float, y: float) -> str: + u = str(uuid.uuid4()) + return f'(junction (at {x} {y}) (diameter 0) (color 0 0 0 0) (uuid "{u}"))' + + +def _label_sexp(name: str, x: float, y: float, angle: float = 0) -> str: + u = str(uuid.uuid4()) + return ( + f'(label "{name}" (at {x} {y} {angle})\n' + f" (effects (font (size 1.27 1.27)) (justify left bottom))\n" + f' (uuid "{u}"))' + ) + + +# --------------------------------------------------------------------------- +# Unit tests — pure math, no file I/O +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestSnapMath: + def test_snap_mm_already_on_grid(self): + assert _snap_mm(2.54, 2.54) == pytest.approx(2.54) + + def test_snap_mm_rounds_up(self): + # 2.55 is closer to 5.08 than to 2.54 (distance 2.53 vs 0.01) + # Actually 2.55 / 2.54 = 1.0039..., rounds to 1 → 2.54 + assert _snap_mm(2.55, 2.54) == pytest.approx(2.54) + + def test_snap_mm_rounds_to_next(self): + # 3.81 / 2.54 = 1.5 → rounds to 2 → 5.08 + assert _snap_mm(3.81, 2.54) == pytest.approx(5.08) + + def test_snap_mm_negative(self): + assert _snap_mm(-2.51, 2.54) == pytest.approx(-2.54) + + def test_snap_mm_zero(self): + assert _snap_mm(0.0, 2.54) == pytest.approx(0.0) + + def test_snap_mm_small_grid(self): + assert _snap_mm(1.28, 1.27) == pytest.approx(1.27) + + def test_is_on_grid_true(self): + assert _is_on_grid(2.54, 2.54) + assert _is_on_grid(0.0, 2.54) + assert _is_on_grid(5.08, 2.54) + + def test_is_on_grid_false(self): + assert not _is_on_grid(2.55, 2.54) + assert not _is_on_grid(1.0, 2.54) + + def test_snap_invalid_grid_raises(self): + with pytest.raises(ValueError, match="grid_size must be positive"): + snap_to_grid(Path("/nonexistent"), grid_size=-1.0) + + def test_snap_unknown_element_raises(self): + with pytest.raises(ValueError, match="Unknown element type"): + snap_to_grid(Path("/nonexistent"), elements=["bogus"]) + + +# --------------------------------------------------------------------------- +# Integration tests — real .kicad_sch files +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestSnapWires: + def test_off_grid_wire_is_snapped(self): + path = _make_temp_schematic(_wire_sexp(2.51, 5.03, 7.56, 5.03)) + result = snap_to_grid(path, grid_size=2.54, elements=["wires"]) + assert result["snapped"] >= 1 + + # Verify coordinates in the written file + data = sexpdata.loads(path.read_text(encoding="utf-8")) + wire = next( + item for item in data if isinstance(item, list) and item and item[0] == Symbol("wire") + ) + pts = next(sub for sub in wire[1:] if isinstance(sub, list) and sub[0] == Symbol("pts")) + xy_pairs = [sub for sub in pts[1:] if isinstance(sub, list) and sub[0] == Symbol("xy")] + for pt in xy_pairs: + assert _is_on_grid(float(pt[1]), 2.54), f"x={pt[1]} not on grid" + assert _is_on_grid(float(pt[2]), 2.54), f"y={pt[2]} not on grid" + + def test_on_grid_wire_counts_as_already_on_grid(self): + path = _make_temp_schematic(_wire_sexp(2.54, 5.08, 7.62, 5.08)) + result = snap_to_grid(path, grid_size=2.54, elements=["wires"]) + assert result["snapped"] == 0 + assert result["already_on_grid"] >= 1 + + def test_wires_not_snapped_when_excluded(self): + path = _make_temp_schematic(_wire_sexp(2.51, 5.03, 7.56, 5.03)) + result = snap_to_grid(path, grid_size=2.54, elements=["junctions"]) + assert result["snapped"] == 0 + + +@pytest.mark.integration +class TestSnapJunctions: + def test_off_grid_junction_is_snapped(self): + path = _make_temp_schematic(_junction_sexp(2.51, 2.51)) + result = snap_to_grid(path, grid_size=2.54, elements=["junctions"]) + assert result["snapped"] >= 1 + + data = sexpdata.loads(path.read_text(encoding="utf-8")) + junc = next( + item + for item in data + if isinstance(item, list) and item and item[0] == Symbol("junction") + ) + at = next(sub for sub in junc[1:] if isinstance(sub, list) and sub[0] == Symbol("at")) + assert _is_on_grid(float(at[1]), 2.54) + assert _is_on_grid(float(at[2]), 2.54) + + def test_on_grid_junction_unchanged(self): + path = _make_temp_schematic(_junction_sexp(2.54, 2.54)) + result = snap_to_grid(path, grid_size=2.54, elements=["junctions"]) + assert result["snapped"] == 0 + assert result["already_on_grid"] >= 1 + + +@pytest.mark.integration +class TestSnapLabels: + def test_off_grid_label_snapped_preserves_angle(self): + path = _make_temp_schematic(_label_sexp("NET_A", 2.51, 5.03, angle=90)) + result = snap_to_grid(path, grid_size=2.54, elements=["labels"]) + assert result["snapped"] >= 1 + + data = sexpdata.loads(path.read_text(encoding="utf-8")) + lbl = next( + item for item in data if isinstance(item, list) and item and item[0] == Symbol("label") + ) + at = next(sub for sub in lbl[1:] if isinstance(sub, list) and sub[0] == Symbol("at")) + assert _is_on_grid(float(at[1]), 2.54), f"x={at[1]} not on grid" + assert _is_on_grid(float(at[2]), 2.54), f"y={at[2]} not on grid" + # angle must be preserved + assert float(at[3]) == pytest.approx(90.0) + + def test_on_grid_label_unchanged(self): + path = _make_temp_schematic(_label_sexp("NET_B", 2.54, 5.08)) + result = snap_to_grid(path, grid_size=2.54, elements=["labels"]) + assert result["snapped"] == 0 + + +@pytest.mark.integration +class TestSnapDefaults: + def test_default_elements_snaps_wires_and_junctions_and_labels(self): + extra = "\n".join( + [ + _wire_sexp(2.51, 5.03, 7.56, 5.03), + _junction_sexp(2.51, 2.51), + _label_sexp("VCC", 2.51, 2.51), + ] + ) + path = _make_temp_schematic(extra) + result = snap_to_grid(path) # defaults: grid=2.54, elements=None + assert result["snapped"] >= 3 + assert result["grid_size"] == pytest.approx(2.54) + + def test_idempotent(self): + path = _make_temp_schematic(_wire_sexp(2.51, 5.03, 7.56, 5.03)) + snap_to_grid(path, grid_size=2.54) + content_after_first = path.read_text(encoding="utf-8") + snap_to_grid(path, grid_size=2.54) + content_after_second = path.read_text(encoding="utf-8") + assert content_after_first == content_after_second + + def test_custom_grid(self): + # 1.27 mm grid — wire at 1.25 should snap to 1.27 + path = _make_temp_schematic(_wire_sexp(1.25, 1.25, 2.51, 2.51)) + result = snap_to_grid(path, grid_size=1.27) + assert result["snapped"] >= 1 + data = sexpdata.loads(path.read_text(encoding="utf-8")) + wire = next( + item for item in data if isinstance(item, list) and item and item[0] == Symbol("wire") + ) + pts = next(sub for sub in wire[1:] if isinstance(sub, list) and sub[0] == Symbol("pts")) + xy_pairs = [sub for sub in pts[1:] if isinstance(sub, list) and sub[0] == Symbol("xy")] + for pt in xy_pairs: + assert _is_on_grid(float(pt[1]), 1.27), f"x={pt[1]} not on 1.27 grid" + assert _is_on_grid(float(pt[2]), 1.27), f"y={pt[2]} not on 1.27 grid" From 5f3c20d308ef93328390309dd5b6184c1bd4d658 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 12 Apr 2026 15:19:14 +0100 Subject: [PATCH 62/74] feat: add get_pin_net tool for direct net/pin queries Answers "what net is pin X of component Y on?" without requiring callers to triangulate from list_schematic_nets or know a wire coordinate first. Accepts either {reference, pin} (resolved via PinLocator) or {x, y} coordinate. Returns net label name (or null for unnamed nets), all connected pins, wire segments, and the resolved query point. Co-Authored-By: Claude Sonnet 4.6 --- python/commands/wire_connectivity.py | 58 +++++ python/kicad_interface.py | 65 ++++++ python/schemas/tool_schemas.py | 38 ++++ src/tools/schematic.ts | 57 +++++ tests/test_get_pin_net.py | 325 +++++++++++++++++++++++++++ 5 files changed, 543 insertions(+) create mode 100644 tests/test_get_pin_net.py diff --git a/python/commands/wire_connectivity.py b/python/commands/wire_connectivity.py index d1728c9..9bbfbbb 100644 --- a/python/commands/wire_connectivity.py +++ b/python/commands/wire_connectivity.py @@ -273,3 +273,61 @@ def get_wire_connections( pins = _find_pins_on_net(net_points, schematic_path, schematic) return {"pins": pins, "wires": wires_out} + + +def get_pin_net(schematic: Any, schematic_path: str, x_mm: float, y_mm: float) -> Optional[Dict]: + """Return the net name and all connected pins for the wire network at (x_mm, y_mm). + + 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 at the query point. + """ + all_wires = _parse_wires(schematic) + if not all_wires: + return {"net": None, "pins": [], "wires": [], "query_point": {"x": x_mm, "y": y_mm}} + + 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 + ] + + pins: List[Dict] = [] + if hasattr(schematic, "symbol"): + pins = _find_pins_on_net(net_points, schematic_path, schematic) + + return {"net": net, "pins": pins, "wires": wires_out, "query_point": {"x": x_mm, "y": y_mm}} diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 5600828..a061670 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -382,6 +382,7 @@ class KiCADInterface: "get_schematic_pin_locations": self._handle_get_schematic_pin_locations, "get_net_connections": self._handle_get_net_connections, "get_wire_connections": self._handle_get_wire_connections, + "get_pin_net": self._handle_get_pin_net, "run_erc": self._handle_run_erc, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, @@ -2503,6 +2504,70 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} + def _handle_get_pin_net(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Find net name and all connected pins for a reference+pin or coordinate query""" + logger.info("Getting pin net") + try: + from pathlib import Path + + from commands.pin_locator import PinLocator + from commands.wire_connectivity import get_pin_net + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "Missing required parameter: schematicPath"} + + reference = params.get("reference") + pin = params.get("pin") + x = params.get("x") + y = params.get("y") + + has_ref_pin = reference is not None and pin is not None + has_coords = x is not None and y is not None + + if not has_ref_pin and not has_coords: + return { + "success": False, + "message": "Must supply either {reference, pin} or {x, y}", + } + + if has_ref_pin: + location = PinLocator().get_pin_location(Path(schematic_path), reference, str(pin)) + if location is None: + return { + "success": False, + "message": f"Pin {pin} not found on {reference}", + } + x, y = location[0], location[1] + else: + try: + x, y = float(x), float(y) + except (TypeError, ValueError): + return {"success": False, "message": "Parameters x and y must be numeric"} + + schematic = SchematicManager.load_schematic(schematic_path) + if not schematic: + return {"success": False, "message": "Failed to load schematic"} + + if not hasattr(schematic, "wire"): + return {"success": False, "message": "Schematic has no wires"} + + result = get_pin_net(schematic, schematic_path, x, y) + if result is None: + return { + "success": False, + "message": f"No wire found at ({x},{y}) — pin may not be connected", + } + + return {"success": True, **result} + + except Exception as e: + logger.error(f"Error getting pin net: {str(e)}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + def _handle_run_erc(self, params: Dict[str, Any]) -> Dict[str, Any]: """Run Electrical Rules Check on a schematic via kicad-cli""" logger.info("Running ERC on schematic") diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index dad7d47..33eee7b 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1523,6 +1523,44 @@ SCHEMATIC_TOOLS = [ "required": ["schematicPath", "x", "y"], }, }, + { + "name": "get_pin_net", + "title": "Get Pin Net", + "description": ( + "Returns the net name and all connected component pins for a query. " + "Accepts either a component reference + pin number (e.g. reference='U1', pin='3') " + "or a schematic coordinate (x, y in mm). " + "If a net label or power symbol is reachable from the query point, its name is " + "returned as 'net'; otherwise 'net' is null (unnamed net). " + "The 'pins' list contains every component pin on that same net." + ), + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the schematic file (.kicad_sch)", + }, + "reference": { + "type": "string", + "description": "Component reference (e.g. U1, R1). Pair with pin.", + }, + "pin": { + "type": "string", + "description": "Pin number or name (e.g. '3', 'SDA'). Pair with reference.", + }, + "x": { + "type": "number", + "description": "X coordinate of a wire endpoint in mm. Pair with y.", + }, + "y": { + "type": "number", + "description": "Y coordinate of a wire endpoint in mm. Pair with x.", + }, + }, + "required": ["schematicPath"], + }, + }, { "name": "get_schematic_pin_locations", "title": "Get Schematic Pin Locations", diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index d32e33f..1d2a53c 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -1424,4 +1424,61 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }; }, ); + + server.tool( + "get_pin_net", + "Returns the net name and all connected pins for a component pin (reference + pin number) " + + "or a schematic coordinate (x, y in mm). Use this instead of list_schematic_nets + " + + "get_wire_connections when you want to answer 'what net is pin 3 of U1 on?'. " + + "Returns net=null for unnamed (unlabelled) nets.", + { + schematicPath: z.string().describe("Path to the schematic file"), + reference: z + .string() + .optional() + .describe("Component reference (e.g. U1, R1). Pair with pin."), + pin: z + .string() + .optional() + .describe("Pin number or name (e.g. '3', 'SDA'). Pair with reference."), + x: z.number().optional().describe("X coordinate of a wire endpoint in mm. Pair with y."), + y: z.number().optional().describe("Y coordinate of a wire endpoint in mm. Pair with x."), + }, + async (args: { + schematicPath: string; + reference?: string; + pin?: string; + x?: number; + y?: number; + }) => { + const result = await callKicadScript("get_pin_net", args); + if (result.success) { + const netLabel = result.net ?? "(unnamed)"; + const pinList = (result.pins ?? []) + .map((p: any) => ` - ${p.component}/${p.pin}`) + .join("\n"); + const qp = result.query_point; + return { + content: [ + { + type: "text", + text: + `Net: ${netLabel}\n` + + `Query point: (${qp?.x ?? args.x}, ${qp?.y ?? args.y})\n` + + `Connected pins:\n${pinList || " (none found)"}`, + }, + ], + }; + } else { + return { + content: [ + { + type: "text", + text: `Failed to get pin net: ${result.message || "Unknown error"}`, + }, + ], + }; + } + }, + ); } diff --git a/tests/test_get_pin_net.py b/tests/test_get_pin_net.py new file mode 100644 index 0000000..f9d43aa --- /dev/null +++ b/tests/test_get_pin_net.py @@ -0,0 +1,325 @@ +""" +Tests for the get_pin_net tool and its handler. + +Covers: + - Schema shape (TestGetPinNetSchema) + - Handler dispatch registration (TestGetPinNetHandlerDispatch) + - Parameter validation in the handler (TestGetPinNetHandlerParamValidation) + - Core logic: get_pin_net function (TestGetPinNetCoreLogic) + - Reference+pin resolution path (TestGetPinNetHandlerRefPinResolution) +""" + +import sys +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "python")) + +from commands.wire_connectivity import get_pin_net + +# --------------------------------------------------------------------------- +# Shared mock helpers (mirrors test_wire_connectivity.py) +# --------------------------------------------------------------------------- + + +def _make_point(x: float, y: float) -> MagicMock: + pt = MagicMock() + pt.value = [x, y] + return pt + + +def _make_wire(x1: float, y1: float, x2: float, y2: float) -> MagicMock: + wire = MagicMock() + wire.pts = MagicMock() + wire.pts.xy = [_make_point(x1, y1), _make_point(x2, y2)] + return wire + + +def _make_schematic(*wires: Any) -> MagicMock: + sch = MagicMock() + sch.wire = list(wires) + del sch.label + del sch.symbol + return sch + + +# --------------------------------------------------------------------------- +# TestGetPinNetSchema +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetPinNetSchema: + """Verify the get_pin_net tool schema is present and well-formed.""" + + def test_schema_registered(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + assert "get_pin_net" in TOOL_SCHEMAS + + def test_schema_required_fields(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + required = TOOL_SCHEMAS["get_pin_net"]["inputSchema"]["required"] + assert required == ["schematicPath"] + + def test_schema_has_title_and_description(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + schema = TOOL_SCHEMAS["get_pin_net"] + assert schema.get("title") + assert schema.get("description") + + def test_schema_has_optional_fields(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + props = TOOL_SCHEMAS["get_pin_net"]["inputSchema"]["properties"] + for field in ("reference", "pin", "x", "y"): + assert field in props, f"Expected '{field}' in schema properties" + + +# --------------------------------------------------------------------------- +# TestGetPinNetHandlerDispatch +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetPinNetHandlerDispatch: + """Verify the handler is wired into KiCadInterface.command_routes.""" + + def test_get_pin_net_in_routes(self) -> None: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + iface.board = None + iface.project_filename = None + iface.use_ipc = False + iface.ipc_backend = MagicMock() + iface.ipc_board_api = None + iface.footprint_library = MagicMock() + iface.project_commands = MagicMock() + iface.board_commands = MagicMock() + iface.component_commands = MagicMock() + iface.routing_commands = MagicMock() + KiCADInterface.__init__(iface) + + assert "get_pin_net" in iface.command_routes + assert callable(iface.command_routes["get_pin_net"]) + + +# --------------------------------------------------------------------------- +# TestGetPinNetHandlerParamValidation +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetPinNetHandlerParamValidation: + """Handler returns error responses for bad or missing parameters.""" + + def _make_handler(self) -> Any: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + return iface._handle_get_pin_net + + def test_missing_schematic_path(self) -> None: + handler = self._make_handler() + result = handler({"x": 1.0, "y": 2.0}) + assert result["success"] is False + assert "schematicPath" in result["message"] or "Missing" in result["message"] + + def test_missing_both_modes(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch"}) + assert result["success"] is False + + def test_partial_ref_only(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "reference": "U1"}) + assert result["success"] is False + + def test_partial_pin_only(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "pin": "3"}) + assert result["success"] is False + + def test_partial_x_only(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0}) + assert result["success"] is False + + def test_partial_y_only(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "y": 1.0}) + assert result["success"] is False + + def test_non_numeric_coords(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "bad", "y": 2.0}) + assert result["success"] is False + assert "numeric" in result["message"].lower() or "x" in result["message"] + + +# --------------------------------------------------------------------------- +# TestGetPinNetCoreLogic +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetPinNetCoreLogic: + """Unit tests for the get_pin_net function.""" + + def test_no_wires_returns_empty_dict(self) -> None: + sch = MagicMock() + sch.wire = [] + del sch.label + del sch.symbol + result = get_pin_net(sch, "/tmp/test.kicad_sch", 1.0, 2.0) + assert result is not None + assert result["net"] is None + assert result["pins"] == [] + assert result["wires"] == [] + assert result["query_point"] == {"x": 1.0, "y": 2.0} + + def test_no_wire_at_point_returns_none(self) -> None: + sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) + # Query a midpoint — not on a wire endpoint + result = get_pin_net(sch, "/tmp/test.kicad_sch", 0.5, 0.0) + assert result is None + + def test_unnamed_net_no_labels(self) -> None: + sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) + result = get_pin_net(sch, "/tmp/test.kicad_sch", 0.0, 0.0) + assert result is not None + assert result["net"] is None + + def test_net_name_from_label(self) -> None: + """Wire with a net label at one endpoint should yield that label as net name.""" + wire = _make_wire(0.0, 0.0, 1.0, 0.0) + + label = MagicMock() + label.value = "SDA" + label.at = MagicMock() + label.at.value = [0.0, 0.0, 0] # label placed at wire start + + sch = MagicMock() + sch.wire = [wire] + sch.label = [label] + del sch.symbol + + result = get_pin_net(sch, "/tmp/test.kicad_sch", 0.0, 0.0) + assert result is not None + assert result["net"] == "SDA" + + def test_query_point_in_result(self) -> None: + sch = _make_schematic(_make_wire(5.0, 3.0, 6.0, 3.0)) + result = get_pin_net(sch, "/tmp/test.kicad_sch", 5.0, 3.0) + assert result is not None + assert result["query_point"] == {"x": 5.0, "y": 3.0} + + def test_result_has_pins_and_wires_keys(self) -> None: + sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) + result = get_pin_net(sch, "/tmp/test.kicad_sch", 0.0, 0.0) + assert result is not None + assert "pins" in result + assert "wires" in result + + def test_wires_returned_in_mm(self) -> None: + sch = _make_schematic(_make_wire(2.0, 3.0, 4.0, 3.0)) + result = get_pin_net(sch, "/tmp/test.kicad_sch", 2.0, 3.0) + assert result is not None + assert len(result["wires"]) == 1 + w = result["wires"][0] + assert w["start"]["x"] == pytest.approx(2.0) + assert w["start"]["y"] == pytest.approx(3.0) + assert w["end"]["x"] == pytest.approx(4.0) + assert w["end"]["y"] == pytest.approx(3.0) + + +# --------------------------------------------------------------------------- +# TestGetPinNetHandlerRefPinResolution +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetPinNetHandlerRefPinResolution: + """Test the reference+pin → coordinate resolution path in the handler.""" + + def _make_handler(self) -> Any: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + return iface._handle_get_pin_net + + def test_ref_pin_resolves_to_coordinates(self) -> None: + handler = self._make_handler() + mock_result = { + "net": "SDA", + "pins": [{"component": "U1", "pin": "3"}], + "wires": [], + "query_point": {"x": 10.5, "y": 15.2}, + } + with ( + patch( + "commands.pin_locator.PinLocator.get_pin_location", + return_value=[10.5, 15.2], + ), + patch("kicad_interface.SchematicManager.load_schematic") as mock_load, + patch("commands.wire_connectivity.get_pin_net", return_value=mock_result) as mock_gpn, + ): + mock_sch = MagicMock() + mock_sch.wire = [_make_wire(10.5, 15.2, 11.5, 15.2)] + mock_load.return_value = mock_sch + + result = handler( + {"schematicPath": "/tmp/test.kicad_sch", "reference": "U1", "pin": "3"} + ) + + assert result["success"] is True + mock_gpn.assert_called_once() + call_args = mock_gpn.call_args + assert call_args[0][2] == pytest.approx(10.5) + assert call_args[0][3] == pytest.approx(15.2) + + def test_ref_pin_not_found(self) -> None: + handler = self._make_handler() + with patch( + "commands.pin_locator.PinLocator.get_pin_location", + return_value=None, + ): + result = handler( + {"schematicPath": "/tmp/test.kicad_sch", "reference": "U1", "pin": "99"} + ) + assert result["success"] is False + assert "U1" in result["message"] or "99" in result["message"] + + def test_coordinate_mode_passes_floats(self) -> None: + handler = self._make_handler() + mock_result = { + "net": None, + "pins": [], + "wires": [], + "query_point": {"x": 10.5, "y": 15.2}, + } + with ( + patch("kicad_interface.SchematicManager.load_schematic") as mock_load, + patch("commands.wire_connectivity.get_pin_net", return_value=mock_result) as mock_gpn, + ): + mock_sch = MagicMock() + mock_sch.wire = [_make_wire(10.5, 15.2, 11.5, 15.2)] + mock_load.return_value = mock_sch + + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "10.5", "y": "15.2"}) + + assert result["success"] is True + call_args = mock_gpn.call_args + assert isinstance(call_args[0][2], float) + assert isinstance(call_args[0][3], float) + assert call_args[0][2] == pytest.approx(10.5) + assert call_args[0][3] == pytest.approx(15.2) From e826cf3d32bc7b755d0e85eccf7a1cf65bf27207 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 12 Apr 2026 15:29:46 +0100 Subject: [PATCH 63/74] feat: add get_net_at_point tool for coordinate-based net lookup Co-Authored-By: Claude Sonnet 4.6 --- python/commands/wire_connectivity.py | 50 ++++ python/kicad_interface.py | 35 +++ python/schemas/tool_schemas.py | 29 ++ src/tools/schematic.ts | 40 +++ tests/test_get_net_at_point.py | 410 +++++++++++++++++++++++++++ 5 files changed, 564 insertions(+) create mode 100644 tests/test_get_net_at_point.py diff --git a/python/commands/wire_connectivity.py b/python/commands/wire_connectivity.py index 9bbfbbb..5c4c147 100644 --- a/python/commands/wire_connectivity.py +++ b/python/commands/wire_connectivity.py @@ -331,3 +331,53 @@ def get_pin_net(schematic: Any, schematic_path: str, x_mm: float, y_mm: float) - pins = _find_pins_on_net(net_points, schematic_path, schematic) return {"net": net, "pins": pins, "wires": wires_out, "query_point": {"x": x_mm, "y": y_mm}} + + +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: + _, 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 + adjacency, _ = _build_adjacency(all_wires) + 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} diff --git a/python/kicad_interface.py b/python/kicad_interface.py index a061670..4e23237 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -382,6 +382,7 @@ class KiCADInterface: "get_schematic_pin_locations": self._handle_get_schematic_pin_locations, "get_net_connections": self._handle_get_net_connections, "get_wire_connections": self._handle_get_wire_connections, + "get_net_at_point": self._handle_get_net_at_point, "get_pin_net": self._handle_get_pin_net, "run_erc": self._handle_run_erc, "generate_netlist": self._handle_generate_netlist, @@ -2504,6 +2505,40 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} + def _handle_get_net_at_point(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Return the net name at a given (x, y) coordinate, or null if none found.""" + logger.info("Getting net at point") + try: + from commands.wire_connectivity import get_net_at_point + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "Missing required parameter: schematicPath"} + + x = params.get("x") + y = params.get("y") + if x is None or y is None: + return {"success": False, "message": "Missing required parameters: x and y"} + + try: + x, y = float(x), float(y) + except (TypeError, ValueError): + return {"success": False, "message": "Parameters x and y must be numeric"} + + schematic = SchematicManager.load_schematic(schematic_path) + if not schematic: + return {"success": False, "message": "Failed to load schematic"} + + result = get_net_at_point(schematic, schematic_path, x, y) + return {"success": True, **result} + + except Exception as e: + logger.error(f"Error getting net at point: {str(e)}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + def _handle_get_pin_net(self, params: Dict[str, Any]) -> Dict[str, Any]: """Find net name and all connected pins for a reference+pin or coordinate query""" logger.info("Getting pin net") diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 33eee7b..2eb6584 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1523,6 +1523,35 @@ SCHEMATIC_TOOLS = [ "required": ["schematicPath", "x", "y"], }, }, + { + "name": "get_net_at_point", + "title": "Get Net At Point", + "description": ( + "Returns the net name at a given (x, y) coordinate in a schematic, " + "or null if no net label or wire endpoint is present at that position. " + "Checks net label positions first, then wire endpoints. " + "Useful for quickly identifying what net occupies a specific coordinate " + "without traversing the full wire graph." + ), + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the schematic file (.kicad_sch)", + }, + "x": { + "type": "number", + "description": "X coordinate in mm", + }, + "y": { + "type": "number", + "description": "Y coordinate in mm", + }, + }, + "required": ["schematicPath", "x", "y"], + }, + }, { "name": "get_pin_net", "title": "Get Pin Net", diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index 1d2a53c..b3113e6 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -1425,6 +1425,46 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, ); + server.tool( + "get_net_at_point", + "Returns the net name at a given (x, y) coordinate in a schematic, or null if no net label " + + "or wire endpoint is present at that position. Faster than get_pin_net when you only need " + + "the net name at a known coordinate and don't need pin traversal.", + { + schematicPath: z.string().describe("Path to the schematic file (.kicad_sch)"), + x: z.number().describe("X coordinate in mm"), + y: z.number().describe("Y coordinate in mm"), + }, + async (args: { schematicPath: string; x: number; y: number }) => { + const result = await callKicadScript("get_net_at_point", args); + if (result.success) { + const netName = result.net_name ?? null; + const source = result.source ?? null; + const pos = result.position; + return { + content: [ + { + type: "text", + text: + `Net at (${pos?.x ?? args.x}, ${pos?.y ?? args.y}): ` + + (netName !== null ? netName : "(none)") + + (source ? ` [source: ${source}]` : ""), + }, + ], + }; + } else { + return { + content: [ + { + type: "text", + text: `Failed to get net at point: ${result.message || "Unknown error"}`, + }, + ], + }; + } + }, + ); + server.tool( "get_pin_net", "Returns the net name and all connected pins for a component pin (reference + pin number) " + diff --git a/tests/test_get_net_at_point.py b/tests/test_get_net_at_point.py new file mode 100644 index 0000000..4332252 --- /dev/null +++ b/tests/test_get_net_at_point.py @@ -0,0 +1,410 @@ +""" +Tests for the get_net_at_point tool and its handler. + +Covers: + - Schema shape (TestGetNetAtPointSchema) + - Handler dispatch registration (TestGetNetAtPointHandlerDispatch) + - Parameter validation in the handler (TestGetNetAtPointHandlerParamValidation) + - Core logic: get_net_at_point function (TestGetNetAtPointCoreLogic) + - Integration: real schematic file (TestGetNetAtPointIntegration) +""" + +import shutil +import sys +import tempfile +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "python")) + +from commands.wire_connectivity import get_net_at_point + +# --------------------------------------------------------------------------- +# Shared mock helpers +# --------------------------------------------------------------------------- + +_TEMPLATE = Path(__file__).parent.parent / "python" / "templates" / "empty.kicad_sch" + + +def _make_point(x: float, y: float) -> MagicMock: + pt = MagicMock() + pt.value = [x, y] + return pt + + +def _make_wire(x1: float, y1: float, x2: float, y2: float) -> MagicMock: + wire = MagicMock() + wire.pts = MagicMock() + wire.pts.xy = [_make_point(x1, y1), _make_point(x2, y2)] + return wire + + +def _make_schematic_no_labels(*wires: Any) -> MagicMock: + sch = MagicMock() + sch.wire = list(wires) + del sch.label + del sch.symbol + return sch + + +def _make_schematic_with_label(label_name: str, lx: float, ly: float, *wires: Any) -> MagicMock: + label = MagicMock() + label.value = label_name + label.at = MagicMock() + label.at.value = [lx, ly, 0] + + sch = MagicMock() + sch.wire = list(wires) + sch.label = [label] + del sch.symbol + return sch + + +# --------------------------------------------------------------------------- +# TestGetNetAtPointSchema +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetNetAtPointSchema: + """Verify the get_net_at_point tool schema is present and well-formed.""" + + def test_schema_registered(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + assert "get_net_at_point" in TOOL_SCHEMAS + + def test_schema_required_fields(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + required = TOOL_SCHEMAS["get_net_at_point"]["inputSchema"]["required"] + assert set(required) == {"schematicPath", "x", "y"} + + def test_schema_has_title_and_description(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + schema = TOOL_SCHEMAS["get_net_at_point"] + assert schema.get("title") + assert schema.get("description") + + def test_schema_properties(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + props = TOOL_SCHEMAS["get_net_at_point"]["inputSchema"]["properties"] + for field in ("schematicPath", "x", "y"): + assert field in props, f"Expected '{field}' in schema properties" + + +# --------------------------------------------------------------------------- +# TestGetNetAtPointHandlerDispatch +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetNetAtPointHandlerDispatch: + """Verify the handler is wired into KiCadInterface.command_routes.""" + + def test_get_net_at_point_in_routes(self) -> None: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + iface.board = None + iface.project_filename = None + iface.use_ipc = False + iface.ipc_backend = MagicMock() + iface.ipc_board_api = None + iface.footprint_library = MagicMock() + iface.project_commands = MagicMock() + iface.board_commands = MagicMock() + iface.component_commands = MagicMock() + iface.routing_commands = MagicMock() + KiCADInterface.__init__(iface) + + assert "get_net_at_point" in iface.command_routes + assert callable(iface.command_routes["get_net_at_point"]) + + +# --------------------------------------------------------------------------- +# TestGetNetAtPointHandlerParamValidation +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetNetAtPointHandlerParamValidation: + """Handler returns error responses for bad or missing parameters.""" + + def _make_handler(self) -> Any: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + return iface._handle_get_net_at_point + + def test_missing_schematic_path(self) -> None: + handler = self._make_handler() + result = handler({"x": 1.0, "y": 2.0}) + assert result["success"] is False + assert "schematicPath" in result["message"] or "Missing" in result["message"] + + def test_missing_x(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "y": 2.0}) + assert result["success"] is False + + def test_missing_y(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0}) + assert result["success"] is False + + def test_missing_both_coords(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch"}) + assert result["success"] is False + + def test_non_numeric_x(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "bad", "y": 2.0}) + assert result["success"] is False + assert "numeric" in result["message"].lower() or "x" in result["message"] + + def test_non_numeric_y(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0, "y": "bad"}) + assert result["success"] is False + + +# --------------------------------------------------------------------------- +# TestGetNetAtPointCoreLogic +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetNetAtPointCoreLogic: + """Unit tests for the get_net_at_point function.""" + + def test_no_wires_no_labels_returns_null_net(self) -> None: + sch = MagicMock() + sch.wire = [] + del sch.label + del sch.symbol + result = get_net_at_point(sch, "/tmp/test.kicad_sch", 1.0, 2.0) + assert result["net_name"] is None + assert result["source"] is None + assert result["position"] == {"x": 1.0, "y": 2.0} + + def test_point_not_on_wire_or_label_returns_null(self) -> None: + sch = _make_schematic_no_labels(_make_wire(0.0, 0.0, 1.0, 0.0)) + result = get_net_at_point(sch, "/tmp/test.kicad_sch", 5.0, 5.0) + assert result["net_name"] is None + assert result["source"] is None + + def test_midpoint_not_on_wire_endpoint(self) -> None: + sch = _make_schematic_no_labels(_make_wire(0.0, 0.0, 2.0, 0.0)) + result = get_net_at_point(sch, "/tmp/test.kicad_sch", 1.0, 0.0) + assert result["net_name"] is None + + def test_wire_endpoint_unnamed_net(self) -> None: + sch = _make_schematic_no_labels(_make_wire(0.0, 0.0, 1.0, 0.0)) + result = get_net_at_point(sch, "/tmp/test.kicad_sch", 0.0, 0.0) + assert result["net_name"] is None + assert result["source"] == "wire_endpoint" + + def test_net_label_at_point_returns_net_name(self) -> None: + sch = _make_schematic_with_label("SDA", 0.0, 0.0, _make_wire(0.0, 0.0, 1.0, 0.0)) + result = get_net_at_point(sch, "/tmp/test.kicad_sch", 0.0, 0.0) + assert result["net_name"] == "SDA" + assert result["source"] == "net_label" + + def test_net_label_takes_priority_over_wire_endpoint(self) -> None: + """When a label sits exactly on a wire endpoint, source should be net_label.""" + sch = _make_schematic_with_label("SCL", 1.0, 0.0, _make_wire(0.0, 0.0, 1.0, 0.0)) + result = get_net_at_point(sch, "/tmp/test.kicad_sch", 1.0, 0.0) + assert result["net_name"] == "SCL" + assert result["source"] == "net_label" + + def test_wire_endpoint_finds_net_via_connected_label(self) -> None: + """Wire endpoint not directly labelled still finds net via connected network points.""" + label = MagicMock() + label.value = "VCC" + label.at = MagicMock() + label.at.value = [1.0, 0.0, 0] + + sch = MagicMock() + sch.wire = [_make_wire(0.0, 0.0, 1.0, 0.0)] + sch.label = [label] + del sch.symbol + + # Query the label end directly — since the label is on the wire endpoint, + # _parse_virtual_connections maps (10000,0) → "VCC", but we're querying (0,0) + # which is the other wire endpoint; net_points includes (10000,0) so "VCC" is found. + result_labelled_end = get_net_at_point(sch, "/tmp/test.kicad_sch", 1.0, 0.0) + assert result_labelled_end["net_name"] == "VCC" + assert result_labelled_end["source"] == "net_label" + + # Query the unlabelled end: source=wire_endpoint, net_name found via network traversal + result_other_end = get_net_at_point(sch, "/tmp/test.kicad_sch", 0.0, 0.0) + assert result_other_end["source"] == "wire_endpoint" + # net_name may be "VCC" (found via net_points scan) or None (depends on traversal) + assert result_other_end["net_name"] in ("VCC", None) + + def test_position_in_result(self) -> None: + sch = _make_schematic_no_labels(_make_wire(3.5, 7.2, 4.5, 7.2)) + result = get_net_at_point(sch, "/tmp/test.kicad_sch", 3.5, 7.2) + assert result["position"] == {"x": 3.5, "y": 7.2} + + def test_result_has_all_keys(self) -> None: + sch = _make_schematic_no_labels() + result = get_net_at_point(sch, "/tmp/test.kicad_sch", 0.0, 0.0) + assert "net_name" in result + assert "position" in result + assert "source" in result + + def test_no_wire_attr_still_returns_dict(self) -> None: + sch = MagicMock() + del sch.wire + del sch.label + del sch.symbol + result = get_net_at_point(sch, "/tmp/test.kicad_sch", 0.0, 0.0) + assert isinstance(result, dict) + assert result["net_name"] is None + + +# --------------------------------------------------------------------------- +# TestGetNetAtPointHandlerSuccess +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetNetAtPointHandlerSuccess: + """Handler returns success=True and result keys for valid coordinates.""" + + def _make_handler(self) -> Any: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + return iface._handle_get_net_at_point + + def test_returns_success_with_net_name(self) -> None: + handler = self._make_handler() + mock_result = {"net_name": "GND", "position": {"x": 10.0, "y": 5.0}, "source": "net_label"} + with ( + patch("kicad_interface.SchematicManager.load_schematic") as mock_load, + patch("commands.wire_connectivity.get_net_at_point", return_value=mock_result), + ): + mock_load.return_value = MagicMock() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 10.0, "y": 5.0}) + + assert result["success"] is True + assert result["net_name"] == "GND" + assert result["source"] == "net_label" + + def test_returns_success_with_null_net(self) -> None: + handler = self._make_handler() + mock_result = {"net_name": None, "position": {"x": 0.0, "y": 0.0}, "source": None} + with ( + patch("kicad_interface.SchematicManager.load_schematic") as mock_load, + patch("commands.wire_connectivity.get_net_at_point", return_value=mock_result), + ): + mock_load.return_value = MagicMock() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 0.0, "y": 0.0}) + + assert result["success"] is True + assert result["net_name"] is None + + def test_string_coords_are_cast_to_float(self) -> None: + handler = self._make_handler() + mock_result = {"net_name": None, "position": {"x": 1.5, "y": 2.5}, "source": None} + with ( + patch("kicad_interface.SchematicManager.load_schematic") as mock_load, + patch( + "commands.wire_connectivity.get_net_at_point", return_value=mock_result + ) as mock_fn, + ): + mock_load.return_value = MagicMock() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "1.5", "y": "2.5"}) + + assert result["success"] is True + call_args = mock_fn.call_args + assert isinstance(call_args[0][2], float) + assert isinstance(call_args[0][3], float) + assert call_args[0][2] == pytest.approx(1.5) + assert call_args[0][3] == pytest.approx(2.5) + + +# --------------------------------------------------------------------------- +# TestGetNetAtPointIntegration +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestGetNetAtPointIntegration: + """Integration tests using a real (but temporary) schematic file.""" + + def _write_schematic(self, content: str, tmp_dir: Path) -> Path: + path = tmp_dir / "test.kicad_sch" + path.write_text(content) + return path + + def test_empty_schematic_returns_null(self, tmp_path: Path) -> None: + shutil.copy(_TEMPLATE, tmp_path / "empty.kicad_sch") + sch_path = str(tmp_path / "empty.kicad_sch") + + from commands.schematic import SchematicManager + + sch = SchematicManager.load_schematic(sch_path) + result = get_net_at_point(sch, sch_path, 10.0, 10.0) + assert result["net_name"] is None + assert result["source"] is None + + def test_schematic_with_wire_and_label(self, tmp_path: Path) -> None: + """Write a minimal schematic with a wire and net label, then query it.""" + sch_content = """\ +(kicad_sch (version 20250114) (generator "test") + (uuid aaaaaaaa-0000-0000-0000-000000000001) + (paper "A4") + (wire (pts (xy 10 20) (xy 20 20)) + (stroke (width 0) (type default)) + (uuid aaaaaaaa-0000-0000-0000-000000000002) + ) + (label "TESTNET" + (at 10 20 0) + (effects (font (size 1.27 1.27)) (justify left bottom)) + (uuid aaaaaaaa-0000-0000-0000-000000000003) + ) +) +""" + sch_path = self._write_schematic(sch_content, tmp_path) + + from commands.schematic import SchematicManager + + sch = SchematicManager.load_schematic(str(sch_path)) + # Query the label position + result = get_net_at_point(sch, str(sch_path), 10.0, 20.0) + assert result["net_name"] == "TESTNET" + assert result["source"] == "net_label" + + def test_schematic_wire_endpoint_no_label(self, tmp_path: Path) -> None: + sch_content = """\ +(kicad_sch (version 20250114) (generator "test") + (uuid aaaaaaaa-0000-0000-0000-000000000004) + (paper "A4") + (wire (pts (xy 5 5) (xy 10 5)) + (stroke (width 0) (type default)) + (uuid aaaaaaaa-0000-0000-0000-000000000005) + ) +) +""" + sch_path = self._write_schematic(sch_content, tmp_path) + + from commands.schematic import SchematicManager + + sch = SchematicManager.load_schematic(str(sch_path)) + result = get_net_at_point(sch, str(sch_path), 5.0, 5.0) + assert result["net_name"] is None + assert result["source"] == "wire_endpoint" + assert result["position"] == {"x": pytest.approx(5.0), "y": pytest.approx(5.0)} From 4895bf169c05c1667445d22422a001b15483d3e1 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 12 Apr 2026 15:46:32 +0100 Subject: [PATCH 64/74] feat: add connected_pin_count to list_schematic_nets and list_floating_labels tool Co-Authored-By: Claude Sonnet 4.6 --- python/commands/wire_connectivity.py | 159 +++++++++ python/kicad_interface.py | 55 +++ python/schemas/tool_schemas.py | 21 ++ src/tools/schematic.ts | 36 +- tests/test_net_connectivity.py | 516 +++++++++++++++++++++++++++ 5 files changed, 786 insertions(+), 1 deletion(-) create mode 100644 tests/test_net_connectivity.py diff --git a/python/commands/wire_connectivity.py b/python/commands/wire_connectivity.py index 5c4c147..3e65dd0 100644 --- a/python/commands/wire_connectivity.py +++ b/python/commands/wire_connectivity.py @@ -333,6 +333,165 @@ def get_pin_net(schematic: Any, schematic_path: str, x_mm: float, y_mm: float) - return {"net": net, "pins": pins, "wires": wires_out, "query_point": {"x": x_mm, "y": y_mm}} +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 ''}: {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]) + lx_iu = _to_iu(lx_mm, ly_mm) + + # Check if the label anchor itself is a pin position + if lx_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]: diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 4e23237..a710dd1 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -406,6 +406,7 @@ class KiCADInterface: "get_elements_in_region": self._handle_get_elements_in_region, "find_wires_crossing_symbols": self._handle_find_wires_crossing_symbols, "find_orphaned_wires": self._handle_find_orphaned_wires, + "list_floating_labels": self._handle_list_floating_labels, "snap_to_grid": self._handle_snap_to_grid, "import_svg_logo": self._handle_import_svg_logo, # UI/Process management commands @@ -1924,6 +1925,13 @@ class KiCADInterface: try: from pathlib import Path + from commands.wire_connectivity import ( + _build_adjacency, + _parse_virtual_connections, + _parse_wires, + count_pins_on_net, + ) + schematic_path = params.get("schematicPath") if not schematic_path: return {"success": False, "message": "schematicPath is required"} @@ -1943,15 +1951,34 @@ class KiCADInterface: if hasattr(label, "value"): net_names.add(label.value) + # Pre-build shared wire graph structures for efficiency + 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) + nets = [] for net_name in sorted(net_names): connections = ConnectionManager.get_net_connections( schematic, net_name, Path(schematic_path) ) + pin_count = count_pins_on_net( + schematic, + schematic_path, + net_name, + all_wires, + iu_to_wires, + adjacency, + point_to_label, + label_to_points, + ) nets.append( { "name": net_name, "connections": connections, + "connected_pin_count": pin_count, } ) @@ -3063,6 +3090,34 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} + def _handle_list_floating_labels(self, params: Dict[str, Any]) -> Dict[str, Any]: + """List net labels that are not connected to any component pin""" + logger.info("Listing floating net labels in schematic") + try: + from commands.wire_connectivity import list_floating_labels + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + + schematic = SchematicManager.load_schematic(schematic_path) + if not schematic: + return {"success": False, "message": "Failed to load schematic"} + + labels = list_floating_labels(schematic, schematic_path) + return { + "success": True, + "floating_labels": labels, + "count": len(labels), + "message": f"Found {len(labels)} floating label(s)", + } + except Exception as e: + logger.error(f"Error listing floating labels: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + def _handle_snap_to_grid(self, params: Dict[str, Any]) -> Dict[str, Any]: """Snap schematic element coordinates to the nearest grid point""" logger.info("Snapping schematic elements to grid") diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 2eb6584..bf4b1fc 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1881,6 +1881,27 @@ SCHEMATIC_TOOLS = [ "required": ["schematicPath"], }, }, + { + "name": "list_floating_labels", + "title": "List Floating Net Labels", + "description": ( + "Returns all net labels in the schematic 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." + ), + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the .kicad_sch schematic file", + } + }, + "required": ["schematicPath"], + }, + }, { "name": "snap_to_grid", "title": "Snap Schematic Elements to Grid", diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index b3113e6..f82bb8e 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -660,7 +660,9 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp } const lines = nets.map((n: any) => { const conns = (n.connections || []).map((c: any) => `${c.component}/${c.pin}`).join(", "); - return ` ${n.name}: ${conns || "(no connections)"}`; + const pinCount = + n.connected_pin_count !== undefined ? ` [${n.connected_pin_count} pin(s)]` : ""; + return ` ${n.name}${pinCount}: ${conns || "(no connections)"}`; }); return { content: [ @@ -1361,6 +1363,38 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, ); + // List floating net labels + server.tool( + "list_floating_labels", + "Returns all net labels in the schematic that are not connected to any component pin. " + + "A label is 'floating' when no component pin falls on the wire-network reachable from the " + + "label's position. Floating labels indicate misplaced or off-grid labels that cause ERC errors. " + + "Does not require the KiCAD UI to be running.", + { + schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), + }, + async (args: { schematicPath: string }) => { + const result = await callKicadScript("list_floating_labels", args); + if (result.success) { + const labels: any[] = result.floating_labels || []; + if (labels.length === 0) { + return { content: [{ type: "text", text: "No floating labels found." }] }; + } + const lines: string[] = [`Found ${labels.length} floating label(s):\n`]; + labels.slice(0, 50).forEach((lbl: any) => { + lines.push(` "${lbl.name}" (${lbl.type}) at (${lbl.x}, ${lbl.y})`); + }); + if (labels.length > 50) { + lines.push(` ... and ${labels.length - 50} more`); + } + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + return { + content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }], + }; + }, + ); + // Find orphaned wires server.tool( "find_orphaned_wires", diff --git a/tests/test_net_connectivity.py b/tests/test_net_connectivity.py new file mode 100644 index 0000000..3750f3c --- /dev/null +++ b/tests/test_net_connectivity.py @@ -0,0 +1,516 @@ +""" +Tests for connected_pin_count in list_schematic_nets and the list_floating_labels tool. + +Covers: + - Schema registration for list_floating_labels (TestListFloatingLabelsSchema) + - Handler dispatch registration (TestListFloatingLabelsDispatch) + - Parameter validation (TestListFloatingLabelsParamValidation) + - Core logic: list_floating_labels (TestListFloatingLabelsCoreLogic) + - Core logic: count_pins_on_net (TestCountPinsOnNet) + - connected_pin_count field in list_schematic_nets handler (TestListSchematicNetsConnectedPinCount) + - Integration: floating labels in a real schematic file (TestListFloatingLabelsIntegration) +""" + +import shutil +import sys +import tempfile +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "python")) + +from commands.wire_connectivity import ( + _build_adjacency, + _parse_virtual_connections, + _parse_wires, + count_pins_on_net, + list_floating_labels, +) + +# --------------------------------------------------------------------------- +# Shared mock helpers +# --------------------------------------------------------------------------- + +TEMPLATE_SCH = Path(__file__).parent.parent / "python" / "templates" / "empty.kicad_sch" + + +def _make_point(x: float, y: float) -> MagicMock: + pt = MagicMock() + pt.value = [x, y] + return pt + + +def _make_wire(x1: float, y1: float, x2: float, y2: float) -> MagicMock: + wire = MagicMock() + wire.pts = MagicMock() + wire.pts.xy = [_make_point(x1, y1), _make_point(x2, y2)] + return wire + + +def _make_label(name: str, x: float, y: float) -> MagicMock: + label = MagicMock() + label.value = name + label.at = MagicMock() + label.at.value = [x, y, 0] + return label + + +def _make_schematic_no_labels_no_symbols(*wires: Any) -> MagicMock: + sch = MagicMock() + sch.wire = list(wires) + del sch.label + del sch.symbol + return sch + + +# --------------------------------------------------------------------------- +# TestListFloatingLabelsSchema +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestListFloatingLabelsSchema: + """Verify the list_floating_labels schema is registered and well-formed.""" + + def test_schema_registered(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + assert "list_floating_labels" in TOOL_SCHEMAS + + def test_schema_required_fields(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + required = TOOL_SCHEMAS["list_floating_labels"]["inputSchema"]["required"] + assert required == ["schematicPath"] + + def test_schema_has_title_and_description(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + schema = TOOL_SCHEMAS["list_floating_labels"] + assert schema.get("title") + assert schema.get("description") + + +# --------------------------------------------------------------------------- +# TestListFloatingLabelsDispatch +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestListFloatingLabelsDispatch: + """Verify the handler is wired into KiCadInterface.command_routes.""" + + def test_list_floating_labels_in_routes(self) -> None: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + iface.board = None + iface.project_filename = None + iface.use_ipc = False + iface.ipc_backend = MagicMock() + iface.ipc_board_api = None + iface.footprint_library = MagicMock() + iface.project_commands = MagicMock() + iface.board_commands = MagicMock() + iface.component_commands = MagicMock() + iface.routing_commands = MagicMock() + KiCADInterface.__init__(iface) + + assert "list_floating_labels" in iface.command_routes + assert callable(iface.command_routes["list_floating_labels"]) + + +# --------------------------------------------------------------------------- +# TestListFloatingLabelsParamValidation +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestListFloatingLabelsParamValidation: + """Handler returns error for missing schematicPath.""" + + def _make_handler(self) -> Any: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + return iface._handle_list_floating_labels + + def test_missing_schematic_path(self) -> None: + handler = self._make_handler() + result = handler({}) + assert result["success"] is False + assert "schematicPath" in result["message"] + + def test_bad_schematic_path_returns_error(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/nonexistent/path/test.kicad_sch"}) + assert result["success"] is False + + +# --------------------------------------------------------------------------- +# TestListFloatingLabelsCoreLogic +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestListFloatingLabelsCoreLogic: + """Unit tests for the list_floating_labels function.""" + + def test_no_labels_returns_empty(self) -> None: + sch = _make_schematic_no_labels_no_symbols() + result = list_floating_labels(sch, "/tmp/test.kicad_sch") + assert result == [] + + def test_label_with_no_wires_and_no_pins_is_floating(self) -> None: + label = _make_label("SDA", 10.0, 5.0) + sch = MagicMock() + sch.wire = [] + sch.label = [label] + del sch.symbol + result = list_floating_labels(sch, "/tmp/test.kicad_sch") + assert len(result) == 1 + assert result[0]["name"] == "SDA" + assert result[0]["x"] == pytest.approx(10.0) + assert result[0]["y"] == pytest.approx(5.0) + assert result[0]["type"] == "label" + + def test_label_connected_to_pin_not_floating(self) -> None: + """Label at (0,0) connected to a pin at (2,0) via wire should NOT be floating.""" + wire = _make_wire(0.0, 0.0, 2.0, 0.0) + label = _make_label("SCL", 0.0, 0.0) + + sch = MagicMock() + sch.wire = [wire] + sch.label = [label] + + # Mock a symbol whose pin is at (2, 0) + symbol = MagicMock() + symbol.property = MagicMock() + symbol.property.Reference = MagicMock() + symbol.property.Reference.value = "U1" + sch.symbol = [symbol] + + with patch( + "commands.pin_locator.PinLocator.get_all_symbol_pins", + return_value={"1": (2.0, 0.0)}, + ): + result = list_floating_labels(sch, "/tmp/test.kicad_sch") + + assert result == [] + + def test_label_not_connected_to_any_pin_is_floating(self) -> None: + """Label at (0,0) with no wires to any pin should be floating.""" + label = _make_label("MOSI", 0.0, 0.0) + wire = _make_wire(0.0, 0.0, 1.0, 0.0) + + sch = MagicMock() + sch.wire = [wire] + sch.label = [label] + + # A symbol whose pin is at a completely different location + symbol = MagicMock() + symbol.property = MagicMock() + symbol.property.Reference = MagicMock() + symbol.property.Reference.value = "U2" + sch.symbol = [symbol] + + with patch( + "commands.pin_locator.PinLocator.get_all_symbol_pins", + return_value={"1": (99.0, 99.0)}, + ): + result = list_floating_labels(sch, "/tmp/test.kicad_sch") + + assert len(result) == 1 + assert result[0]["name"] == "MOSI" + + def test_label_directly_on_pin_not_floating(self) -> None: + """Label placed directly at a pin position (no wire needed) should NOT be floating.""" + label = _make_label("PWR", 5.0, 3.0) + + sch = MagicMock() + sch.wire = [] + sch.label = [label] + + symbol = MagicMock() + symbol.property = MagicMock() + symbol.property.Reference = MagicMock() + symbol.property.Reference.value = "R1" + sch.symbol = [symbol] + + # Pin is exactly at the label position + with patch( + "commands.pin_locator.PinLocator.get_all_symbol_pins", + return_value={"1": (5.0, 3.0)}, + ): + result = list_floating_labels(sch, "/tmp/test.kicad_sch") + + assert result == [] + + def test_multiple_labels_mixed_floating_and_connected(self) -> None: + """Two labels: one connected, one floating.""" + label_connected = _make_label("NET_A", 0.0, 0.0) + label_floating = _make_label("NET_B", 20.0, 20.0) + wire = _make_wire(0.0, 0.0, 2.0, 0.0) + + sch = MagicMock() + sch.wire = [wire] + sch.label = [label_connected, label_floating] + + symbol = MagicMock() + symbol.property = MagicMock() + symbol.property.Reference = MagicMock() + symbol.property.Reference.value = "C1" + sch.symbol = [symbol] + + with patch( + "commands.pin_locator.PinLocator.get_all_symbol_pins", + return_value={"1": (2.0, 0.0)}, + ): + result = list_floating_labels(sch, "/tmp/test.kicad_sch") + + assert len(result) == 1 + assert result[0]["name"] == "NET_B" + + def test_template_symbols_skipped(self) -> None: + """Symbols with _TEMPLATE references should be skipped, not crash.""" + label = _make_label("VBUS", 0.0, 0.0) + + sch = MagicMock() + sch.wire = [] + sch.label = [label] + + template_sym = MagicMock() + template_sym.property = MagicMock() + template_sym.property.Reference = MagicMock() + template_sym.property.Reference.value = "_TEMPLATE_R" + sch.symbol = [template_sym] + + with patch( + "commands.pin_locator.PinLocator.get_all_symbol_pins", + return_value={"1": (0.0, 0.0)}, + ) as mock_pins: + result = list_floating_labels(sch, "/tmp/test.kicad_sch") + + # _TEMPLATE_ symbols are skipped; mock_pins should not have been called + mock_pins.assert_not_called() + assert len(result) == 1 + + +# --------------------------------------------------------------------------- +# TestCountPinsOnNet +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestCountPinsOnNet: + """Unit tests for count_pins_on_net.""" + + def _build_graph(self, sch: Any, schematic_path: str): # type: ignore[return] + all_wires = _parse_wires(sch) + 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(sch, schematic_path) + return all_wires, iu_to_wires, adjacency, point_to_label, label_to_points + + def test_no_labels_returns_zero(self) -> None: + sch = _make_schematic_no_labels_no_symbols() + all_wires, iu_to_wires, adj, p2l, l2p = self._build_graph(sch, "/tmp/t.kicad_sch") + count = count_pins_on_net( + sch, "/tmp/t.kicad_sch", "VCC", all_wires, iu_to_wires, adj, p2l, l2p + ) + assert count == 0 + + def test_unknown_net_returns_zero(self) -> None: + wire = _make_wire(0.0, 0.0, 1.0, 0.0) + label = _make_label("SDA", 0.0, 0.0) + sch = MagicMock() + sch.wire = [wire] + sch.label = [label] + del sch.symbol + all_wires, iu_to_wires, adj, p2l, l2p = self._build_graph(sch, "/tmp/t.kicad_sch") + count = count_pins_on_net( + sch, "/tmp/t.kicad_sch", "UNKNOWN_NET", all_wires, iu_to_wires, adj, p2l, l2p + ) + assert count == 0 + + def test_counts_pin_via_wire(self) -> None: + """Label at (0,0), wire to (2,0), pin at (2,0) → count == 1.""" + wire = _make_wire(0.0, 0.0, 2.0, 0.0) + label = _make_label("SCL", 0.0, 0.0) + sch = MagicMock() + sch.wire = [wire] + sch.label = [label] + symbol = MagicMock() + symbol.property = MagicMock() + symbol.property.Reference = MagicMock() + symbol.property.Reference.value = "U1" + sch.symbol = [symbol] + all_wires, iu_to_wires, adj, p2l, l2p = self._build_graph(sch, "/tmp/t.kicad_sch") + with patch( + "commands.pin_locator.PinLocator.get_all_symbol_pins", + return_value={"3": (2.0, 0.0)}, + ): + count = count_pins_on_net( + sch, "/tmp/t.kicad_sch", "SCL", all_wires, iu_to_wires, adj, p2l, l2p + ) + assert count == 1 + + def test_no_symbol_attribute_returns_zero(self) -> None: + wire = _make_wire(0.0, 0.0, 2.0, 0.0) + label = _make_label("SDA", 0.0, 0.0) + sch = MagicMock() + sch.wire = [wire] + sch.label = [label] + del sch.symbol + all_wires, iu_to_wires, adj, p2l, l2p = self._build_graph(sch, "/tmp/t.kicad_sch") + count = count_pins_on_net( + sch, "/tmp/t.kicad_sch", "SDA", all_wires, iu_to_wires, adj, p2l, l2p + ) + assert count == 0 + + +# --------------------------------------------------------------------------- +# TestListSchematicNetsConnectedPinCount +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestListSchematicNetsConnectedPinCount: + """Verify connected_pin_count is present in list_schematic_nets response.""" + + def _make_handler(self) -> Any: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + return iface._handle_list_schematic_nets + + def test_connected_pin_count_present_in_response(self) -> None: + handler = self._make_handler() + + label = _make_label("NET1", 0.0, 0.0) + mock_sch = MagicMock() + mock_sch.wire = [] + mock_sch.label = [label] + del mock_sch.global_label + del mock_sch.symbol + + with ( + patch("kicad_interface.SchematicManager.load_schematic", return_value=mock_sch), + patch( + "kicad_interface.ConnectionManager.get_net_connections", + return_value=[], + ), + ): + result = handler({"schematicPath": "/tmp/test.kicad_sch"}) + + assert result["success"] is True + assert len(result["nets"]) == 1 + net = result["nets"][0] + assert "connected_pin_count" in net + assert isinstance(net["connected_pin_count"], int) + + def test_connected_pin_count_is_zero_when_no_pins(self) -> None: + handler = self._make_handler() + + label = _make_label("ORPHAN_NET", 50.0, 50.0) + mock_sch = MagicMock() + mock_sch.wire = [] + mock_sch.label = [label] + del mock_sch.global_label + del mock_sch.symbol + + with ( + patch("kicad_interface.SchematicManager.load_schematic", return_value=mock_sch), + patch( + "kicad_interface.ConnectionManager.get_net_connections", + return_value=[], + ), + ): + result = handler({"schematicPath": "/tmp/test.kicad_sch"}) + + assert result["success"] is True + assert result["nets"][0]["connected_pin_count"] == 0 + + +# --------------------------------------------------------------------------- +# TestListFloatingLabelsIntegration +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestListFloatingLabelsIntegration: + """Integration tests using a real .kicad_sch file.""" + + def _make_sch_with_floating_label(self, tmp_path: Path) -> Path: + """Copy the empty template and append a floating label.""" + sch_path = tmp_path / "test.kicad_sch" + shutil.copy(TEMPLATE_SCH, sch_path) + content = sch_path.read_text(encoding="utf-8") + floating_label = ( + ' (label "FLOATING_NET" (at 100 100 0)\n' + " (effects (font (size 1.27 1.27)))\n" + " (uuid 11111111-0000-0000-0000-000000000001)\n" + " )" + ) + idx = content.rfind(")") + content = content[:idx] + "\n" + floating_label + "\n)" + sch_path.write_text(content, encoding="utf-8") + return sch_path + + def test_empty_schematic_has_no_floating_labels(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + sch_path = Path(tmp) / "empty.kicad_sch" + shutil.copy(TEMPLATE_SCH, sch_path) + + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + result = iface._handle_list_floating_labels({"schematicPath": str(sch_path)}) + + assert result["success"] is True + assert result["count"] == 0 + assert result["floating_labels"] == [] + + def test_schematic_with_floating_label_detected(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + sch_path = self._make_sch_with_floating_label(Path(tmp)) + + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + result = iface._handle_list_floating_labels({"schematicPath": str(sch_path)}) + + assert result["success"] is True + assert result["count"] == 1 + label = result["floating_labels"][0] + assert label["name"] == "FLOATING_NET" + assert label["x"] == pytest.approx(100.0) + assert label["y"] == pytest.approx(100.0) + assert label["type"] == "label" + + def test_list_schematic_nets_has_connected_pin_count(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + sch_path = self._make_sch_with_floating_label(Path(tmp)) + + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + result = iface._handle_list_schematic_nets({"schematicPath": str(sch_path)}) + + assert result["success"] is True + assert result["count"] == 1 + net = result["nets"][0] + assert net["name"] == "FLOATING_NET" + assert "connected_pin_count" in net + assert net["connected_pin_count"] == 0 From f73d8d979542861184921605cf1a3a32d9ce0f53 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 12 Apr 2026 15:49:15 +0100 Subject: [PATCH 65/74] feat: warn on case-mismatched net label names in add_schematic_net_label Co-Authored-By: Claude Sonnet 4.6 --- python/kicad_interface.py | 31 ++- tests/test_case_sensitivity_warning.py | 350 +++++++++++++++++++++++++ 2 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 tests/test_case_sensitivity_warning.py diff --git a/python/kicad_interface.py b/python/kicad_interface.py index a710dd1..58ae67d 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -13,7 +13,7 @@ import os import sys import traceback from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read @@ -1579,6 +1579,24 @@ class KiCADInterface: ), } + # Collect existing net names BEFORE adding the new label so we can + # detect case-mismatch collisions against pre-existing nets only. + existing_net_names: List[str] = [] + try: + pre_schematic = SchematicManager.load_schematic(schematic_path) + if pre_schematic is not None: + if hasattr(pre_schematic, "label"): + for lbl in pre_schematic.label: + if hasattr(lbl, "value"): + existing_net_names.append(lbl.value) + if hasattr(pre_schematic, "global_label"): + for lbl in pre_schematic.global_label: + if hasattr(lbl, "value"): + existing_net_names.append(lbl.value) + except Exception: + # Non-fatal: if we can't read existing nets, skip the warning + existing_net_names = [] + # Use WireManager for S-expression manipulation success = WireManager.add_label( Path(schematic_path), @@ -1591,6 +1609,15 @@ class KiCADInterface: if not success: return {"success": False, "message": "Failed to add net label"} + # Compute case-mismatch warnings against pre-existing net names. + # A collision is: existing name != new name, but lowercases match. + new_name_lower = net_name.lower() + case_warnings: List[str] = [ + f"Net '{existing}' already exists — label '{net_name}' may be a case mismatch." + for existing in existing_net_names + if existing.lower() == new_name_lower and existing != net_name + ] + response: Dict[str, Any] = { "success": True, "message": f"Added net label '{net_name}' at {position}", @@ -1602,6 +1629,8 @@ class KiCADInterface: f"Added net label '{net_name}' at exact pin endpoint " f"{component_ref}/{pin_number} ({position[0]}, {position[1]})" ) + if case_warnings: + response["case_warnings"] = case_warnings return response except Exception as e: diff --git a/tests/test_case_sensitivity_warning.py b/tests/test_case_sensitivity_warning.py new file mode 100644 index 0000000..164c3dc --- /dev/null +++ b/tests/test_case_sensitivity_warning.py @@ -0,0 +1,350 @@ +""" +Tests for case-sensitivity warnings in add_schematic_net_label. + +When a label is placed whose name differs from an existing net only in case +(e.g. adding "outp" when "OUTP" already exists), the tool should succeed but +include a `case_warnings` list in the response. + +Covers: + - Unit: case_warnings populated when names differ only in case + - Unit: no case_warnings when name is an exact match or no similar name exists + - Integration: place "outp" in a schematic that already has "OUTP" -> warning + - Integration: place "VCC" in a schematic that already has "VCC" -> no warning +""" + +import shutil +import sys +import tempfile +import types +import uuid +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +PYTHON_DIR = Path(__file__).resolve().parent.parent / "python" +sys.path.insert(0, str(PYTHON_DIR)) + +TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "python" / "templates" / "empty.kicad_sch" + + +# --------------------------------------------------------------------------- +# Helpers shared between unit and integration tests +# --------------------------------------------------------------------------- + + +def _make_iface() -> Any: + """Return a KiCADInterface instance with __init__ stubbed out.""" + for mod in ["pcbnew", "skip"]: + sys.modules.setdefault(mod, types.ModuleType(mod)) + from kicad_interface import KiCADInterface + + with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): + return KiCADInterface.__new__(KiCADInterface) + + +def _label_sexp(name: str, x: float, y: float, angle: float = 0) -> str: + u = str(uuid.uuid4()) + return ( + f'(label "{name}" (at {x} {y} {angle})\n' + f" (effects (font (size 1.27 1.27)) (justify left bottom))\n" + f' (uuid "{u}"))' + ) + + +def _make_temp_schematic(extra_sexp: str = "") -> Path: + """Copy empty.kicad_sch to a temp dir, optionally injecting extra S-expressions.""" + tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" + shutil.copy(TEMPLATE_PATH, tmp) + if extra_sexp: + content = tmp.read_text(encoding="utf-8") + idx = content.rfind(")") + content = content[:idx] + "\n" + extra_sexp + "\n)" + tmp.write_text(content, encoding="utf-8") + return tmp + + +# --------------------------------------------------------------------------- +# Unit tests — mock file I/O completely +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestCaseWarningPopulated: + """case_warnings should be present when new label differs from existing by case only.""" + + @pytest.fixture(autouse=True) + def setup(self) -> None: + self.iface = _make_iface() + + def _make_mock_label(self, value: str) -> MagicMock: + lbl = MagicMock() + lbl.value = value + return lbl + + def _make_mock_schematic(self, label_names: list) -> MagicMock: + sch = MagicMock(spec=["label", "global_label"]) + sch.label = [self._make_mock_label(n) for n in label_names] + sch.global_label = [] + return sch + + @patch("commands.wire_manager.WireManager.add_label", return_value=True) + @patch("commands.schematic.SchematicManager.load_schematic") + def test_case_warning_when_uppercase_exists(self, mock_load: Any, mock_add_label: Any) -> None: + """Adding 'outp' when 'OUTP' already exists produces a case_warning.""" + mock_load.return_value = self._make_mock_schematic(["OUTP"]) + + result = self.iface._handle_add_schematic_net_label( + { + "schematicPath": "/fake/sch.kicad_sch", + "netName": "outp", + "position": [10.0, 20.0], + } + ) + + assert result["success"] is True + assert "case_warnings" in result + assert len(result["case_warnings"]) == 1 + assert "OUTP" in result["case_warnings"][0] + assert "outp" in result["case_warnings"][0] + + @patch("commands.wire_manager.WireManager.add_label", return_value=True) + @patch("commands.schematic.SchematicManager.load_schematic") + def test_case_warning_when_lowercase_exists(self, mock_load: Any, mock_add_label: Any) -> None: + """Adding 'OUTP' when 'outp' already exists produces a case_warning.""" + mock_load.return_value = self._make_mock_schematic(["outp"]) + + result = self.iface._handle_add_schematic_net_label( + { + "schematicPath": "/fake/sch.kicad_sch", + "netName": "OUTP", + "position": [10.0, 20.0], + } + ) + + assert result["success"] is True + assert "case_warnings" in result + assert len(result["case_warnings"]) == 1 + + @patch("commands.wire_manager.WireManager.add_label", return_value=True) + @patch("commands.schematic.SchematicManager.load_schematic") + def test_multiple_case_collisions_all_reported( + self, mock_load: Any, mock_add_label: Any + ) -> None: + """Multiple existing labels that differ only in case all produce warnings.""" + mock_load.return_value = self._make_mock_schematic(["OUTP", "Outp", "oUtP"]) + + result = self.iface._handle_add_schematic_net_label( + { + "schematicPath": "/fake/sch.kicad_sch", + "netName": "outp", + "position": [10.0, 20.0], + } + ) + + assert result["success"] is True + assert "case_warnings" in result + assert len(result["case_warnings"]) == 3 + + @patch("commands.wire_manager.WireManager.add_label", return_value=True) + @patch("commands.schematic.SchematicManager.load_schematic") + def test_global_label_case_collision_reported( + self, mock_load: Any, mock_add_label: Any + ) -> None: + """Case collision against a global_label also produces a warning.""" + sch = MagicMock(spec=["label", "global_label"]) + sch.label = [] + sch.global_label = [self._make_mock_label("VDD")] + + mock_load.return_value = sch + + result = self.iface._handle_add_schematic_net_label( + { + "schematicPath": "/fake/sch.kicad_sch", + "netName": "vdd", + "position": [10.0, 20.0], + } + ) + + assert result["success"] is True + assert "case_warnings" in result + assert len(result["case_warnings"]) == 1 + assert "VDD" in result["case_warnings"][0] + + +@pytest.mark.unit +class TestCaseWarningAbsent: + """case_warnings should be absent (or empty) when there is no case collision.""" + + @pytest.fixture(autouse=True) + def setup(self) -> None: + self.iface = _make_iface() + + def _make_mock_schematic(self, label_names: list) -> MagicMock: + sch = MagicMock(spec=["label", "global_label"]) + sch.label = [] + for name in label_names: + lbl = MagicMock() + lbl.value = name + sch.label.append(lbl) + sch.global_label = [] + return sch + + @patch("commands.wire_manager.WireManager.add_label", return_value=True) + @patch("commands.schematic.SchematicManager.load_schematic") + def test_exact_match_no_warning(self, mock_load: Any, mock_add_label: Any) -> None: + """Adding 'VCC' when 'VCC' already exists is not a case mismatch.""" + mock_load.return_value = self._make_mock_schematic(["VCC"]) + + result = self.iface._handle_add_schematic_net_label( + { + "schematicPath": "/fake/sch.kicad_sch", + "netName": "VCC", + "position": [10.0, 20.0], + } + ) + + assert result["success"] is True + assert "case_warnings" not in result or result.get("case_warnings") == [] + + @patch("commands.wire_manager.WireManager.add_label", return_value=True) + @patch("commands.schematic.SchematicManager.load_schematic") + def test_unrelated_nets_no_warning(self, mock_load: Any, mock_add_label: Any) -> None: + """Adding a label whose name has no case-insensitive match produces no warning.""" + mock_load.return_value = self._make_mock_schematic(["GND", "VCC", "CLK"]) + + result = self.iface._handle_add_schematic_net_label( + { + "schematicPath": "/fake/sch.kicad_sch", + "netName": "MOSI", + "position": [10.0, 20.0], + } + ) + + assert result["success"] is True + assert "case_warnings" not in result or result.get("case_warnings") == [] + + @patch("commands.wire_manager.WireManager.add_label", return_value=True) + @patch("commands.schematic.SchematicManager.load_schematic") + def test_empty_schematic_no_warning(self, mock_load: Any, mock_add_label: Any) -> None: + """Adding a label to an empty schematic produces no warning.""" + mock_load.return_value = self._make_mock_schematic([]) + + result = self.iface._handle_add_schematic_net_label( + { + "schematicPath": "/fake/sch.kicad_sch", + "netName": "SIG", + "position": [10.0, 20.0], + } + ) + + assert result["success"] is True + assert "case_warnings" not in result or result.get("case_warnings") == [] + + @patch("commands.wire_manager.WireManager.add_label", return_value=True) + @patch("commands.schematic.SchematicManager.load_schematic") + def test_load_failure_no_warning_but_still_succeeds( + self, mock_load: Any, mock_add_label: Any + ) -> None: + """If loading the schematic for net-name inspection fails, succeed without warning.""" + mock_load.side_effect = RuntimeError("I/O error") + + result = self.iface._handle_add_schematic_net_label( + { + "schematicPath": "/fake/sch.kicad_sch", + "netName": "SIG", + "position": [10.0, 20.0], + } + ) + + assert result["success"] is True + assert "case_warnings" not in result or result.get("case_warnings") == [] + + +# --------------------------------------------------------------------------- +# Integration tests — real .kicad_sch file I/O +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestCaseWarningIntegration: + """End-to-end: use the handler against a real .kicad_sch file.""" + + @pytest.fixture(autouse=True) + def setup(self) -> None: + self.iface = _make_iface() + + def test_case_collision_produces_warning(self) -> None: + """Placing 'outp' when 'OUTP' already exists in the file produces a warning.""" + path = _make_temp_schematic(_label_sexp("OUTP", 10.0, 10.0)) + + result = self.iface._handle_add_schematic_net_label( + { + "schematicPath": str(path), + "netName": "outp", + "position": [20.0, 20.0], + } + ) + + assert result["success"] is True, f"Unexpected failure: {result.get('message')}" + assert "case_warnings" in result, "Expected case_warnings in response" + assert len(result["case_warnings"]) >= 1 + # Warning message should name both parties + warning_text = " ".join(result["case_warnings"]) + assert "OUTP" in warning_text + assert "outp" in warning_text + + def test_exact_match_no_warning(self) -> None: + """Placing 'VCC' when 'VCC' already exists should NOT produce a case warning.""" + path = _make_temp_schematic(_label_sexp("VCC", 10.0, 10.0)) + + result = self.iface._handle_add_schematic_net_label( + { + "schematicPath": str(path), + "netName": "VCC", + "position": [20.0, 20.0], + } + ) + + assert result["success"] is True, f"Unexpected failure: {result.get('message')}" + case_warnings = result.get("case_warnings", []) + assert case_warnings == [], f"Unexpected case_warnings: {case_warnings}" + + def test_no_similar_nets_no_warning(self) -> None: + """Placing a label whose name has no case-insensitive match gives no warning.""" + path = _make_temp_schematic(_label_sexp("GND", 10.0, 10.0)) + + result = self.iface._handle_add_schematic_net_label( + { + "schematicPath": str(path), + "netName": "MOSI", + "position": [20.0, 20.0], + } + ) + + assert result["success"] is True, f"Unexpected failure: {result.get('message')}" + case_warnings = result.get("case_warnings", []) + assert case_warnings == [], f"Unexpected case_warnings: {case_warnings}" + + def test_mixed_case_collision_multiple_existing(self) -> None: + """Multiple existing labels with same letters in different case all warn.""" + extra = "\n".join( + [ + _label_sexp("OUTP", 10.0, 10.0), + _label_sexp("Outp", 15.0, 10.0), + ] + ) + path = _make_temp_schematic(extra) + + result = self.iface._handle_add_schematic_net_label( + { + "schematicPath": str(path), + "netName": "outp", + "position": [20.0, 20.0], + } + ) + + assert result["success"] is True, f"Unexpected failure: {result.get('message')}" + assert "case_warnings" in result + assert len(result["case_warnings"]) == 2 From 4f733bb3db33cc60c39ce0346db5c6891dd48456 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 12 Apr 2026 17:02:35 +0100 Subject: [PATCH 66/74] refactor: consolidate get_pin_net into get_wire_connections get_pin_net was a superset of get_wire_connections with the same coordinate-based flood-fill but two extra response fields (net, query_point) and a reference+pin input mode. Having both tools confused LLM tool selection. get_wire_connections now: - Returns net (label name or null) and query_point in all response paths - Accepts reference+pin input in addition to x/y coordinates, resolving the pin endpoint via PinLocator internally get_pin_net tool, handler, schema, TS registration, and tests removed. test_wire_connectivity.py updated with coverage for all new behaviour. Co-Authored-By: Claude Sonnet 4.6 --- python/commands/wire_connectivity.py | 65 +----- python/kicad_interface.py | 109 +++------ python/schemas/tool_schemas.py | 65 ++---- src/tools/schematic.ts | 97 +++----- tests/test_get_pin_net.py | 325 --------------------------- tests/test_wire_connectivity.py | 167 +++++++++++++- 6 files changed, 259 insertions(+), 569 deletions(-) delete mode 100644 tests/test_get_pin_net.py diff --git a/python/commands/wire_connectivity.py b/python/commands/wire_connectivity.py index 3e65dd0..3785dd5 100644 --- a/python/commands/wire_connectivity.py +++ b/python/commands/wire_connectivity.py @@ -220,7 +220,7 @@ def _find_pins_on_net( def get_wire_connections( schematic: Any, schematic_path: str, x_mm: float, y_mm: float ) -> Optional[Dict]: - """Find all component pins reachable from a point via connected wires, net labels, and power symbols. + """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 — @@ -229,67 +229,20 @@ def get_wire_connections( 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: - - "pins": list of {"component": str, "pin": str} - - "wires": list of {"start": {"x", "y"}, "end": {"x", "y"}} in mm - Or None if no wire endpoint found within tolerance of the query point. - """ - all_wires = _parse_wires(schematic) - if not all_wires: - return {"pins": [], "wires": []} - - 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 - - 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 {"pins": [], "wires": wires_out} - - pins = _find_pins_on_net(net_points, schematic_path, schematic) - return {"pins": pins, "wires": wires_out} - - -def get_pin_net(schematic: Any, schematic_path: str, x_mm: float, y_mm: float) -> Optional[Dict]: - """Return the net name and all connected pins for the wire network at (x_mm, y_mm). - 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 at the query point. + 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": {"x": x_mm, "y": y_mm}} + 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( @@ -326,11 +279,11 @@ def get_pin_net(schematic: Any, schematic_path: str, x_mm: float, y_mm: float) - for i in visited ] - pins: List[Dict] = [] - if hasattr(schematic, "symbol"): - pins = _find_pins_on_net(net_points, schematic_path, schematic) + if not hasattr(schematic, "symbol"): + return {"net": net, "pins": [], "wires": wires_out, "query_point": query_point} - return {"net": net, "pins": pins, "wires": wires_out, "query_point": {"x": x_mm, "y": y_mm}} + 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( diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 58ae67d..49cbfff 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -383,7 +383,6 @@ class KiCADInterface: "get_net_connections": self._handle_get_net_connections, "get_wire_connections": self._handle_get_wire_connections, "get_net_at_point": self._handle_get_net_at_point, - "get_pin_net": self._handle_get_pin_net, "run_erc": self._handle_run_erc, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, @@ -2515,29 +2514,57 @@ class KiCADInterface: return {"success": False, "message": str(e)} def _handle_get_wire_connections(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Find all component pins reachable from a point via connected wires""" + """Find net name and all component pins reachable from a point or component pin.""" logger.info("Getting wire connections") try: + from pathlib import Path + + from commands.pin_locator import PinLocator from commands.wire_connectivity import get_wire_connections schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "Missing required parameter: schematicPath"} + + reference = params.get("reference") + pin = params.get("pin") x = params.get("x") y = params.get("y") - if not (schematic_path and x is not None and y is not None): + has_ref_pin = reference is not None and pin is not None + has_coords = x is not None and y is not None + + if has_ref_pin and has_coords: return { "success": False, - "message": "Missing required parameters: schematicPath, x, y", + "message": "Supply either {reference, pin} or {x, y}, not both", } - try: - x, y = float(x), float(y) - except (TypeError, ValueError): + if not has_ref_pin and not has_coords: + if reference is not None or pin is not None: + return { + "success": False, + "message": "Both reference and pin are required together", + } return { "success": False, - "message": "Parameters x and y must be numeric", + "message": "Must supply either {reference, pin} or {x, y}", } + if has_ref_pin: + location = PinLocator().get_pin_location(Path(schematic_path), reference, str(pin)) + if location is None: + return { + "success": False, + "message": f"Pin {pin} not found on {reference}", + } + x, y = location[0], location[1] + else: + try: + x, y = float(x), float(y) + except (TypeError, ValueError): + return {"success": False, "message": "Parameters x and y must be numeric"} + schematic = SchematicManager.load_schematic(schematic_path) if not schematic: return {"success": False, "message": "Failed to load schematic"} @@ -2549,7 +2576,7 @@ class KiCADInterface: if result is None: return { "success": False, - "message": f"No wire found at ({x},{y}) within tolerance", + "message": f"No wire found at ({x},{y}) — point may not be connected", } return {"success": True, **result} @@ -2595,70 +2622,6 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_get_pin_net(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Find net name and all connected pins for a reference+pin or coordinate query""" - logger.info("Getting pin net") - try: - from pathlib import Path - - from commands.pin_locator import PinLocator - from commands.wire_connectivity import get_pin_net - - schematic_path = params.get("schematicPath") - if not schematic_path: - return {"success": False, "message": "Missing required parameter: schematicPath"} - - reference = params.get("reference") - pin = params.get("pin") - x = params.get("x") - y = params.get("y") - - has_ref_pin = reference is not None and pin is not None - has_coords = x is not None and y is not None - - if not has_ref_pin and not has_coords: - return { - "success": False, - "message": "Must supply either {reference, pin} or {x, y}", - } - - if has_ref_pin: - location = PinLocator().get_pin_location(Path(schematic_path), reference, str(pin)) - if location is None: - return { - "success": False, - "message": f"Pin {pin} not found on {reference}", - } - x, y = location[0], location[1] - else: - try: - x, y = float(x), float(y) - except (TypeError, ValueError): - return {"success": False, "message": "Parameters x and y must be numeric"} - - schematic = SchematicManager.load_schematic(schematic_path) - if not schematic: - return {"success": False, "message": "Failed to load schematic"} - - if not hasattr(schematic, "wire"): - return {"success": False, "message": "Schematic has no wires"} - - result = get_pin_net(schematic, schematic_path, x, y) - if result is None: - return { - "success": False, - "message": f"No wire found at ({x},{y}) — pin may not be connected", - } - - return {"success": True, **result} - - except Exception as e: - logger.error(f"Error getting pin net: {str(e)}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - def _handle_run_erc(self, params: Dict[str, Any]) -> Dict[str, Any]: """Run Electrical Rules Check on a schematic via kicad-cli""" logger.info("Running ERC on schematic") diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index bf4b1fc..d4eeac0 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1503,24 +1503,41 @@ SCHEMATIC_TOOLS = [ { "name": "get_wire_connections", "title": "Get Wire Connections", - "description": "Returns all wires and component pins connected to the wire at a given point, by flood-filling through touching wires.", + "description": ( + "Returns the net name and all wires and component pins connected at a given point. " + "Accepts either a component reference + pin number (e.g. reference='U1', pin='3') " + "or a schematic coordinate (x, y in mm). " + "The response includes: 'net' (label name or null for unnamed nets), " + "'pins' (all component pins on the net), 'wires' (all wire segments on the net), " + "and 'query_point' (the resolved coordinate used). " + "The query point must be at a wire endpoint or junction — wire midpoints are not matched. " + "Use get_schematic_pin_locations or list_schematic_wires to obtain exact endpoint coordinates." + ), "inputSchema": { "type": "object", "properties": { "schematicPath": { "type": "string", - "description": "Path to schematic file", + "description": "Path to the schematic file (.kicad_sch)", + }, + "reference": { + "type": "string", + "description": "Component reference (e.g. U1, R1). Pair with pin.", + }, + "pin": { + "type": "string", + "description": "Pin number or name (e.g. '3', 'SDA'). Pair with reference.", }, "x": { "type": "number", - "description": "X coordinate of the point on the wire", + "description": "X coordinate of a wire endpoint in mm. Pair with y.", }, "y": { "type": "number", - "description": "Y coordinate of the point on the wire", + "description": "Y coordinate of a wire endpoint in mm. Pair with x.", }, }, - "required": ["schematicPath", "x", "y"], + "required": ["schematicPath"], }, }, { @@ -1552,44 +1569,6 @@ SCHEMATIC_TOOLS = [ "required": ["schematicPath", "x", "y"], }, }, - { - "name": "get_pin_net", - "title": "Get Pin Net", - "description": ( - "Returns the net name and all connected component pins for a query. " - "Accepts either a component reference + pin number (e.g. reference='U1', pin='3') " - "or a schematic coordinate (x, y in mm). " - "If a net label or power symbol is reachable from the query point, its name is " - "returned as 'net'; otherwise 'net' is null (unnamed net). " - "The 'pins' list contains every component pin on that same net." - ), - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Path to the schematic file (.kicad_sch)", - }, - "reference": { - "type": "string", - "description": "Component reference (e.g. U1, R1). Pair with pin.", - }, - "pin": { - "type": "string", - "description": "Pin number or name (e.g. '3', 'SDA'). Pair with reference.", - }, - "x": { - "type": "number", - "description": "X coordinate of a wire endpoint in mm. Pair with y.", - }, - "y": { - "type": "number", - "description": "Y coordinate of a wire endpoint in mm. Pair with x.", - }, - }, - "required": ["schematicPath"], - }, - }, { "name": "get_schematic_pin_locations", "title": "Get Schematic Pin Locations", diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index f82bb8e..3ddad9a 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -469,24 +469,50 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp // Get wire connections server.tool( "get_wire_connections", - "Find all component pins reachable from a schematic point via connected wires, net labels, and power symbols. The query point must be at a wire endpoint or junction — midpoints of wire segments are not matched. Use get_schematic_pin_locations or list_schematic_wires to obtain exact endpoint coordinates first.", + "Returns the net name and all wires and component pins connected at a given point. " + + "Accepts either a component reference + pin number (e.g. reference='U1', pin='3') " + + "or a schematic coordinate (x, y in mm). " + + "Returns net=null for unnamed (unlabelled) nets. " + + "The query point must be at a wire endpoint or junction — midpoints are not matched. " + + "Use get_schematic_pin_locations or list_schematic_wires to obtain exact endpoint coordinates.", { schematicPath: z.string().describe("Path to the schematic file"), - x: z.number().describe("X coordinate of a wire endpoint or junction"), - y: z.number().describe("Y coordinate of a wire endpoint or junction"), + reference: z + .string() + .optional() + .describe("Component reference (e.g. U1, R1). Pair with pin."), + pin: z + .string() + .optional() + .describe("Pin number or name (e.g. '3', 'SDA'). Pair with reference."), + x: z.number().optional().describe("X coordinate of a wire endpoint in mm. Pair with y."), + y: z.number().optional().describe("Y coordinate of a wire endpoint in mm. Pair with x."), }, - async (args: { schematicPath: string; x: number; y: number }) => { + async (args: { + schematicPath: string; + reference?: string; + pin?: string; + x?: number; + y?: number; + }) => { const result = await callKicadScript("get_wire_connections", args); - if (result.success && result.pins) { - const pinList = result.pins.map((p: any) => ` - ${p.component}/${p.pin}`).join("\n"); + if (result.success) { + const netLabel = result.net ?? "(unnamed)"; + const pinList = (result.pins ?? []) + .map((p: any) => ` - ${p.component}/${p.pin}`) + .join("\n"); const wireList = (result.wires ?? []) .map((w: any) => ` - (${w.start.x},${w.start.y}) → (${w.end.x},${w.end.y})`) .join("\n"); + const qp = result.query_point; return { content: [ { type: "text", - text: `Pins connected at (${args.x},${args.y}):\n${pinList || " (none found)"}\n\nWire segments:\n${wireList || " (none)"}`, + text: + `Net: ${netLabel}\n` + + `Query point: (${qp?.x ?? args.x}, ${qp?.y ?? args.y})\n` + + `Connected pins:\n${pinList || " (none found)"}\n\nWire segments:\n${wireList || " (none)"}`, }, ], }; @@ -1498,61 +1524,4 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp } }, ); - - server.tool( - "get_pin_net", - "Returns the net name and all connected pins for a component pin (reference + pin number) " + - "or a schematic coordinate (x, y in mm). Use this instead of list_schematic_nets + " + - "get_wire_connections when you want to answer 'what net is pin 3 of U1 on?'. " + - "Returns net=null for unnamed (unlabelled) nets.", - { - schematicPath: z.string().describe("Path to the schematic file"), - reference: z - .string() - .optional() - .describe("Component reference (e.g. U1, R1). Pair with pin."), - pin: z - .string() - .optional() - .describe("Pin number or name (e.g. '3', 'SDA'). Pair with reference."), - x: z.number().optional().describe("X coordinate of a wire endpoint in mm. Pair with y."), - y: z.number().optional().describe("Y coordinate of a wire endpoint in mm. Pair with x."), - }, - async (args: { - schematicPath: string; - reference?: string; - pin?: string; - x?: number; - y?: number; - }) => { - const result = await callKicadScript("get_pin_net", args); - if (result.success) { - const netLabel = result.net ?? "(unnamed)"; - const pinList = (result.pins ?? []) - .map((p: any) => ` - ${p.component}/${p.pin}`) - .join("\n"); - const qp = result.query_point; - return { - content: [ - { - type: "text", - text: - `Net: ${netLabel}\n` + - `Query point: (${qp?.x ?? args.x}, ${qp?.y ?? args.y})\n` + - `Connected pins:\n${pinList || " (none found)"}`, - }, - ], - }; - } else { - return { - content: [ - { - type: "text", - text: `Failed to get pin net: ${result.message || "Unknown error"}`, - }, - ], - }; - } - }, - ); } diff --git a/tests/test_get_pin_net.py b/tests/test_get_pin_net.py deleted file mode 100644 index f9d43aa..0000000 --- a/tests/test_get_pin_net.py +++ /dev/null @@ -1,325 +0,0 @@ -""" -Tests for the get_pin_net tool and its handler. - -Covers: - - Schema shape (TestGetPinNetSchema) - - Handler dispatch registration (TestGetPinNetHandlerDispatch) - - Parameter validation in the handler (TestGetPinNetHandlerParamValidation) - - Core logic: get_pin_net function (TestGetPinNetCoreLogic) - - Reference+pin resolution path (TestGetPinNetHandlerRefPinResolution) -""" - -import sys -from pathlib import Path -from typing import Any -from unittest.mock import MagicMock, patch - -import pytest - -sys.path.insert(0, str(Path(__file__).parent.parent / "python")) - -from commands.wire_connectivity import get_pin_net - -# --------------------------------------------------------------------------- -# Shared mock helpers (mirrors test_wire_connectivity.py) -# --------------------------------------------------------------------------- - - -def _make_point(x: float, y: float) -> MagicMock: - pt = MagicMock() - pt.value = [x, y] - return pt - - -def _make_wire(x1: float, y1: float, x2: float, y2: float) -> MagicMock: - wire = MagicMock() - wire.pts = MagicMock() - wire.pts.xy = [_make_point(x1, y1), _make_point(x2, y2)] - return wire - - -def _make_schematic(*wires: Any) -> MagicMock: - sch = MagicMock() - sch.wire = list(wires) - del sch.label - del sch.symbol - return sch - - -# --------------------------------------------------------------------------- -# TestGetPinNetSchema -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestGetPinNetSchema: - """Verify the get_pin_net tool schema is present and well-formed.""" - - def test_schema_registered(self) -> None: - from schemas.tool_schemas import TOOL_SCHEMAS - - assert "get_pin_net" in TOOL_SCHEMAS - - def test_schema_required_fields(self) -> None: - from schemas.tool_schemas import TOOL_SCHEMAS - - required = TOOL_SCHEMAS["get_pin_net"]["inputSchema"]["required"] - assert required == ["schematicPath"] - - def test_schema_has_title_and_description(self) -> None: - from schemas.tool_schemas import TOOL_SCHEMAS - - schema = TOOL_SCHEMAS["get_pin_net"] - assert schema.get("title") - assert schema.get("description") - - def test_schema_has_optional_fields(self) -> None: - from schemas.tool_schemas import TOOL_SCHEMAS - - props = TOOL_SCHEMAS["get_pin_net"]["inputSchema"]["properties"] - for field in ("reference", "pin", "x", "y"): - assert field in props, f"Expected '{field}' in schema properties" - - -# --------------------------------------------------------------------------- -# TestGetPinNetHandlerDispatch -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestGetPinNetHandlerDispatch: - """Verify the handler is wired into KiCadInterface.command_routes.""" - - def test_get_pin_net_in_routes(self) -> None: - with patch("kicad_interface.USE_IPC_BACKEND", False): - from kicad_interface import KiCADInterface - - iface = KiCADInterface.__new__(KiCADInterface) - iface.board = None - iface.project_filename = None - iface.use_ipc = False - iface.ipc_backend = MagicMock() - iface.ipc_board_api = None - iface.footprint_library = MagicMock() - iface.project_commands = MagicMock() - iface.board_commands = MagicMock() - iface.component_commands = MagicMock() - iface.routing_commands = MagicMock() - KiCADInterface.__init__(iface) - - assert "get_pin_net" in iface.command_routes - assert callable(iface.command_routes["get_pin_net"]) - - -# --------------------------------------------------------------------------- -# TestGetPinNetHandlerParamValidation -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestGetPinNetHandlerParamValidation: - """Handler returns error responses for bad or missing parameters.""" - - def _make_handler(self) -> Any: - with patch("kicad_interface.USE_IPC_BACKEND", False): - from kicad_interface import KiCADInterface - - iface = KiCADInterface.__new__(KiCADInterface) - return iface._handle_get_pin_net - - def test_missing_schematic_path(self) -> None: - handler = self._make_handler() - result = handler({"x": 1.0, "y": 2.0}) - assert result["success"] is False - assert "schematicPath" in result["message"] or "Missing" in result["message"] - - def test_missing_both_modes(self) -> None: - handler = self._make_handler() - result = handler({"schematicPath": "/tmp/test.kicad_sch"}) - assert result["success"] is False - - def test_partial_ref_only(self) -> None: - handler = self._make_handler() - result = handler({"schematicPath": "/tmp/test.kicad_sch", "reference": "U1"}) - assert result["success"] is False - - def test_partial_pin_only(self) -> None: - handler = self._make_handler() - result = handler({"schematicPath": "/tmp/test.kicad_sch", "pin": "3"}) - assert result["success"] is False - - def test_partial_x_only(self) -> None: - handler = self._make_handler() - result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0}) - assert result["success"] is False - - def test_partial_y_only(self) -> None: - handler = self._make_handler() - result = handler({"schematicPath": "/tmp/test.kicad_sch", "y": 1.0}) - assert result["success"] is False - - def test_non_numeric_coords(self) -> None: - handler = self._make_handler() - result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "bad", "y": 2.0}) - assert result["success"] is False - assert "numeric" in result["message"].lower() or "x" in result["message"] - - -# --------------------------------------------------------------------------- -# TestGetPinNetCoreLogic -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestGetPinNetCoreLogic: - """Unit tests for the get_pin_net function.""" - - def test_no_wires_returns_empty_dict(self) -> None: - sch = MagicMock() - sch.wire = [] - del sch.label - del sch.symbol - result = get_pin_net(sch, "/tmp/test.kicad_sch", 1.0, 2.0) - assert result is not None - assert result["net"] is None - assert result["pins"] == [] - assert result["wires"] == [] - assert result["query_point"] == {"x": 1.0, "y": 2.0} - - def test_no_wire_at_point_returns_none(self) -> None: - sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) - # Query a midpoint — not on a wire endpoint - result = get_pin_net(sch, "/tmp/test.kicad_sch", 0.5, 0.0) - assert result is None - - def test_unnamed_net_no_labels(self) -> None: - sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) - result = get_pin_net(sch, "/tmp/test.kicad_sch", 0.0, 0.0) - assert result is not None - assert result["net"] is None - - def test_net_name_from_label(self) -> None: - """Wire with a net label at one endpoint should yield that label as net name.""" - wire = _make_wire(0.0, 0.0, 1.0, 0.0) - - label = MagicMock() - label.value = "SDA" - label.at = MagicMock() - label.at.value = [0.0, 0.0, 0] # label placed at wire start - - sch = MagicMock() - sch.wire = [wire] - sch.label = [label] - del sch.symbol - - result = get_pin_net(sch, "/tmp/test.kicad_sch", 0.0, 0.0) - assert result is not None - assert result["net"] == "SDA" - - def test_query_point_in_result(self) -> None: - sch = _make_schematic(_make_wire(5.0, 3.0, 6.0, 3.0)) - result = get_pin_net(sch, "/tmp/test.kicad_sch", 5.0, 3.0) - assert result is not None - assert result["query_point"] == {"x": 5.0, "y": 3.0} - - def test_result_has_pins_and_wires_keys(self) -> None: - sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) - result = get_pin_net(sch, "/tmp/test.kicad_sch", 0.0, 0.0) - assert result is not None - assert "pins" in result - assert "wires" in result - - def test_wires_returned_in_mm(self) -> None: - sch = _make_schematic(_make_wire(2.0, 3.0, 4.0, 3.0)) - result = get_pin_net(sch, "/tmp/test.kicad_sch", 2.0, 3.0) - assert result is not None - assert len(result["wires"]) == 1 - w = result["wires"][0] - assert w["start"]["x"] == pytest.approx(2.0) - assert w["start"]["y"] == pytest.approx(3.0) - assert w["end"]["x"] == pytest.approx(4.0) - assert w["end"]["y"] == pytest.approx(3.0) - - -# --------------------------------------------------------------------------- -# TestGetPinNetHandlerRefPinResolution -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestGetPinNetHandlerRefPinResolution: - """Test the reference+pin → coordinate resolution path in the handler.""" - - def _make_handler(self) -> Any: - with patch("kicad_interface.USE_IPC_BACKEND", False): - from kicad_interface import KiCADInterface - - iface = KiCADInterface.__new__(KiCADInterface) - return iface._handle_get_pin_net - - def test_ref_pin_resolves_to_coordinates(self) -> None: - handler = self._make_handler() - mock_result = { - "net": "SDA", - "pins": [{"component": "U1", "pin": "3"}], - "wires": [], - "query_point": {"x": 10.5, "y": 15.2}, - } - with ( - patch( - "commands.pin_locator.PinLocator.get_pin_location", - return_value=[10.5, 15.2], - ), - patch("kicad_interface.SchematicManager.load_schematic") as mock_load, - patch("commands.wire_connectivity.get_pin_net", return_value=mock_result) as mock_gpn, - ): - mock_sch = MagicMock() - mock_sch.wire = [_make_wire(10.5, 15.2, 11.5, 15.2)] - mock_load.return_value = mock_sch - - result = handler( - {"schematicPath": "/tmp/test.kicad_sch", "reference": "U1", "pin": "3"} - ) - - assert result["success"] is True - mock_gpn.assert_called_once() - call_args = mock_gpn.call_args - assert call_args[0][2] == pytest.approx(10.5) - assert call_args[0][3] == pytest.approx(15.2) - - def test_ref_pin_not_found(self) -> None: - handler = self._make_handler() - with patch( - "commands.pin_locator.PinLocator.get_pin_location", - return_value=None, - ): - result = handler( - {"schematicPath": "/tmp/test.kicad_sch", "reference": "U1", "pin": "99"} - ) - assert result["success"] is False - assert "U1" in result["message"] or "99" in result["message"] - - def test_coordinate_mode_passes_floats(self) -> None: - handler = self._make_handler() - mock_result = { - "net": None, - "pins": [], - "wires": [], - "query_point": {"x": 10.5, "y": 15.2}, - } - with ( - patch("kicad_interface.SchematicManager.load_schematic") as mock_load, - patch("commands.wire_connectivity.get_pin_net", return_value=mock_result) as mock_gpn, - ): - mock_sch = MagicMock() - mock_sch.wire = [_make_wire(10.5, 15.2, 11.5, 15.2)] - mock_load.return_value = mock_sch - - result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "10.5", "y": "15.2"}) - - assert result["success"] is True - call_args = mock_gpn.call_args - assert isinstance(call_args[0][2], float) - assert isinstance(call_args[0][3], float) - assert call_args[0][2] == pytest.approx(10.5) - assert call_args[0][3] == pytest.approx(15.2) diff --git a/tests/test_wire_connectivity.py b/tests/test_wire_connectivity.py index 9c013e7..a36aa24 100644 --- a/tests/test_wire_connectivity.py +++ b/tests/test_wire_connectivity.py @@ -7,6 +7,8 @@ Covers: - Parameter validation in the handler (TestHandlerParamValidation) - Core logic: _to_iu, _parse_wires, _build_adjacency, _find_connected_wires, get_wire_connections (TestCoreLogic) + - New net/query_point fields and reference+pin input mode (TestGetWireConnectionsNewFields, + TestGetWireConnectionsHandlerRefPinMode) """ import sys @@ -77,8 +79,23 @@ class TestSchema: schema = TOOL_SCHEMAS["get_wire_connections"] required = schema["inputSchema"]["required"] assert "schematicPath" in required - assert "x" in required - assert "y" in required + # x, y and reference, pin are all optional (dual-mode input) + assert "x" not in required + assert "y" not in required + + def test_schema_optional_fields(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + props = TOOL_SCHEMAS["get_wire_connections"]["inputSchema"]["properties"] + assert "reference" in props + assert "pin" in props + assert "x" in props + assert "y" in props + + def test_get_pin_net_not_registered(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + assert "get_pin_net" not in TOOL_SCHEMAS def test_schema_has_title_and_description(self) -> None: from schemas.tool_schemas import TOOL_SCHEMAS @@ -145,14 +162,24 @@ class TestHandlerParamValidation: assert result["success"] is False assert "schematicPath" in result["message"] or "Missing" in result["message"] - def test_missing_x(self) -> None: + def test_missing_both_modes(self) -> None: handler = self._make_handler() - result = handler({"schematicPath": "/tmp/test.kicad_sch", "y": 2.0}) + result = handler({"schematicPath": "/tmp/test.kicad_sch"}) + assert result["success"] is False + assert ( + "reference" in result["message"] + or "x" in result["message"] + or "supply" in result["message"].lower() + ) + + def test_partial_reference_without_pin(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "reference": "U1"}) assert result["success"] is False - def test_missing_y(self) -> None: + def test_partial_pin_without_reference(self) -> None: handler = self._make_handler() - result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0}) + result = handler({"schematicPath": "/tmp/test.kicad_sch", "pin": "3"}) assert result["success"] is False def test_non_numeric_x(self) -> None: @@ -304,7 +331,11 @@ class TestCoreLogic: sch = MagicMock() sch.wire = [] result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) - assert result == {"pins": [], "wires": []} + assert result is not None + assert result["pins"] == [] + assert result["wires"] == [] + assert result["net"] is None + assert result["query_point"] == {"x": 0.0, "y": 0.0} def test_get_wire_connections_no_wire_at_point_returns_none(self) -> None: sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) @@ -313,7 +344,6 @@ class TestCoreLogic: def test_get_wire_connections_returns_wire_data(self) -> None: sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) - # Prevent _find_pins_on_net from iterating symbols result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) assert result is not None assert result["pins"] == [] @@ -321,6 +351,8 @@ class TestCoreLogic: wire = result["wires"][0] assert wire["start"] == {"x": 0.0, "y": 0.0} assert wire["end"] == {"x": 1.0, "y": 0.0} + assert "net" in result + assert "query_point" in result def test_get_wire_connections_chain_returns_all_wires(self) -> None: sch = _make_schematic( @@ -330,3 +362,122 @@ class TestCoreLogic: result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) assert result is not None assert len(result["wires"]) == 2 + + +# --------------------------------------------------------------------------- +# TestGetWireConnectionsNewFields +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetWireConnectionsNewFields: + """Verify net and query_point are present in all return paths.""" + + def test_net_field_present_when_no_wires(self) -> None: + sch = MagicMock() + sch.wire = [] + result = get_wire_connections(sch, "/fake/path.kicad_sch", 1.0, 2.0) + assert result is not None + assert "net" in result + assert result["net"] is None + + def test_query_point_echoed_when_no_wires(self) -> None: + sch = MagicMock() + sch.wire = [] + result = get_wire_connections(sch, "/fake/path.kicad_sch", 3.5, 7.25) + assert result is not None + assert result["query_point"] == {"x": 3.5, "y": 7.25} + + def test_net_is_none_for_unnamed_net(self) -> None: + # Wire with no labels → net should be None + sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) + result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) + assert result is not None + assert result["net"] is None + + def test_query_point_echoed_with_wire(self) -> None: + sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) + result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) + assert result is not None + assert result["query_point"] == {"x": 0.0, "y": 0.0} + + def test_net_none_returned_when_no_wire_at_point(self) -> None: + sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) + result = get_wire_connections(sch, "/fake/path.kicad_sch", 5.0, 0.0) + assert result is None # no match at midpoint + + +# --------------------------------------------------------------------------- +# TestGetWireConnectionsHandlerRefPinMode +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetWireConnectionsHandlerRefPinMode: + """Handler correctly resolves reference+pin to coordinates via PinLocator.""" + + def _make_handler(self) -> Any: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + return iface._handle_get_wire_connections + + def test_ref_pin_resolves_to_coordinates(self) -> None: + handler = self._make_handler() + mock_result = { + "net": "VCC", + "pins": [], + "wires": [], + "query_point": {"x": 10.0, "y": 20.0}, + } + with ( + patch( + "commands.pin_locator.PinLocator.get_pin_location", + return_value=(10.0, 20.0), + ), + patch("commands.wire_connectivity.get_wire_connections", return_value=mock_result), + patch( + "kicad_interface.SchematicManager.load_schematic", + return_value=MagicMock(wire=[MagicMock()]), + ), + ): + result = handler( + {"schematicPath": "/fake/path.kicad_sch", "reference": "U1", "pin": "3"} + ) + assert result["success"] is True + + def test_ref_pin_not_found_returns_error(self) -> None: + handler = self._make_handler() + with patch( + "commands.pin_locator.PinLocator.get_pin_location", + return_value=None, + ): + result = handler( + {"schematicPath": "/fake/path.kicad_sch", "reference": "U1", "pin": "99"} + ) + assert result["success"] is False + assert "99" in result["message"] or "U1" in result["message"] + + def test_missing_both_modes_returns_error(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/fake/path.kicad_sch"}) + assert result["success"] is False + + def test_partial_reference_without_pin_returns_error(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/fake/path.kicad_sch", "reference": "U1"}) + assert result["success"] is False + + def test_partial_pin_without_reference_returns_error(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/fake/path.kicad_sch", "pin": "3"}) + assert result["success"] is False + + def test_get_pin_net_not_in_routes(self) -> None: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + KiCADInterface.__init__(iface) + assert "get_pin_net" not in iface.command_routes From 1df6332aa86df5460720456414e4dab078ba9dbb Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 12 Apr 2026 17:32:18 +0100 Subject: [PATCH 67/74] =?UTF-8?q?refactor:=20address=20code=20review=20iss?= =?UTF-8?q?ues=20=E2=80=94=20remove=20stale=20import,=20fix=20misleading?= =?UTF-8?q?=20var=20name,=20eliminate=20redundant=20adjacency=20build,=20a?= =?UTF-8?q?dd=20warning=20log=20on=20pin-angle=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- python/commands/connection_schematic.py | 5 ++++- python/commands/wire_connectivity.py | 7 +++---- python/kicad_interface.py | 1 - 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/python/commands/connection_schematic.py b/python/commands/connection_schematic.py index f46ea99..82807f2 100644 --- a/python/commands/connection_schematic.py +++ b/python/commands/connection_schematic.py @@ -98,7 +98,10 @@ class ConnectionManager: # Stub direction follows the pin's outward angle from the PinLocator 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 diff --git a/python/commands/wire_connectivity.py b/python/commands/wire_connectivity.py index 3785dd5..b6efb2f 100644 --- a/python/commands/wire_connectivity.py +++ b/python/commands/wire_connectivity.py @@ -414,10 +414,10 @@ def list_floating_labels(schematic: Any, schematic_path: str) -> List[Dict[str, coords = label.at.value lx_mm = float(coords[0]) ly_mm = float(coords[1]) - lx_iu = _to_iu(lx_mm, ly_mm) + label_iu = _to_iu(lx_mm, ly_mm) # Check if the label anchor itself is a pin position - if lx_iu in pin_iu_set: + if label_iu in pin_iu_set: continue # Trace the wire-network from this label and check for pins @@ -470,10 +470,9 @@ def get_net_at_point( # Check if query point is on a wire endpoint all_wires = _parse_wires(schematic) if hasattr(schematic, "wire") else [] if all_wires: - _, iu_to_wires = _build_adjacency(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 - adjacency, _ = _build_adjacency(all_wires) visited, net_points = _find_connected_wires( x_mm, y_mm, diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 49cbfff..9e5ac46 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -1527,7 +1527,6 @@ class KiCADInterface: """ logger.info("Adding net label to schematic") try: - import traceback from pathlib import Path from commands.wire_manager import WireManager From 33a2118960270229d0837f3a39e46eaa1cb939d0 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 12 Apr 2026 17:52:51 +0100 Subject: [PATCH 68/74] docs: document get_net_at_point, list_floating_labels, find_orphaned_wires, snap_to_grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four tools added in this branch had no docs coverage. Add entries to SCHEMATIC_TOOLS_REFERENCE.md with parameter tables, response field tables, and usage notes. Update section tool counts (Net Analysis 4→5, Validation 3→6). Co-Authored-By: Claude Sonnet 4.6 --- docs/SCHEMATIC_TOOLS_REFERENCE.md | 69 ++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/docs/SCHEMATIC_TOOLS_REFERENCE.md b/docs/SCHEMATIC_TOOLS_REFERENCE.md index e57eea1..deccaf9 100644 --- a/docs/SCHEMATIC_TOOLS_REFERENCE.md +++ b/docs/SCHEMATIC_TOOLS_REFERENCE.md @@ -211,7 +211,7 @@ Remove a net label from the schematic. | 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 (4 tools) +## Net Analysis (5 tools) ### get_net_connections @@ -248,6 +248,26 @@ List all net labels, global labels, and power flags in the schematic. | ------------- | ------ | -------- | --------------------------- | | 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 (5 tools) ### create_schematic @@ -300,7 +320,52 @@ Generate a netlist from the schematic. **Usage Notes:** Returns a complete netlist with component information (reference, value, footprint) and net connections (net name with all connected component/pin pairs). -## Validation and Synchronization (3 tools) +## 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\ | 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 From ba09fc4e0f5f7092e039bd3f0081ed9e394a4c56 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 12 Apr 2026 18:10:55 +0100 Subject: [PATCH 69/74] fix: change snap_to_grid default grid from 2.54mm to 1.27mm (50 mil) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Half of all valid KiCAD schematic pin positions are on the 50-mil (1.27mm) grid but not the 100-mil (2.54mm) grid — e.g. 26.67mm = 21 × 1.27mm. Snapping to 2.54mm displaced those coordinates by 1.27mm, moving labels off their pins and increasing floating-label count. KiCAD source confirms: DEFAULT_CONNECTION_GRID_MILS = 50 and the ERC off-grid check uses exact integer modulo against this value, so any displacement breaks connectivity unconditionally. Also update the kicad-source absolute path in CLAUDE.md. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 60 +++++++++++++++++++++++++++++++ python/commands/schematic_snap.py | 22 +++++++++--- python/kicad_interface.py | 2 +- python/schemas/tool_schemas.py | 10 +++--- tests/test_snap_to_grid.py | 19 +++++++++- 5 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6f203ba --- /dev/null +++ b/CLAUDE.md @@ -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_.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` and methods `test_`. + +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 ...`). diff --git a/python/commands/schematic_snap.py b/python/commands/schematic_snap.py index fa3568a..83c60a9 100644 --- a/python/commands/schematic_snap.py +++ b/python/commands/schematic_snap.py @@ -4,9 +4,18 @@ 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 eeschema grid is 2.54 mm (0.1 inch). Off-grid coordinates -cause wires that appear visually connected to fail ERC connectivity checks -because KiCAD uses exact integer (IU) matching internally. +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 @@ -18,7 +27,7 @@ from sexpdata import Symbol logger = logging.getLogger("kicad_interface") -_DEFAULT_GRID_MM: float = 2.54 +_DEFAULT_GRID_MM: float = 1.27 # Element type names exposed in the public API _VALID_ELEMENTS = frozenset({"wires", "junctions", "labels", "components"}) @@ -91,7 +100,10 @@ def snap_to_grid( Args: schematic_path: Path to the ``.kicad_sch`` file. - grid_size: Grid spacing in mm (default 2.54 mm = 0.1 inch). + 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 diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 9e5ac46..f39e40b 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -3121,7 +3121,7 @@ class KiCADInterface: if not schematic_path: return {"success": False, "message": "schematicPath is required"} - grid_size = float(params.get("gridSize", 2.54)) + grid_size = float(params.get("gridSize", 1.27)) elements = params.get("elements") # None → defaults inside snap_to_grid result = snap_to_grid(Path(schematic_path), grid_size=grid_size, elements=elements) diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index d4eeac0..a71ed0c 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1902,12 +1902,12 @@ SCHEMATIC_TOOLS = [ "gridSize": { "type": "number", "description": ( - "Grid spacing in mm. " - "Standard KiCAD schematic grid is 2.54 mm (0.1 inch). " - "Use 1.27 mm for high-density layouts. " - "Defaults to 2.54." + "Grid spacing in mm (default: 1.27 — standard KiCAD schematic grid). " + "Do NOT use 2.54: half of all valid KiCAD pin positions are at odd " + "multiples of 1.27 mm and would be displaced 1.27 mm, breaking " + "connectivity." ), - "default": 2.54, + "default": 1.27, }, "elements": { "type": "array", diff --git a/tests/test_snap_to_grid.py b/tests/test_snap_to_grid.py index 4089801..74e73a8 100644 --- a/tests/test_snap_to_grid.py +++ b/tests/test_snap_to_grid.py @@ -203,7 +203,7 @@ class TestSnapDefaults: path = _make_temp_schematic(extra) result = snap_to_grid(path) # defaults: grid=2.54, elements=None assert result["snapped"] >= 3 - assert result["grid_size"] == pytest.approx(2.54) + assert result["grid_size"] == pytest.approx(1.27) def test_idempotent(self): path = _make_temp_schematic(_wire_sexp(2.51, 5.03, 7.56, 5.03)) @@ -213,6 +213,23 @@ class TestSnapDefaults: content_after_second = path.read_text(encoding="utf-8") assert content_after_first == content_after_second + def test_default_grid_is_1_27mm(self): + # Regression: default was 2.54 mm, which displaces valid KiCAD pin + # coordinates that fall on the 50-mil (1.27 mm) grid but not on the + # 100-mil (2.54 mm) grid — e.g. 26.67 mm = 21 × 1.27 mm. + # With the correct 1.27 mm default those coordinates must be left + # untouched (snapped == 0, already_on_grid >= 1). + # 26.67 / 2.54 == 10.5 → would snap to 25.40 mm (off by 1.27 mm). + # 26.67 / 1.27 == 21.0 → already on grid, no move. + path = _make_temp_schematic(_wire_sexp(335.28, 26.67, 350.52, 26.67)) + result = snap_to_grid(path) # default grid + assert result["grid_size"] == pytest.approx(1.27) + assert result["snapped"] == 0, ( + "Wire at valid 50-mil pin coordinates was displaced by default snap — " + "default grid must be 1.27 mm, not 2.54 mm" + ) + assert result["already_on_grid"] >= 1 + def test_custom_grid(self): # 1.27 mm grid — wire at 1.25 should snap to 1.27 path = _make_temp_schematic(_wire_sexp(1.25, 1.25, 2.51, 2.51)) From c7b0e3105be9513a5126fa1be4869f09e7ce7f48 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 12 Apr 2026 18:52:46 +0100 Subject: [PATCH 70/74] fix: implement export_netlist handler and rewrite generate_netlist to use kicad-cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit export_netlist was returning "Unknown command" because no Python handler existed. generate_netlist was timing out (30s) due to an O(nets × components × pins) wire-graph algorithm with a new PinLocator instantiated per net. Both handlers now delegate to `kicad-cli sch export netlist`: - export_netlist: new handler; writes KiCad XML / Spice / Cadstar / OrcadPCB2 to the caller-supplied outputPath. Added schematicPath parameter to the TS tool definition (was absent, making file export impossible). - generate_netlist: replaces the slow wire-graph with kicad-cli + XML parse; returns the same {components, nets} JSON the TS handler already expected. Also adds _find_kicad_cli_static() so both handlers share CLI discovery without depending on ExportCommands (which requires a loaded pcbnew board). Cleaned up generate_netlist schema in tool_schemas.py (removed outputPath and format fields the handler never used), updated MCP tool descriptions and SCHEMATIC_TOOLS_REFERENCE.md to clearly distinguish the two tools. 26 unit tests added covering parameter validation, subprocess mocking, format mapping, XML→JSON parsing, and error/timeout propagation. Co-Authored-By: Claude Sonnet 4.6 --- docs/SCHEMATIC_TOOLS_REFERENCE.md | 26 ++- python/kicad_interface.py | 188 ++++++++++++++- python/schemas/tool_schemas.py | 22 +- src/tools/export.ts | 7 +- src/tools/schematic.ts | 4 +- tests/test_netlist_handlers.py | 375 ++++++++++++++++++++++++++++++ 6 files changed, 589 insertions(+), 33 deletions(-) create mode 100644 tests/test_netlist_handlers.py diff --git a/docs/SCHEMATIC_TOOLS_REFERENCE.md b/docs/SCHEMATIC_TOOLS_REFERENCE.md index deccaf9..72c63c8 100644 --- a/docs/SCHEMATIC_TOOLS_REFERENCE.md +++ b/docs/SCHEMATIC_TOOLS_REFERENCE.md @@ -268,7 +268,7 @@ Checks net label / power symbol positions first (exact IU match), then wire endp | position | `{"x": float, "y": float}` — echoes the query coordinates | | source | `"net_label"` \| `"wire_endpoint"` \| `null` — how the net was resolved | -## Schematic Creation and Export (5 tools) +## Schematic Creation and Export (6 tools) ### create_schematic @@ -312,13 +312,27 @@ Return a rasterized image of the schematic (PNG by default, or SVG). Uses kicad- ### generate_netlist -Generate a netlist from the schematic. +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 | Path to the schematic file | +| Parameter | Type | Required | Description | +| ------------- | ------ | -------- | ---------------------------------------------- | +| schematicPath | string | Yes | Absolute path to the .kicad_sch schematic file | -**Usage Notes:** Returns a complete netlist with component information (reference, value, footprint) and net connections (net name with all connected component/pin pairs). +**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) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index f39e40b..234670f 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -384,6 +384,7 @@ class KiCADInterface: "get_wire_connections": self._handle_get_wire_connections, "get_net_at_point": self._handle_get_net_at_point, "run_erc": self._handle_run_erc, + "export_netlist": self._handle_export_netlist, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -2717,23 +2718,190 @@ class KiCADInterface: logger.error(f"Error running ERC: {str(e)}") return {"success": False, "message": str(e)} - def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Generate netlist from schematic""" - logger.info("Generating netlist from schematic") + # ------------------------------------------------------------------ + # kicad-cli helper shared by netlist handlers + # ------------------------------------------------------------------ + + @staticmethod + def _find_kicad_cli_static() -> Optional[str]: + """Return path to kicad-cli executable, or None.""" + import platform + import shutil + + cli = shutil.which("kicad-cli") + if cli: + return cli + + system = platform.system() + if system == "Windows": + candidates = [ + r"C:\Program Files\KiCad\9.0\bin\kicad-cli.exe", + r"C:\Program Files\KiCad\8.0\bin\kicad-cli.exe", + r"C:\Program Files (x86)\KiCad\9.0\bin\kicad-cli.exe", + r"C:\Program Files (x86)\KiCad\8.0\bin\kicad-cli.exe", + ] + elif system == "Darwin": + candidates = [ + "/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli", + "/usr/local/bin/kicad-cli", + ] + else: + candidates = [ + "/usr/bin/kicad-cli", + "/usr/local/bin/kicad-cli", + ] + for path in candidates: + if os.path.exists(path): + return path + return None + + # ------------------------------------------------------------------ + + def _handle_export_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export netlist to a file using kicad-cli.""" + import subprocess + + logger.info("Exporting netlist via kicad-cli") try: schematic_path = params.get("schematicPath") + output_path = params.get("outputPath") + fmt = params.get("format", "KiCad") if not schematic_path: - return {"success": False, "message": "Schematic path is required"} + return {"success": False, "message": "schematicPath is required"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + if not os.path.exists(schematic_path): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} - schematic = SchematicManager.load_schematic(schematic_path) - if not schematic: - return {"success": False, "message": "Failed to load schematic"} + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} - netlist = ConnectionManager.generate_netlist(schematic, schematic_path=schematic_path) - return {"success": True, "netlist": netlist} + fmt_map = { + "KiCad": "kicadxml", + "Spice": "spice", + "Cadstar": "cadstar", + "OrcadPCB2": "orcadpcb2", + } + cli_format = fmt_map.get(fmt, "kicadxml") + + os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) + + cmd = [ + kicad_cli, + "sch", + "export", + "netlist", + "--format", + cli_format, + "--output", + output_path, + schematic_path, + ] + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + + if result.returncode == 0: + return {"success": True, "outputPath": output_path, "format": fmt} + else: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): {result.stderr.strip()}", + } + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 60 seconds"} except Exception as e: - logger.error(f"Error generating netlist: {str(e)}") + logger.error(f"Error exporting netlist: {e}") + return {"success": False, "message": str(e)} + + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Generate netlist from schematic and return structured JSON. + + Uses kicad-cli to export KiCad XML netlist to a temp file, then + parses it into {components, nets} structure expected by the TS handler. + """ + import subprocess + import tempfile + import xml.etree.ElementTree as ET + + logger.info("Generating netlist from schematic via kicad-cli") + try: + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "Schematic path is required"} + if not os.path.exists(schematic_path): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as tmp: + tmp_path = tmp.name + + try: + cmd = [ + kicad_cli, + "sch", + "export", + "netlist", + "--format", + "kicadxml", + "--output", + tmp_path, + schematic_path, + ] + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): {result.stderr.strip()}", + } + + tree = ET.parse(tmp_path) + root = tree.getroot() + + components = [] + for comp in root.findall("./components/comp"): + ref = comp.get("ref", "") + value = comp.findtext("value", "") + footprint = comp.findtext("footprint", "") + components.append({"reference": ref, "value": value, "footprint": footprint}) + + nets = [] + for net in root.findall("./nets/net"): + net_name = net.get("name", "") + connections = [] + for node in net.findall("node"): + connections.append( + { + "component": node.get("ref", ""), + "pin": node.get("pin", ""), + } + ) + nets.append({"name": net_name, "connections": connections}) + + logger.info(f"Generated netlist: {len(components)} components, {len(nets)} nets") + return {"success": True, "netlist": {"components": components, "nets": nets}} + + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 60 seconds"} + except Exception as e: + logger.error(f"Error generating netlist: {e}") return {"success": False, "message": str(e)} def _handle_sync_schematic_to_board(self, params: Dict[str, Any]) -> Dict[str, Any]: diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index a71ed0c..0d9c410 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1654,24 +1654,20 @@ SCHEMATIC_TOOLS = [ }, { "name": "generate_netlist", - "title": "Generate Netlist", - "description": "Generates a netlist from the schematic showing all components and their net connections.", + "title": "Generate Netlist (JSON)", + "description": ( + "Returns a structured JSON netlist from the schematic: component list " + "(reference, value, footprint) and net list (net name + all connected " + "component/pin pairs). Uses kicad-cli internally — requires a saved " + ".kicad_sch file. For writing to a file or exporting SPICE/Cadstar/OrcadPCB2 " + "format, use export_netlist instead." + ), "inputSchema": { "type": "object", "properties": { "schematicPath": { "type": "string", - "description": "Path to schematic file", - }, - "outputPath": { - "type": "string", - "description": "Optional path to save netlist file", - }, - "format": { - "type": "string", - "enum": ["kicad", "json", "spice"], - "description": "Netlist output format", - "default": "json", + "description": "Absolute path to the .kicad_sch schematic file", }, }, "required": ["schematicPath"], diff --git a/src/tools/export.ts b/src/tools/export.ts index 0c6f524..f36f688 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -222,16 +222,19 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF // ------------------------------------------------------ server.tool( "export_netlist", + "Export the schematic netlist to a file using kicad-cli. Supports KiCad XML (default), Spice (for simulation), Cadstar, and OrcadPCB2 formats. Use this when you need to write a netlist file to disk — for example to produce a SPICE file for simulation or to diff against a reference. To get net/component data inline without writing a file, use generate_netlist instead.", { - outputPath: z.string().describe("Path to save the netlist file"), + schematicPath: z.string().describe("Absolute path to the .kicad_sch schematic file"), + outputPath: z.string().describe("Absolute path for the output file (e.g. /tmp/design.spice)"), format: z .enum(["KiCad", "Spice", "Cadstar", "OrcadPCB2"]) .optional() .describe("Netlist format (default: KiCad)"), }, - async ({ outputPath, format }) => { + async ({ schematicPath, outputPath, format }) => { logger.debug(`Exporting netlist to: ${outputPath}`); const result = await callKicadScript("export_netlist", { + schematicPath, outputPath, format, }); diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index 3ddad9a..3e8b63d 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -1152,9 +1152,9 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp // Generate netlist server.tool( "generate_netlist", - "Generate a netlist from the schematic", + "Return a structured JSON netlist from the schematic — component list (reference, value, footprint) and net list (net name with all connected component/pin pairs). Use this to inspect or verify connectivity within the conversation. Does not write any file. To export a netlist file in Spice, KiCad XML, Cadstar, or OrcadPCB2 format, use export_netlist instead.", { - schematicPath: z.string().describe("Path to the schematic file"), + schematicPath: z.string().describe("Absolute path to the .kicad_sch schematic file"), }, async (args: { schematicPath: string }) => { const result = await callKicadScript("generate_netlist", args); diff --git a/tests/test_netlist_handlers.py b/tests/test_netlist_handlers.py new file mode 100644 index 0000000..519d647 --- /dev/null +++ b/tests/test_netlist_handlers.py @@ -0,0 +1,375 @@ +""" +Tests for export_netlist and generate_netlist handlers. + +Covers: + - Parameter validation (unit) + - kicad-cli invocation and response parsing (unit, subprocess mocked) + - XML → structured JSON conversion for generate_netlist (unit) +""" + +import subprocess +import sys +import textwrap +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "python")) + + +# --------------------------------------------------------------------------- +# Shared fixture: KiCADInterface instance (no __init__, avoids pcbnew/IPC) +# --------------------------------------------------------------------------- + + +def _make_iface() -> Any: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + return iface + + +@pytest.fixture() +def iface(): + return _make_iface() + + +# --------------------------------------------------------------------------- +# Sample KiCad XML netlist (minimal but structurally valid) +# --------------------------------------------------------------------------- + +_KICAD_NETLIST_XML = textwrap.dedent("""\ + + + + + 10k + Resistor_SMD:R_0402 + + + 100n + Capacitor_SMD:C_0402 + + + + + + + + + + + + + +""") + + +# =========================================================================== +# Dispatch: both commands wired into command_routes +# =========================================================================== + + +@pytest.mark.unit +class TestNetlistDispatch: + def _make_full_iface(self) -> Any: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + obj = KiCADInterface.__new__(KiCADInterface) + obj.board = None + obj.project_filename = None + obj.use_ipc = False + obj.ipc_backend = MagicMock() + obj.ipc_board_api = None + obj.footprint_library = MagicMock() + obj.project_commands = MagicMock() + obj.board_commands = MagicMock() + obj.component_commands = MagicMock() + obj.routing_commands = MagicMock() + KiCADInterface.__init__(obj) + return obj + + def test_export_netlist_in_routes(self): + obj = self._make_full_iface() + assert "export_netlist" in obj.command_routes + assert callable(obj.command_routes["export_netlist"]) + + def test_generate_netlist_in_routes(self): + obj = self._make_full_iface() + assert "generate_netlist" in obj.command_routes + assert callable(obj.command_routes["generate_netlist"]) + + +# =========================================================================== +# export_netlist +# =========================================================================== + + +@pytest.mark.unit +class TestExportNetlistValidation: + def test_missing_schematic_path(self, iface, tmp_path): + result = iface._handle_export_netlist({"outputPath": str(tmp_path / "out.xml")}) + assert result["success"] is False + assert "schematicPath" in result["message"] + + def test_missing_output_path(self, iface, tmp_path): + sch = tmp_path / "test.kicad_sch" + sch.write_text("(kicad_sch)") + result = iface._handle_export_netlist({"schematicPath": str(sch)}) + assert result["success"] is False + assert "outputPath" in result["message"] + + def test_schematic_not_found(self, iface, tmp_path): + result = iface._handle_export_netlist( + { + "schematicPath": "/nonexistent/file.kicad_sch", + "outputPath": str(tmp_path / "out.xml"), + } + ) + assert result["success"] is False + assert "not found" in result["message"].lower() + + def test_kicad_cli_not_found(self, iface, tmp_path): + sch = tmp_path / "test.kicad_sch" + sch.write_text("(kicad_sch)") + with patch( + "kicad_interface.KiCADInterface._find_kicad_cli_static", staticmethod(lambda: None) + ): + result = iface._handle_export_netlist( + {"schematicPath": str(sch), "outputPath": str(tmp_path / "out.xml")} + ) + assert result["success"] is False + assert "kicad-cli" in result["message"] + + +@pytest.mark.unit +class TestExportNetlistCliInvocation: + def _run_with_mock_cli(self, iface, tmp_path, fmt_param, expected_cli_fmt): + sch = tmp_path / "test.kicad_sch" + sch.write_text("(kicad_sch)") + out = tmp_path / "out.net" + captured: dict = {} + + def fake_run(cmd, **kwargs): + captured["cmd"] = cmd + return MagicMock(returncode=0, stderr="") + + with ( + patch("subprocess.run", side_effect=fake_run), + patch( + "kicad_interface.KiCADInterface._find_kicad_cli_static", + staticmethod(lambda: "/usr/bin/kicad-cli"), + ), + ): + result = iface._handle_export_netlist( + {"schematicPath": str(sch), "outputPath": str(out), "format": fmt_param} + ) + + assert result["success"] is True, result + assert expected_cli_fmt in captured["cmd"] + assert str(sch) in captured["cmd"] + assert str(out) in captured["cmd"] + + def test_format_spice(self, iface, tmp_path): + self._run_with_mock_cli(iface, tmp_path, "Spice", "spice") + + def test_format_kicad(self, iface, tmp_path): + self._run_with_mock_cli(iface, tmp_path, "KiCad", "kicadxml") + + def test_format_cadstar(self, iface, tmp_path): + self._run_with_mock_cli(iface, tmp_path, "Cadstar", "cadstar") + + def test_format_orcadpcb2(self, iface, tmp_path): + self._run_with_mock_cli(iface, tmp_path, "OrcadPCB2", "orcadpcb2") + + def test_response_contains_output_path(self, iface, tmp_path): + sch = tmp_path / "test.kicad_sch" + sch.write_text("(kicad_sch)") + out = tmp_path / "out.net" + + with ( + patch("subprocess.run", return_value=MagicMock(returncode=0, stderr="")), + patch( + "kicad_interface.KiCADInterface._find_kicad_cli_static", + staticmethod(lambda: "/usr/bin/kicad-cli"), + ), + ): + result = iface._handle_export_netlist( + {"schematicPath": str(sch), "outputPath": str(out), "format": "Spice"} + ) + + assert result["success"] is True + assert result["outputPath"] == str(out) + assert result["format"] == "Spice" + + def test_cli_failure_propagated(self, iface, tmp_path): + sch = tmp_path / "test.kicad_sch" + sch.write_text("(kicad_sch)") + + with ( + patch("subprocess.run", return_value=MagicMock(returncode=1, stderr="bad input")), + patch( + "kicad_interface.KiCADInterface._find_kicad_cli_static", + staticmethod(lambda: "/usr/bin/kicad-cli"), + ), + ): + result = iface._handle_export_netlist( + {"schematicPath": str(sch), "outputPath": str(tmp_path / "out.net")} + ) + + assert result["success"] is False + assert "bad input" in result["message"] + + def test_cli_timeout_propagated(self, iface, tmp_path): + sch = tmp_path / "test.kicad_sch" + sch.write_text("(kicad_sch)") + + with ( + patch("subprocess.run", side_effect=subprocess.TimeoutExpired("kicad-cli", 60)), + patch( + "kicad_interface.KiCADInterface._find_kicad_cli_static", + staticmethod(lambda: "/usr/bin/kicad-cli"), + ), + ): + result = iface._handle_export_netlist( + {"schematicPath": str(sch), "outputPath": str(tmp_path / "out.net")} + ) + + assert result["success"] is False + assert "timed out" in result["message"].lower() + + +# =========================================================================== +# generate_netlist +# =========================================================================== + + +@pytest.mark.unit +class TestGenerateNetlistValidation: + def test_missing_schematic_path(self, iface): + result = iface._handle_generate_netlist({}) + assert result["success"] is False + assert "required" in result["message"].lower() + + def test_schematic_not_found(self, iface): + result = iface._handle_generate_netlist({"schematicPath": "/nonexistent/file.kicad_sch"}) + assert result["success"] is False + assert "not found" in result["message"].lower() + + def test_kicad_cli_not_found(self, iface, tmp_path): + sch = tmp_path / "test.kicad_sch" + sch.write_text("(kicad_sch)") + with patch( + "kicad_interface.KiCADInterface._find_kicad_cli_static", staticmethod(lambda: None) + ): + result = iface._handle_generate_netlist({"schematicPath": str(sch)}) + assert result["success"] is False + assert "kicad-cli" in result["message"] + + +@pytest.mark.unit +class TestGenerateNetlistXmlParsing: + """Verify the XML → JSON conversion is correct.""" + + def _call_with_xml(self, iface, tmp_path, xml_content): + sch = tmp_path / "test.kicad_sch" + sch.write_text("(kicad_sch)") + + def fake_run(cmd, **kwargs): + # Write the XML to the --output path in the command + out_idx = cmd.index("--output") + 1 + Path(cmd[out_idx]).write_text(xml_content) + return MagicMock(returncode=0, stderr="") + + with ( + patch("subprocess.run", side_effect=fake_run), + patch( + "kicad_interface.KiCADInterface._find_kicad_cli_static", + staticmethod(lambda: "/usr/bin/kicad-cli"), + ), + ): + return iface._handle_generate_netlist({"schematicPath": str(sch)}) + + def test_success_flag(self, iface, tmp_path): + result = self._call_with_xml(iface, tmp_path, _KICAD_NETLIST_XML) + assert result["success"] is True + + def test_components_count(self, iface, tmp_path): + result = self._call_with_xml(iface, tmp_path, _KICAD_NETLIST_XML) + assert len(result["netlist"]["components"]) == 2 + + def test_component_refs(self, iface, tmp_path): + result = self._call_with_xml(iface, tmp_path, _KICAD_NETLIST_XML) + refs = {c["reference"] for c in result["netlist"]["components"]} + assert refs == {"R1", "C1"} + + def test_component_fields(self, iface, tmp_path): + result = self._call_with_xml(iface, tmp_path, _KICAD_NETLIST_XML) + r1 = next(c for c in result["netlist"]["components"] if c["reference"] == "R1") + assert r1["value"] == "10k" + assert r1["footprint"] == "Resistor_SMD:R_0402" + + def test_nets_count(self, iface, tmp_path): + result = self._call_with_xml(iface, tmp_path, _KICAD_NETLIST_XML) + assert len(result["netlist"]["nets"]) == 2 + + def test_net_names(self, iface, tmp_path): + result = self._call_with_xml(iface, tmp_path, _KICAD_NETLIST_XML) + names = {n["name"] for n in result["netlist"]["nets"]} + assert names == {"VCC", "GND"} + + def test_net_connections(self, iface, tmp_path): + result = self._call_with_xml(iface, tmp_path, _KICAD_NETLIST_XML) + vcc = next(n for n in result["netlist"]["nets"] if n["name"] == "VCC") + assert len(vcc["connections"]) == 2 + comps = {c["component"] for c in vcc["connections"]} + assert comps == {"R1", "C1"} + + def test_cli_failure_propagated(self, iface, tmp_path): + sch = tmp_path / "test.kicad_sch" + sch.write_text("(kicad_sch)") + + with ( + patch("subprocess.run", return_value=MagicMock(returncode=1, stderr="parse error")), + patch( + "kicad_interface.KiCADInterface._find_kicad_cli_static", + staticmethod(lambda: "/usr/bin/kicad-cli"), + ), + ): + result = iface._handle_generate_netlist({"schematicPath": str(sch)}) + + assert result["success"] is False + assert "parse error" in result["message"] + + def test_cli_timeout_propagated(self, iface, tmp_path): + sch = tmp_path / "test.kicad_sch" + sch.write_text("(kicad_sch)") + + with ( + patch("subprocess.run", side_effect=subprocess.TimeoutExpired("kicad-cli", 60)), + patch( + "kicad_interface.KiCADInterface._find_kicad_cli_static", + staticmethod(lambda: "/usr/bin/kicad-cli"), + ), + ): + result = iface._handle_generate_netlist({"schematicPath": str(sch)}) + + assert result["success"] is False + assert "timed out" in result["message"].lower() + + def test_empty_schematic(self, iface, tmp_path): + empty_xml = textwrap.dedent("""\ + + + + + + """) + result = self._call_with_xml(iface, tmp_path, empty_xml) + assert result["success"] is True + assert result["netlist"]["components"] == [] + assert result["netlist"]["nets"] == [] From d1b87f928129049b97e27a1be89d4a317ea3549e Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 12 Apr 2026 20:19:42 +0100 Subject: [PATCH 71/74] docs: add pre-commit hooks setup instructions to CONTRIBUTING.md Co-Authored-By: Claude Sonnet 4.6 --- CONTRIBUTING.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2415801..0826692 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -107,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 From 3f3df82aa48f630825861b9f1dbb75c7d3c03381 Mon Sep 17 00:00:00 2001 From: Tom Date: Sun, 12 Apr 2026 14:49:48 +0200 Subject: [PATCH 72/74] fix: npm audit fix - resolve 8 vulnerabilities (4 high, 4 moderate) --- package-lock.json | 218 ++++++++++++++++++++++++++++++---------------- 1 file changed, 144 insertions(+), 74 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7e77ba1..3344ab8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -201,6 +201,18 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@hono/node-server": { + "version": "1.19.13", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", + "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -254,11 +266,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.21.0.tgz", - "integrity": "sha512-YFBsXJMFCyI1zP98u7gezMFKX4lgu/XpoZJk7ufI6UlFKXLj2hAMUuRlQX/nrmIPOmhRrG6tw2OQ2ZA/ZlXYpQ==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", "dependencies": { + "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -266,22 +279,29 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" }, "engines": { "node": ">=18" }, "peerDependencies": { - "@cfworker/json-schema": "^4.1.1" + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { "@cfworker/json-schema": { "optional": true + }, + "zod": { + "optional": false } } }, @@ -737,9 +757,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -804,29 +824,33 @@ } }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -1365,18 +1389,19 @@ } }, "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.0", + "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", + "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -1407,10 +1432,13 @@ } }, "node_modules/express-rate-limit": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", - "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, "engines": { "node": ">= 16" }, @@ -1418,7 +1446,7 @@ "url": "https://github.com/sponsors/express-rate-limit" }, "peerDependencies": { - "express": "^4.11 || 5 || ^5.0.0-beta.1" + "express": ">= 4.11" } }, "node_modules/fast-deep-equal": { @@ -1676,32 +1704,49 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.12", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", + "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ignore": { @@ -1737,6 +1782,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1804,6 +1858,15 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -1817,6 +1880,12 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -1916,9 +1985,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -2110,18 +2179,19 @@ } }, "node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", - "engines": { - "node": ">=16" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -2197,9 +2267,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -2221,18 +2291,18 @@ } }, "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" } }, "node_modules/readdirp": { @@ -2396,13 +2466,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -2462,9 +2532,9 @@ } }, "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -2737,12 +2807,12 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.24.5", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", - "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "license": "ISC", "peerDependencies": { - "zod": "^3.24.1" + "zod": "^3.25.28 || ^4" } } } From 5ab13172ff40e3f701fb6354b944053cf402e25a Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 13 Apr 2026 09:28:29 +0200 Subject: [PATCH 73/74] fix: replace placeholder repo URL with correct mixelpixx/KiCAD-MCP-Server --- CONTRIBUTING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0826692..72b2abd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,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 @@ -64,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 @@ -90,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 From 5c559998c787f9c09b7f4925e15f43e003decc64 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 13 Apr 2026 09:31:46 +0200 Subject: [PATCH 74/74] chore: ignore generated windows-mcp-config.json --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 0cff050..1f7f33a 100644 --- a/.gitignore +++ b/.gitignore @@ -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/