@hybridlabor-api/bdb-hardware-pcb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -0
- package/config/mcp/antigravity.json +22 -0
- package/config/mcp/claude.json +22 -0
- package/config/mcp/codex.toml +25 -0
- package/docs/adr/ADR-001-KICAD-OPENSCAD-MCP-STRATEGY.md +334 -0
- package/docs/review_documentation.md +121 -0
- package/installer.js +358 -0
- package/mcp_servers/kicad-mcp-server/.env.example +22 -0
- package/mcp_servers/kicad-mcp-server/.github/workflows/ci.yml +36 -0
- package/mcp_servers/kicad-mcp-server/CLAUDE.md +487 -0
- package/mcp_servers/kicad-mcp-server/README.md +316 -0
- package/mcp_servers/kicad-mcp-server/docs/DEVICE_TREE.md +416 -0
- package/mcp_servers/kicad-mcp-server/docs/INSTALLATION.md +332 -0
- package/mcp_servers/kicad-mcp-server/docs/PIN_ANALYSIS.md +332 -0
- package/mcp_servers/kicad-mcp-server/docs/README.md +240 -0
- package/mcp_servers/kicad-mcp-server/docs/TESTING.md +613 -0
- package/mcp_servers/kicad-mcp-server/docs/VALIDATION.md +268 -0
- package/mcp_servers/kicad-mcp-server/pyproject.toml +96 -0
- package/mcp_servers/kicad-mcp-server/requirements-dev.txt +16 -0
- package/mcp_servers/kicad-mcp-server/requirements-test.txt +24 -0
- package/mcp_servers/kicad-mcp-server/requirements.txt +13 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/__init__.py +3 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/__main__.py +17 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/config.py +46 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/models/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/models/types.py +87 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/netlist_parser.py +234 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/pcb_parser.py +375 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/pcb_parser_kicad.py +327 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/schematic_parser.py +902 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/server.py +71 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/arduino/connectivity_test.cpp.j2 +189 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/atmega.dts.j2 +77 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/esp32.dts.j2 +77 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/nrf52.dts.j2 +77 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/stm32f4.dts.j2 +89 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/esp_idf/test_suite.c.j2 +340 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/pytest/test_connectivity.py.j2 +147 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/st_hal/hal_test.c.j2 +313 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_gpio_test.py.j2 +99 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_i2c_test.py.j2 +117 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_pinmux_test.py.j2 +43 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_spi_test.py.j2 +94 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_gpio_test.c.j2 +113 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_i2c_test.c.j2 +101 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_spi_test.c.j2 +94 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/unittest/test_schematic.py.j2 +172 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/__init__.py +36 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/device_tree.py +1187 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/hierarchical_analysis.py +211 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/netlist.py +320 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/parts_registry.py +142 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pcb.py +955 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pcb_layout.py +308 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pin_analysis.py +765 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/project.py +196 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic.py +319 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic_editor.py +674 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic_search.py +158 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/validation.py +866 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/visualization.py +225 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/file_handlers.py +65 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/kicad_cli.py +103 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/kicad_version.py +103 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/parts_registry.py +197 -0
- package/mcp_servers/kicad-mcp-server/tests/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/tests/examples/ESP32S3_TEST.md +219 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/README.md +65 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/example_pcb.kicad_pcb +177 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/example_schematic.kicad_sch +145 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/hier/child.kicad_sch +24 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/hier/root.kicad_sch +38 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_hierarchical_labels.py +195 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_kicad_cli.py +116 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_netlist_cache_path.py +29 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_schematic.py +189 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_schematic_hierarchy.py +69 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_visualization.py +136 -0
- package/mcp_servers/kicad-mcp-server/uv.lock +2873 -0
- package/mcp_servers/openscad-mcp-server/.dockerignore +9 -0
- package/mcp_servers/openscad-mcp-server/.github/workflows/test.yml +40 -0
- package/mcp_servers/openscad-mcp-server/Dockerfile +29 -0
- package/mcp_servers/openscad-mcp-server/LICENSE +21 -0
- package/mcp_servers/openscad-mcp-server/README.md +154 -0
- package/mcp_servers/openscad-mcp-server/docs/audit.md +56 -0
- package/mcp_servers/openscad-mcp-server/docs/docker.md +66 -0
- package/mcp_servers/openscad-mcp-server/docs/issue-followup.md +19 -0
- package/mcp_servers/openscad-mcp-server/docs/jetson.md +17 -0
- package/mcp_servers/openscad-mcp-server/glama.json +4 -0
- package/mcp_servers/openscad-mcp-server/legacy/README.md +15 -0
- package/mcp_servers/openscad-mcp-server/legacy/README.original.md +294 -0
- package/mcp_servers/openscad-mcp-server/legacy/implementation_plan.md +100 -0
- package/mcp_servers/openscad-mcp-server/legacy/old/download_sam2_checkpoint.py +115 -0
- package/mcp_servers/openscad-mcp-server/legacy/old/src/ai/sam_segmentation.py +209 -0
- package/mcp_servers/openscad-mcp-server/legacy/old/src/models/threestudio_generator.py +231 -0
- package/mcp_servers/openscad-mcp-server/legacy/old/src/workflow/image_to_model_pipeline.py +260 -0
- package/mcp_servers/openscad-mcp-server/legacy/old/test_sam2_segmentation.py +96 -0
- package/mcp_servers/openscad-mcp-server/legacy/requirements.txt +57 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/README.md +39 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/decisions/ai-driven-code-generation.md +122 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/decisions/export-formats.md +76 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/ai/ai_service.py.md +51 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/main.py.md +63 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/models/code_generator.py.md +63 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/nlp/parameter_extractor.py.md +63 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/ai/natural-language-processing.md +78 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/nlp/parameter-extraction.md +173 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/export-formats.md +91 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/openscad-basics.md +66 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/primitive-testing.md +79 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/ai/ai_service.py +257 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/ai/gemini_api.py +161 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/ai/venice_api.py +203 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/config.py +121 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/main.py +1456 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/main.py.new +404 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/main_remote.py +401 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/models/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/models/code_generator.py +321 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/models/cuda_mvs.py +209 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/models/scad_templates/basic_shapes.scad +144 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/nlp/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/nlp/parameter_extractor.py +388 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/openscad_wrapper/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/openscad_wrapper/wrapper.py +418 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/printer_discovery/__init__.py +1 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/printer_discovery/printer_discovery.py +471 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/remote/connection_manager.py +537 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/remote/cuda_mvs_client.py +435 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/remote/cuda_mvs_server.py +787 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/remote/error_handling.py +415 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/testing/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/testing/primitive_tester.py +203 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/testing/test_primitives.py +98 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/__init__.py +1 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/cad_exporter.py +241 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/format_validator.py +206 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_exporter.py +140 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_repair.py +91 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_validator.py +123 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/visualization/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/visualization/headless_renderer.py +52 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/visualization/renderer.py +177 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/visualization/web_interface.py +639 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/workflow/image_approval.py +148 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/workflow/multi_view_to_model_pipeline.py +338 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_complete_workflow.py +374 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_cuda_mvs.py +191 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_gemini_api.py +168 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_image_approval.py +192 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_image_approval_workflow.py +251 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_image_to_model_pipeline.py +145 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_model_selection.py +41 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_multi_view_pipeline.py +290 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_primitives.sh +13 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_rabbit_direct.py +71 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_remote_cuda_mvs.py +283 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_venice_example.py +69 -0
- package/mcp_servers/openscad-mcp-server/pyproject.toml +33 -0
- package/mcp_servers/openscad-mcp-server/requirements.txt +2 -0
- package/mcp_servers/openscad-mcp-server/scad/simple_cube.scad +2 -0
- package/mcp_servers/openscad-mcp-server/scripts/test_docker.py +220 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/__init__.py +3 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/__main__.py +3 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/engine.py +94 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/geometry.py +242 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/server.py +245 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/service.py +190 -0
- package/mcp_servers/openscad-mcp-server/tests/conftest.py +17 -0
- package/mcp_servers/openscad-mcp-server/tests/test_engine.py +37 -0
- package/mcp_servers/openscad-mcp-server/tests/test_geometry.py +106 -0
- package/mcp_servers/openscad-mcp-server/tests/test_integration.py +172 -0
- package/mcp_servers/openscad-mcp-server/tests/test_transports.py +314 -0
- package/mcp_servers/openscad-mcp-server/uv.lock +1123 -0
- package/package.json +44 -0
- package/scripts/install_mcps.sh +86 -0
- package/scripts/openscad_wrapper.sh +62 -0
- package/scripts/run_kicad_mcp.sh +25 -0
- package/scripts/run_openscad_mcp.sh +41 -0
- package/scripts/test_mcp_connection.py +626 -0
- package/scripts/test_mcp_connection.sh +168 -0
- package/skills/code-first-hardware-design/SKILL.md +260 -0
- package/skills/pcb-constraint-definition/SKILL.md +236 -0
- package/skills/pcb-layout-routing-automation/SKILL.md +153 -0
- package/skills/pcb-validation-dfm-signoff/SKILL.md +194 -0
- package/skills/schematic-datasheet-analysis/SKILL.md +202 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Board visualization export tools.
|
|
2
|
+
|
|
3
|
+
These wrap kicad-cli's rendering and 3D/2D exporters so that MCP clients
|
|
4
|
+
(and the multimodal LLMs behind them) can *see* a design:
|
|
5
|
+
|
|
6
|
+
- render_pcb: 3D-rendered PNG/JPEG of the board (top/bottom/isometric views)
|
|
7
|
+
- export_pcb_3d: STEP model for mechanical CAD handoff
|
|
8
|
+
- export_schematic_svg: per-page schematic SVGs
|
|
9
|
+
|
|
10
|
+
render_pcb returns a FastMCP Image so capable clients display the picture
|
|
11
|
+
inline instead of just a path.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from fastmcp.utilities.types import Image
|
|
17
|
+
|
|
18
|
+
from ..server import mcp
|
|
19
|
+
from ..utils.kicad_cli import run_kicad_cli
|
|
20
|
+
|
|
21
|
+
_RENDER_SIDES = {"top", "bottom", "left", "right", "front", "back"}
|
|
22
|
+
_RENDER_QUALITIES = {"basic", "high", "user", "job_settings"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@mcp.tool()
|
|
26
|
+
async def render_pcb(
|
|
27
|
+
pcb_path: str,
|
|
28
|
+
side: str = "top",
|
|
29
|
+
output_path: str = "",
|
|
30
|
+
width: int = 1600,
|
|
31
|
+
height: int = 900,
|
|
32
|
+
quality: str = "basic",
|
|
33
|
+
rotate: str = "",
|
|
34
|
+
) -> Image | str:
|
|
35
|
+
"""Render a 3D view of the PCB to a PNG image.
|
|
36
|
+
|
|
37
|
+
Produces a photorealistic render of the assembled board via kicad-cli
|
|
38
|
+
(footprints must have 3D models assigned; otherwise plain board geometry
|
|
39
|
+
is rendered). Returns the image itself, so multimodal clients show it
|
|
40
|
+
directly — useful for AI-assisted layout review.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
pcb_path: Path to the .kicad_pcb file.
|
|
44
|
+
side: Camera side: top, bottom, left, right, front, back.
|
|
45
|
+
output_path: Output PNG (default: <pcb>_render_<side>.png next to
|
|
46
|
+
the PCB).
|
|
47
|
+
width: Image width in pixels.
|
|
48
|
+
height: Image height in pixels.
|
|
49
|
+
quality: Render quality: basic (fast) or high (slower, raytraced).
|
|
50
|
+
rotate: Optional board rotation 'X,Y,Z' in degrees for isometric
|
|
51
|
+
views, e.g. '-30,0,25'.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The rendered image, or an error message string.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
pcb = Path(pcb_path)
|
|
58
|
+
if not pcb.exists():
|
|
59
|
+
return f"❌ PCB file not found: {pcb_path}"
|
|
60
|
+
if side not in _RENDER_SIDES:
|
|
61
|
+
return f"❌ Invalid side '{side}'. Options: {', '.join(sorted(_RENDER_SIDES))}"
|
|
62
|
+
if quality not in _RENDER_QUALITIES:
|
|
63
|
+
return f"❌ Invalid quality '{quality}'. Options: {', '.join(sorted(_RENDER_QUALITIES))}"
|
|
64
|
+
|
|
65
|
+
out = Path(output_path) if output_path else pcb.parent / f"{pcb.stem}_render_{side}.png"
|
|
66
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
|
|
68
|
+
args = [
|
|
69
|
+
"pcb",
|
|
70
|
+
"render",
|
|
71
|
+
"--output",
|
|
72
|
+
str(out),
|
|
73
|
+
"--side",
|
|
74
|
+
side,
|
|
75
|
+
"--width",
|
|
76
|
+
str(width),
|
|
77
|
+
"--height",
|
|
78
|
+
str(height),
|
|
79
|
+
"--quality",
|
|
80
|
+
quality,
|
|
81
|
+
]
|
|
82
|
+
if rotate:
|
|
83
|
+
args += ["--rotate", rotate]
|
|
84
|
+
args.append(str(pcb))
|
|
85
|
+
|
|
86
|
+
result = await run_kicad_cli(args, timeout=180)
|
|
87
|
+
|
|
88
|
+
if result.returncode != 0 or not out.exists():
|
|
89
|
+
stderr = (result.stderr or b"").decode("utf-8", "replace").strip()
|
|
90
|
+
return (
|
|
91
|
+
f"❌ PCB render failed (kicad-cli rc={result.returncode}):\n\n"
|
|
92
|
+
f"{stderr or '(no stderr, and no output file produced)'}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return Image(path=str(out))
|
|
96
|
+
except FileNotFoundError:
|
|
97
|
+
return (
|
|
98
|
+
"❌ kicad-cli not found (PATH or KiCad install directory). "
|
|
99
|
+
"Rendering requires KiCad 8+."
|
|
100
|
+
)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
import traceback
|
|
103
|
+
|
|
104
|
+
return f"❌ Error rendering PCB: {e}\n\n{traceback.format_exc()}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@mcp.tool()
|
|
108
|
+
async def export_pcb_3d(
|
|
109
|
+
pcb_path: str,
|
|
110
|
+
output_path: str = "",
|
|
111
|
+
board_only: bool = False,
|
|
112
|
+
) -> str:
|
|
113
|
+
"""Export the PCB as a STEP 3D model for mechanical CAD handoff.
|
|
114
|
+
|
|
115
|
+
Includes board body, footprints and their assigned 3D models.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
pcb_path: Path to the .kicad_pcb file.
|
|
119
|
+
output_path: Output .step file (default: <pcb>.step next to the PCB).
|
|
120
|
+
board_only: Export only the board body, without component models.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Confirmation with the output path and file size.
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
pcb = Path(pcb_path)
|
|
127
|
+
if not pcb.exists():
|
|
128
|
+
return f"❌ PCB file not found: {pcb_path}"
|
|
129
|
+
|
|
130
|
+
out = Path(output_path) if output_path else pcb.parent / f"{pcb.stem}.step"
|
|
131
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
|
|
133
|
+
args = ["pcb", "export", "step", "--output", str(out), "--force", str(pcb)]
|
|
134
|
+
if board_only:
|
|
135
|
+
args.insert(-1, "--board-only")
|
|
136
|
+
|
|
137
|
+
result = await run_kicad_cli(args, timeout=300)
|
|
138
|
+
|
|
139
|
+
if result.returncode != 0 or not out.exists():
|
|
140
|
+
stderr = (result.stderr or b"").decode("utf-8", "replace").strip()
|
|
141
|
+
return (
|
|
142
|
+
f"❌ STEP export failed (kicad-cli rc={result.returncode}):\n\n"
|
|
143
|
+
f"{stderr or '(no stderr, and no output file produced)'}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
size_mb = out.stat().st_size / (1024 * 1024)
|
|
147
|
+
return f"""✅ STEP 3D model exported.
|
|
148
|
+
|
|
149
|
+
**PCB:** {pcb_path}
|
|
150
|
+
**Output:** {out}
|
|
151
|
+
**Size:** {size_mb:.1f} MB
|
|
152
|
+
|
|
153
|
+
Import into Fusion 360 / SolidWorks / FreeCAD for enclosure design."""
|
|
154
|
+
except FileNotFoundError:
|
|
155
|
+
return (
|
|
156
|
+
"❌ kicad-cli not found (PATH or KiCad install directory). "
|
|
157
|
+
"STEP export requires KiCad 7+."
|
|
158
|
+
)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
import traceback
|
|
161
|
+
|
|
162
|
+
return f"❌ Error exporting STEP: {e}\n\n{traceback.format_exc()}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@mcp.tool()
|
|
166
|
+
async def export_schematic_svg(
|
|
167
|
+
schematic_path: str,
|
|
168
|
+
output_dir: str = "",
|
|
169
|
+
) -> str:
|
|
170
|
+
"""Export schematic pages to SVG images.
|
|
171
|
+
|
|
172
|
+
Renders every page of the (hierarchical) schematic as an SVG file via
|
|
173
|
+
kicad-cli. SVGs are vector graphics — clients and humans can zoom into
|
|
174
|
+
any detail without quality loss.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
schematic_path: Path to the root .kicad_sch file.
|
|
178
|
+
output_dir: Output directory (default: <schematic>_svg/ next to it).
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Confirmation listing the generated SVG files.
|
|
182
|
+
"""
|
|
183
|
+
try:
|
|
184
|
+
sch = Path(schematic_path)
|
|
185
|
+
if not sch.exists():
|
|
186
|
+
return f"❌ Schematic file not found: {schematic_path}"
|
|
187
|
+
|
|
188
|
+
out_dir = Path(output_dir) if output_dir else sch.parent / f"{sch.stem}_svg"
|
|
189
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
|
|
191
|
+
result = await run_kicad_cli(
|
|
192
|
+
["sch", "export", "svg", "--output", str(out_dir), str(sch)],
|
|
193
|
+
timeout=120,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if result.returncode != 0:
|
|
197
|
+
stderr = (result.stderr or b"").decode("utf-8", "replace").strip()
|
|
198
|
+
return (
|
|
199
|
+
f"❌ Schematic SVG export failed (kicad-cli rc={result.returncode}):\n\n"
|
|
200
|
+
f"{stderr or '(no stderr)'}"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
svgs = sorted(out_dir.glob("*.svg"))
|
|
204
|
+
if not svgs:
|
|
205
|
+
return (
|
|
206
|
+
f"⚠️ kicad-cli reported success but produced no SVG files in {out_dir}."
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
listing = "\n".join(f"- {s.name}" for s in svgs)
|
|
210
|
+
return f"""✅ Schematic SVG export complete.
|
|
211
|
+
|
|
212
|
+
**Schematic:** {schematic_path}
|
|
213
|
+
**Output directory:** {out_dir}
|
|
214
|
+
|
|
215
|
+
**Files generated ({len(svgs)}):**
|
|
216
|
+
{listing}"""
|
|
217
|
+
except FileNotFoundError:
|
|
218
|
+
return (
|
|
219
|
+
"❌ kicad-cli not found (PATH or KiCad install directory). "
|
|
220
|
+
"SVG export requires KiCad 7+."
|
|
221
|
+
)
|
|
222
|
+
except Exception as e:
|
|
223
|
+
import traceback
|
|
224
|
+
|
|
225
|
+
return f"❌ Error exporting schematic SVG: {e}\n\n{traceback.format_exc()}"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utility functions for KiCad MCP Server."""
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""File handling utilities."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def validate_kicad_file(file_path: str, expected_extension: str) -> Path:
|
|
7
|
+
"""Validate that a file exists and has the correct extension.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
file_path: Path to the file
|
|
11
|
+
expected_extension: Expected file extension (e.g., '.kicad_sch')
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
Resolved Path object
|
|
15
|
+
|
|
16
|
+
Raises:
|
|
17
|
+
FileNotFoundError: If file doesn't exist
|
|
18
|
+
ValueError: If file has wrong extension
|
|
19
|
+
"""
|
|
20
|
+
path = Path(file_path).resolve()
|
|
21
|
+
|
|
22
|
+
if not path.exists():
|
|
23
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
24
|
+
|
|
25
|
+
if path.suffix.lower() != expected_extension.lower():
|
|
26
|
+
raise ValueError(
|
|
27
|
+
f"Expected {expected_extension} file, got {path.suffix}: {file_path}"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
return path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_project_path(file_path: str, search_paths: list[str] | None = None) -> Path:
|
|
34
|
+
"""Resolve a file path, optionally searching project directories.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
file_path: Relative or absolute file path
|
|
38
|
+
search_paths: List of directories to search if file_path is relative
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Resolved Path object
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
FileNotFoundError: If file cannot be found
|
|
45
|
+
"""
|
|
46
|
+
path = Path(file_path)
|
|
47
|
+
|
|
48
|
+
# If absolute path, just resolve it
|
|
49
|
+
if path.is_absolute():
|
|
50
|
+
if path.exists():
|
|
51
|
+
return path.resolve()
|
|
52
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
53
|
+
|
|
54
|
+
# If relative path, try current directory first
|
|
55
|
+
if path.exists():
|
|
56
|
+
return path.resolve()
|
|
57
|
+
|
|
58
|
+
# Search in provided paths
|
|
59
|
+
if search_paths:
|
|
60
|
+
for search_dir in search_paths:
|
|
61
|
+
search_path = Path(search_dir) / path
|
|
62
|
+
if search_path.exists():
|
|
63
|
+
return search_path.resolve()
|
|
64
|
+
|
|
65
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""kicad-cli invocation helpers.
|
|
2
|
+
|
|
3
|
+
Centralizes two concerns that every kicad-cli call site needs:
|
|
4
|
+
|
|
5
|
+
1. Locating the executable. On Windows, KiCad's installer does NOT add its
|
|
6
|
+
bin directory to PATH, so a bare "kicad-cli" only works if the user
|
|
7
|
+
patched PATH by hand. We fall back to the detected install location
|
|
8
|
+
(``find_kicad_install``), where kicad-cli sits next to the versioned
|
|
9
|
+
``share/kicad`` directory.
|
|
10
|
+
2. Running it off the asyncio event loop. All MCP tools are ``async def``;
|
|
11
|
+
calling ``subprocess.run`` inline blocks the whole server for the duration
|
|
12
|
+
of an ERC/DRC/export (up to minutes), so callers must use the async
|
|
13
|
+
``run_kicad_cli`` wrapper instead.
|
|
14
|
+
|
|
15
|
+
The sync ``run_kicad_cli_sync`` variant exists for tests and other
|
|
16
|
+
non-async contexts.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from .kicad_version import find_kicad_install
|
|
26
|
+
|
|
27
|
+
_cached_exe: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def find_kicad_cli() -> str | None:
|
|
31
|
+
"""Locate the kicad-cli executable.
|
|
32
|
+
|
|
33
|
+
Resolution order:
|
|
34
|
+
1. ``KICAD_CLI`` environment variable (explicit user override).
|
|
35
|
+
2. ``shutil.which("kicad-cli")`` (PATH lookup — default on Linux/macOS
|
|
36
|
+
and on Windows installs whose bin dir was added to PATH).
|
|
37
|
+
3. Known KiCad install locations via ``find_kicad_install()``.
|
|
38
|
+
|
|
39
|
+
Returns the executable path, or None when KiCad cannot be found.
|
|
40
|
+
"""
|
|
41
|
+
global _cached_exe
|
|
42
|
+
if _cached_exe is not None:
|
|
43
|
+
return _cached_exe
|
|
44
|
+
|
|
45
|
+
override = os.environ.get("KICAD_CLI")
|
|
46
|
+
if override and Path(override).is_file():
|
|
47
|
+
_cached_exe = override
|
|
48
|
+
return _cached_exe
|
|
49
|
+
|
|
50
|
+
found = shutil.which("kicad-cli")
|
|
51
|
+
if found:
|
|
52
|
+
_cached_exe = found
|
|
53
|
+
return _cached_exe
|
|
54
|
+
|
|
55
|
+
install = find_kicad_install()
|
|
56
|
+
if install:
|
|
57
|
+
install_path, _version = install
|
|
58
|
+
exe_name = "kicad-cli.exe" if os.name == "nt" else "kicad-cli"
|
|
59
|
+
# Windows: <install>/bin/kicad-cli.exe
|
|
60
|
+
# macOS: install_path is .../Contents/SharedSupport, the binary is
|
|
61
|
+
# in the sibling MacOS directory.
|
|
62
|
+
# Linux: distro packages put kicad-cli on PATH, which step 2 found.
|
|
63
|
+
candidates = [
|
|
64
|
+
install_path / "bin" / exe_name,
|
|
65
|
+
install_path.parent / "MacOS" / "kicad-cli",
|
|
66
|
+
]
|
|
67
|
+
for candidate in candidates:
|
|
68
|
+
if candidate.is_file():
|
|
69
|
+
_cached_exe = str(candidate)
|
|
70
|
+
return _cached_exe
|
|
71
|
+
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def run_kicad_cli_sync(
|
|
76
|
+
args: list[str],
|
|
77
|
+
timeout: float = 60.0,
|
|
78
|
+
) -> subprocess.CompletedProcess:
|
|
79
|
+
"""Run kicad-cli synchronously (blocking).
|
|
80
|
+
|
|
81
|
+
Raises FileNotFoundError when kicad-cli cannot be located, so callers can
|
|
82
|
+
distinguish "KiCad not installed" from a failed run.
|
|
83
|
+
"""
|
|
84
|
+
exe = find_kicad_cli()
|
|
85
|
+
if exe is None:
|
|
86
|
+
raise FileNotFoundError(
|
|
87
|
+
"kicad-cli not found in PATH or known KiCad install locations. "
|
|
88
|
+
"Install KiCad 7+ or set the KICAD_CLI environment variable."
|
|
89
|
+
)
|
|
90
|
+
return subprocess.run([exe] + args, capture_output=True, timeout=timeout)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def run_kicad_cli(
|
|
94
|
+
args: list[str],
|
|
95
|
+
timeout: float = 60.0,
|
|
96
|
+
) -> subprocess.CompletedProcess:
|
|
97
|
+
"""Run kicad-cli off the event loop (non-blocking for the server).
|
|
98
|
+
|
|
99
|
+
Raises FileNotFoundError when kicad-cli cannot be located — same contract
|
|
100
|
+
as the sync variant, so existing ``except FileNotFoundError`` handlers
|
|
101
|
+
keep working.
|
|
102
|
+
"""
|
|
103
|
+
return await asyncio.to_thread(run_kicad_cli_sync, args, timeout)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""KiCad installation detection and version utilities."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_cached_install: tuple[Path, str] | None = None
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _parse_version(version_str: str) -> tuple[int, ...]:
|
|
10
|
+
"""Parse version string like '10.0' into tuple for sorting."""
|
|
11
|
+
try:
|
|
12
|
+
return tuple(int(x) for x in version_str.split("."))
|
|
13
|
+
except (ValueError, AttributeError):
|
|
14
|
+
return (0,)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def find_kicad_install() -> tuple[Path, str] | None:
|
|
18
|
+
"""Find the installed KiCad directory and version string.
|
|
19
|
+
|
|
20
|
+
Scans known install locations, preferring the newest version.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
(install_path, version_string) or None if not found
|
|
24
|
+
"""
|
|
25
|
+
global _cached_install
|
|
26
|
+
if _cached_install is not None:
|
|
27
|
+
return _cached_install
|
|
28
|
+
|
|
29
|
+
# Windows: C:/Program Files/KiCad/{version}/
|
|
30
|
+
win_base = Path("C:/Program Files/KiCad")
|
|
31
|
+
if win_base.is_dir():
|
|
32
|
+
try:
|
|
33
|
+
version_dirs = sorted(
|
|
34
|
+
[d for d in win_base.iterdir() if d.is_dir()],
|
|
35
|
+
key=lambda d: _parse_version(d.name),
|
|
36
|
+
reverse=True,
|
|
37
|
+
)
|
|
38
|
+
for vd in version_dirs:
|
|
39
|
+
if (vd / "share" / "kicad").is_dir():
|
|
40
|
+
_cached_install = (vd, vd.name)
|
|
41
|
+
return _cached_install
|
|
42
|
+
except OSError:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
# macOS: single app bundle
|
|
46
|
+
mac_base = Path("/Applications/KiCad/KiCad.app/Contents/SharedSupport")
|
|
47
|
+
if mac_base.is_dir():
|
|
48
|
+
_cached_install = (mac_base, "macos")
|
|
49
|
+
return _cached_install
|
|
50
|
+
|
|
51
|
+
# Linux: standard paths
|
|
52
|
+
for linux_base in [
|
|
53
|
+
Path("/usr/share/kicad"),
|
|
54
|
+
Path("/usr/local/share/kicad"),
|
|
55
|
+
]:
|
|
56
|
+
if linux_base.is_dir():
|
|
57
|
+
_cached_install = (linux_base, "linux")
|
|
58
|
+
return _cached_install
|
|
59
|
+
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_kicad_symbol_dir() -> Path | None:
|
|
64
|
+
"""Get the KiCad symbol library directory (.kicad_sym files)."""
|
|
65
|
+
kicad = find_kicad_install()
|
|
66
|
+
if kicad:
|
|
67
|
+
install_path, version_marker = kicad
|
|
68
|
+
# find_kicad_install() returns different path shapes per platform:
|
|
69
|
+
# Windows returns the top-level "<...>/KiCad/<version>" dir, with
|
|
70
|
+
# symbols nested under "share/kicad/symbols". Linux/macOS instead
|
|
71
|
+
# already return the "share/kicad" (or SharedSupport) directory
|
|
72
|
+
# itself, so appending "share/kicad" again pointed at a path that
|
|
73
|
+
# never exists there.
|
|
74
|
+
if version_marker in ("linux", "macos"):
|
|
75
|
+
sym_dir = install_path / "symbols"
|
|
76
|
+
else:
|
|
77
|
+
sym_dir = install_path / "share" / "kicad" / "symbols"
|
|
78
|
+
if sym_dir.is_dir():
|
|
79
|
+
return sym_dir
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_pcb_version() -> str:
|
|
84
|
+
"""Get the correct PCB file version string for the installed KiCad.
|
|
85
|
+
|
|
86
|
+
Reads from a template PCB file if available, falls back to '20240130'.
|
|
87
|
+
"""
|
|
88
|
+
kicad = find_kicad_install()
|
|
89
|
+
if kicad:
|
|
90
|
+
install_path, _version = kicad
|
|
91
|
+
for template_name in ["Arduino_Mega", "EuroCard160mmX100mm"]:
|
|
92
|
+
template_pcb = (
|
|
93
|
+
install_path / "share" / "kicad" / "template" / template_name / f"{template_name}.kicad_pcb"
|
|
94
|
+
)
|
|
95
|
+
if template_pcb.exists():
|
|
96
|
+
try:
|
|
97
|
+
content = template_pcb.read_text(encoding="utf-8", errors="replace")
|
|
98
|
+
m = re.search(r"\(kicad_pcb\s+\(version\s+(\d+)\)", content)
|
|
99
|
+
if m:
|
|
100
|
+
return m.group(1)
|
|
101
|
+
except OSError:
|
|
102
|
+
pass
|
|
103
|
+
return "20240130"
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Client for an open parts registry (default: PartReel, https://partreel.com).
|
|
3
|
+
|
|
4
|
+
Lets tools search a public registry of verified KiCad parts and download
|
|
5
|
+
symbol/footprint/3D files without any account or API key. The registry URL is
|
|
6
|
+
configurable, so any service exposing the same JSON shape works.
|
|
7
|
+
|
|
8
|
+
Security posture (all enforced here, not in the tools):
|
|
9
|
+
- Asset downloads are restricted to HTTPS URLs on the registry's own host,
|
|
10
|
+
its subdomains, or hosts explicitly allowed via PARTS_REGISTRY_ASSET_HOSTS.
|
|
11
|
+
- Saved filenames are derived from the remote basename but must match the
|
|
12
|
+
extension allow-list for the requested format (prevents e.g. ``.exe``).
|
|
13
|
+
- Downloads are size-capped (MAX_ASSET_BYTES) and streamed to disk.
|
|
14
|
+
- Destination directories are validated by the caller via PathValidator.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import fnmatch
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import urllib.parse
|
|
21
|
+
import urllib.request
|
|
22
|
+
|
|
23
|
+
DEFAULT_REGISTRY_URL = os.environ.get(
|
|
24
|
+
"PARTS_REGISTRY_URL", "https://partreel.com/api/v1"
|
|
25
|
+
)
|
|
26
|
+
USER_AGENT = "kicad-mcp-parts-registry"
|
|
27
|
+
MAX_ASSET_BYTES = 50 * 1024 * 1024 # 50 MB cap for any single asset download
|
|
28
|
+
HTTP_TIMEOUT = 30 # seconds
|
|
29
|
+
|
|
30
|
+
# format name -> allowed file extensions (lowercase)
|
|
31
|
+
FORMAT_EXTENSIONS = {
|
|
32
|
+
"footprint": (".kicad_mod",),
|
|
33
|
+
"symbol": (".kicad_sym",),
|
|
34
|
+
"step": (".step", ".stp"),
|
|
35
|
+
"preview": (".glb",),
|
|
36
|
+
"footprint_svg": (".svg",),
|
|
37
|
+
"symbol_svg": (".svg",),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RegistryError(Exception):
|
|
42
|
+
"""Raised for registry access or validation failures."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _NoCrossHostRedirect(urllib.request.HTTPRedirectHandler):
|
|
46
|
+
"""Block HTTP redirects to a different host.
|
|
47
|
+
|
|
48
|
+
``asset_url_allowed`` only checks the URL passed in; urllib otherwise
|
|
49
|
+
silently follows 3xx to any host, letting a compromised registry point
|
|
50
|
+
asset downloads (or JSON metadata) at an attacker-controlled host.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
54
|
+
orig = (urllib.parse.urlparse(req.full_url).hostname or "").lower()
|
|
55
|
+
new = (urllib.parse.urlparse(newurl).hostname or "").lower()
|
|
56
|
+
if orig and new and (new == orig or new.endswith("." + orig)):
|
|
57
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
58
|
+
raise RegistryError(f"redirect to disallowed host blocked: {newurl}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_DEFAULT_OPENER = urllib.request.build_opener(_NoCrossHostRedirect)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _fetch(url: str, opener=None) -> bytes:
|
|
65
|
+
"""GET a URL with a size cap. ``opener`` is injectable for tests."""
|
|
66
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
67
|
+
open_fn = opener or _DEFAULT_OPENER.open
|
|
68
|
+
with open_fn(req, timeout=HTTP_TIMEOUT) as resp:
|
|
69
|
+
declared = resp.headers.get("Content-Length")
|
|
70
|
+
data = resp.read(MAX_ASSET_BYTES + 1)
|
|
71
|
+
# Honour Content-Length so a truncated stream (proxy/server drops the
|
|
72
|
+
# connection mid-asset) is caught, not silently written as a half-file.
|
|
73
|
+
if declared and declared.isdigit() and int(declared) > MAX_ASSET_BYTES:
|
|
74
|
+
raise RegistryError(f"asset exceeds {MAX_ASSET_BYTES} byte limit: {url}")
|
|
75
|
+
if len(data) > MAX_ASSET_BYTES:
|
|
76
|
+
raise RegistryError(f"asset exceeds {MAX_ASSET_BYTES} byte limit: {url}")
|
|
77
|
+
return data
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def registry_host(registry_url: str = DEFAULT_REGISTRY_URL) -> str:
|
|
81
|
+
"""Hostname of the configured registry."""
|
|
82
|
+
return urllib.parse.urlparse(registry_url).hostname or ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def extra_asset_hosts() -> list[str]:
|
|
86
|
+
"""Extra allowed asset hosts from PARTS_REGISTRY_ASSET_HOSTS (comma list)."""
|
|
87
|
+
raw = os.environ.get("PARTS_REGISTRY_ASSET_HOSTS", "")
|
|
88
|
+
return [h.strip().lower() for h in raw.split(",") if h.strip()]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def asset_url_allowed(url: str, registry_url: str = DEFAULT_REGISTRY_URL) -> bool:
|
|
92
|
+
"""True if ``url`` is HTTPS on the registry host, a subdomain of it, or an
|
|
93
|
+
explicitly allowed extra host."""
|
|
94
|
+
parsed = urllib.parse.urlparse(url)
|
|
95
|
+
if parsed.scheme != "https" or not parsed.hostname:
|
|
96
|
+
return False
|
|
97
|
+
host = parsed.hostname.lower()
|
|
98
|
+
root = registry_host(registry_url).lower()
|
|
99
|
+
if not root:
|
|
100
|
+
return False
|
|
101
|
+
if host == root or host.endswith("." + root):
|
|
102
|
+
return True
|
|
103
|
+
return host in extra_asset_hosts()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def filename_for_asset(url: str, part_id: str, fmt: str) -> str:
|
|
107
|
+
"""Safe local filename for an asset: the remote basename when its extension
|
|
108
|
+
matches the format's allow-list, else ``<part_id>.<default ext>``."""
|
|
109
|
+
exts = FORMAT_EXTENSIONS.get(fmt)
|
|
110
|
+
if not exts:
|
|
111
|
+
raise RegistryError(f"unknown format: {fmt}")
|
|
112
|
+
remote = os.path.basename(urllib.parse.urlparse(url).path)
|
|
113
|
+
# basename() strips directories; also reject any residual traversal chars
|
|
114
|
+
if remote and ".." not in remote and remote.lower().endswith(exts):
|
|
115
|
+
return remote
|
|
116
|
+
return f"{part_id}{exts[0]}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Module-level index cache keyed by registry URL. Previously the cache lived on
|
|
120
|
+
# each RegistryClient instance, but the tools build a fresh client per call, so
|
|
121
|
+
# the ~21k-entry index was re-downloaded on every search.
|
|
122
|
+
_INDEX_CACHE: dict[str, list[dict]] = {}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class RegistryClient:
|
|
126
|
+
"""Minimal client for the registry's static JSON API.
|
|
127
|
+
|
|
128
|
+
The full parts index is fetched once and cached at module level (keyed by
|
|
129
|
+
registry URL); the tools construct a fresh client per call.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(self, registry_url: str = DEFAULT_REGISTRY_URL, opener=None):
|
|
133
|
+
self.registry_url = registry_url.rstrip("/")
|
|
134
|
+
self._opener = opener
|
|
135
|
+
|
|
136
|
+
def _get_json(self, url: str):
|
|
137
|
+
try:
|
|
138
|
+
return json.loads(_fetch(url, self._opener).decode("utf-8"))
|
|
139
|
+
except RegistryError:
|
|
140
|
+
raise
|
|
141
|
+
except Exception as exc: # URLError, HTTPError, JSONDecodeError, ...
|
|
142
|
+
raise RegistryError(f"registry request failed: {exc}") from exc
|
|
143
|
+
|
|
144
|
+
def index(self) -> list[dict]:
|
|
145
|
+
"""Full parts index (cached at module level)."""
|
|
146
|
+
if self.registry_url not in _INDEX_CACHE:
|
|
147
|
+
doc = self._get_json(f"{self.registry_url}/parts.json")
|
|
148
|
+
if isinstance(doc, list):
|
|
149
|
+
parts = doc
|
|
150
|
+
elif isinstance(doc, dict):
|
|
151
|
+
raw = doc.get("parts") or []
|
|
152
|
+
parts = raw if isinstance(raw, list) else []
|
|
153
|
+
else:
|
|
154
|
+
parts = [] # null / string / number — don't crash
|
|
155
|
+
_INDEX_CACHE[self.registry_url] = parts
|
|
156
|
+
return _INDEX_CACHE[self.registry_url]
|
|
157
|
+
|
|
158
|
+
def search(self, query: str, limit: int = 20) -> list[dict]:
|
|
159
|
+
"""Case-insensitive AND-match of query tokens against id, name,
|
|
160
|
+
family, category, manufacturer and keywords. ``*`` wildcards work."""
|
|
161
|
+
tokens = [t.lower() for t in query.split() if t.strip()]
|
|
162
|
+
if not tokens:
|
|
163
|
+
return []
|
|
164
|
+
hits = []
|
|
165
|
+
for part in self.index():
|
|
166
|
+
if not isinstance(part, dict):
|
|
167
|
+
continue
|
|
168
|
+
haystack = " ".join(
|
|
169
|
+
str(part.get(k, ""))
|
|
170
|
+
for k in ("id", "name", "family", "category", "manufacturer")
|
|
171
|
+
).lower()
|
|
172
|
+
haystack += " " + " ".join(part.get("keywords") or []).lower()
|
|
173
|
+
if all(
|
|
174
|
+
tok in haystack or fnmatch.fnmatch(haystack, f"*{tok}*")
|
|
175
|
+
for tok in tokens
|
|
176
|
+
):
|
|
177
|
+
hits.append(part)
|
|
178
|
+
if len(hits) >= limit:
|
|
179
|
+
break
|
|
180
|
+
return hits
|
|
181
|
+
|
|
182
|
+
def get_part(self, part_id: str) -> dict:
|
|
183
|
+
"""Full record for one part (includes download URLs in ``files``)."""
|
|
184
|
+
if not part_id or not all(c.isalnum() or c in "_-" for c in part_id):
|
|
185
|
+
raise RegistryError(f"invalid part id: {part_id}")
|
|
186
|
+
return self._get_json(f"{self.registry_url}/parts/{part_id}.json")
|
|
187
|
+
|
|
188
|
+
def download_asset(self, url: str, dest_dir: str, part_id: str, fmt: str) -> str:
|
|
189
|
+
"""Download one asset into ``dest_dir`` (must already be validated by
|
|
190
|
+
the caller). Returns the written file path."""
|
|
191
|
+
if not asset_url_allowed(url, self.registry_url):
|
|
192
|
+
raise RegistryError(f"asset host not allowed: {url}")
|
|
193
|
+
data = _fetch(url, self._opener)
|
|
194
|
+
path = os.path.join(dest_dir, filename_for_asset(url, part_id, fmt))
|
|
195
|
+
with open(path, "wb") as f:
|
|
196
|
+
f.write(data)
|
|
197
|
+
return path
|