@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.
Files changed (192) hide show
  1. package/README.md +116 -0
  2. package/config/mcp/antigravity.json +22 -0
  3. package/config/mcp/claude.json +22 -0
  4. package/config/mcp/codex.toml +25 -0
  5. package/docs/adr/ADR-001-KICAD-OPENSCAD-MCP-STRATEGY.md +334 -0
  6. package/docs/review_documentation.md +121 -0
  7. package/installer.js +358 -0
  8. package/mcp_servers/kicad-mcp-server/.env.example +22 -0
  9. package/mcp_servers/kicad-mcp-server/.github/workflows/ci.yml +36 -0
  10. package/mcp_servers/kicad-mcp-server/CLAUDE.md +487 -0
  11. package/mcp_servers/kicad-mcp-server/README.md +316 -0
  12. package/mcp_servers/kicad-mcp-server/docs/DEVICE_TREE.md +416 -0
  13. package/mcp_servers/kicad-mcp-server/docs/INSTALLATION.md +332 -0
  14. package/mcp_servers/kicad-mcp-server/docs/PIN_ANALYSIS.md +332 -0
  15. package/mcp_servers/kicad-mcp-server/docs/README.md +240 -0
  16. package/mcp_servers/kicad-mcp-server/docs/TESTING.md +613 -0
  17. package/mcp_servers/kicad-mcp-server/docs/VALIDATION.md +268 -0
  18. package/mcp_servers/kicad-mcp-server/pyproject.toml +96 -0
  19. package/mcp_servers/kicad-mcp-server/requirements-dev.txt +16 -0
  20. package/mcp_servers/kicad-mcp-server/requirements-test.txt +24 -0
  21. package/mcp_servers/kicad-mcp-server/requirements.txt +13 -0
  22. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/__init__.py +3 -0
  23. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/__main__.py +17 -0
  24. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/config.py +46 -0
  25. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/models/__init__.py +1 -0
  26. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/models/types.py +87 -0
  27. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/__init__.py +1 -0
  28. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/netlist_parser.py +234 -0
  29. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/pcb_parser.py +375 -0
  30. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/pcb_parser_kicad.py +327 -0
  31. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/schematic_parser.py +902 -0
  32. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/server.py +71 -0
  33. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/__init__.py +1 -0
  34. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/arduino/connectivity_test.cpp.j2 +189 -0
  35. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/atmega.dts.j2 +77 -0
  36. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/esp32.dts.j2 +77 -0
  37. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/nrf52.dts.j2 +77 -0
  38. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/stm32f4.dts.j2 +89 -0
  39. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/esp_idf/test_suite.c.j2 +340 -0
  40. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/pytest/test_connectivity.py.j2 +147 -0
  41. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/st_hal/hal_test.c.j2 +313 -0
  42. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_gpio_test.py.j2 +99 -0
  43. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_i2c_test.py.j2 +117 -0
  44. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_pinmux_test.py.j2 +43 -0
  45. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_spi_test.py.j2 +94 -0
  46. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_gpio_test.c.j2 +113 -0
  47. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_i2c_test.c.j2 +101 -0
  48. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_spi_test.c.j2 +94 -0
  49. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/unittest/test_schematic.py.j2 +172 -0
  50. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/__init__.py +36 -0
  51. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/device_tree.py +1187 -0
  52. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/hierarchical_analysis.py +211 -0
  53. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/netlist.py +320 -0
  54. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/parts_registry.py +142 -0
  55. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pcb.py +955 -0
  56. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pcb_layout.py +308 -0
  57. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pin_analysis.py +765 -0
  58. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/project.py +196 -0
  59. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic.py +319 -0
  60. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic_editor.py +674 -0
  61. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic_search.py +158 -0
  62. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/validation.py +866 -0
  63. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/visualization.py +225 -0
  64. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/__init__.py +1 -0
  65. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/file_handlers.py +65 -0
  66. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/kicad_cli.py +103 -0
  67. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/kicad_version.py +103 -0
  68. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/parts_registry.py +197 -0
  69. package/mcp_servers/kicad-mcp-server/tests/__init__.py +1 -0
  70. package/mcp_servers/kicad-mcp-server/tests/examples/ESP32S3_TEST.md +219 -0
  71. package/mcp_servers/kicad-mcp-server/tests/fixtures/README.md +65 -0
  72. package/mcp_servers/kicad-mcp-server/tests/fixtures/__init__.py +1 -0
  73. package/mcp_servers/kicad-mcp-server/tests/fixtures/example_pcb.kicad_pcb +177 -0
  74. package/mcp_servers/kicad-mcp-server/tests/fixtures/example_schematic.kicad_sch +145 -0
  75. package/mcp_servers/kicad-mcp-server/tests/fixtures/hier/child.kicad_sch +24 -0
  76. package/mcp_servers/kicad-mcp-server/tests/fixtures/hier/root.kicad_sch +38 -0
  77. package/mcp_servers/kicad-mcp-server/tests/test_tools/__init__.py +1 -0
  78. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_hierarchical_labels.py +195 -0
  79. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_kicad_cli.py +116 -0
  80. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_netlist_cache_path.py +29 -0
  81. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_schematic.py +189 -0
  82. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_schematic_hierarchy.py +69 -0
  83. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_visualization.py +136 -0
  84. package/mcp_servers/kicad-mcp-server/uv.lock +2873 -0
  85. package/mcp_servers/openscad-mcp-server/.dockerignore +9 -0
  86. package/mcp_servers/openscad-mcp-server/.github/workflows/test.yml +40 -0
  87. package/mcp_servers/openscad-mcp-server/Dockerfile +29 -0
  88. package/mcp_servers/openscad-mcp-server/LICENSE +21 -0
  89. package/mcp_servers/openscad-mcp-server/README.md +154 -0
  90. package/mcp_servers/openscad-mcp-server/docs/audit.md +56 -0
  91. package/mcp_servers/openscad-mcp-server/docs/docker.md +66 -0
  92. package/mcp_servers/openscad-mcp-server/docs/issue-followup.md +19 -0
  93. package/mcp_servers/openscad-mcp-server/docs/jetson.md +17 -0
  94. package/mcp_servers/openscad-mcp-server/glama.json +4 -0
  95. package/mcp_servers/openscad-mcp-server/legacy/README.md +15 -0
  96. package/mcp_servers/openscad-mcp-server/legacy/README.original.md +294 -0
  97. package/mcp_servers/openscad-mcp-server/legacy/implementation_plan.md +100 -0
  98. package/mcp_servers/openscad-mcp-server/legacy/old/download_sam2_checkpoint.py +115 -0
  99. package/mcp_servers/openscad-mcp-server/legacy/old/src/ai/sam_segmentation.py +209 -0
  100. package/mcp_servers/openscad-mcp-server/legacy/old/src/models/threestudio_generator.py +231 -0
  101. package/mcp_servers/openscad-mcp-server/legacy/old/src/workflow/image_to_model_pipeline.py +260 -0
  102. package/mcp_servers/openscad-mcp-server/legacy/old/test_sam2_segmentation.py +96 -0
  103. package/mcp_servers/openscad-mcp-server/legacy/requirements.txt +57 -0
  104. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/README.md +39 -0
  105. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/decisions/ai-driven-code-generation.md +122 -0
  106. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/decisions/export-formats.md +76 -0
  107. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/ai/ai_service.py.md +51 -0
  108. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/main.py.md +63 -0
  109. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/models/code_generator.py.md +63 -0
  110. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/nlp/parameter_extractor.py.md +63 -0
  111. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/ai/natural-language-processing.md +78 -0
  112. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/nlp/parameter-extraction.md +173 -0
  113. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/export-formats.md +91 -0
  114. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/openscad-basics.md +66 -0
  115. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/primitive-testing.md +79 -0
  116. package/mcp_servers/openscad-mcp-server/legacy/src/__init__.py +0 -0
  117. package/mcp_servers/openscad-mcp-server/legacy/src/ai/ai_service.py +257 -0
  118. package/mcp_servers/openscad-mcp-server/legacy/src/ai/gemini_api.py +161 -0
  119. package/mcp_servers/openscad-mcp-server/legacy/src/ai/venice_api.py +203 -0
  120. package/mcp_servers/openscad-mcp-server/legacy/src/config.py +121 -0
  121. package/mcp_servers/openscad-mcp-server/legacy/src/main.py +1456 -0
  122. package/mcp_servers/openscad-mcp-server/legacy/src/main.py.new +404 -0
  123. package/mcp_servers/openscad-mcp-server/legacy/src/main_remote.py +401 -0
  124. package/mcp_servers/openscad-mcp-server/legacy/src/models/__init__.py +0 -0
  125. package/mcp_servers/openscad-mcp-server/legacy/src/models/code_generator.py +321 -0
  126. package/mcp_servers/openscad-mcp-server/legacy/src/models/cuda_mvs.py +209 -0
  127. package/mcp_servers/openscad-mcp-server/legacy/src/models/scad_templates/basic_shapes.scad +144 -0
  128. package/mcp_servers/openscad-mcp-server/legacy/src/nlp/__init__.py +0 -0
  129. package/mcp_servers/openscad-mcp-server/legacy/src/nlp/parameter_extractor.py +388 -0
  130. package/mcp_servers/openscad-mcp-server/legacy/src/openscad_wrapper/__init__.py +0 -0
  131. package/mcp_servers/openscad-mcp-server/legacy/src/openscad_wrapper/wrapper.py +418 -0
  132. package/mcp_servers/openscad-mcp-server/legacy/src/printer_discovery/__init__.py +1 -0
  133. package/mcp_servers/openscad-mcp-server/legacy/src/printer_discovery/printer_discovery.py +471 -0
  134. package/mcp_servers/openscad-mcp-server/legacy/src/remote/connection_manager.py +537 -0
  135. package/mcp_servers/openscad-mcp-server/legacy/src/remote/cuda_mvs_client.py +435 -0
  136. package/mcp_servers/openscad-mcp-server/legacy/src/remote/cuda_mvs_server.py +787 -0
  137. package/mcp_servers/openscad-mcp-server/legacy/src/remote/error_handling.py +415 -0
  138. package/mcp_servers/openscad-mcp-server/legacy/src/testing/__init__.py +0 -0
  139. package/mcp_servers/openscad-mcp-server/legacy/src/testing/primitive_tester.py +203 -0
  140. package/mcp_servers/openscad-mcp-server/legacy/src/testing/test_primitives.py +98 -0
  141. package/mcp_servers/openscad-mcp-server/legacy/src/utils/__init__.py +1 -0
  142. package/mcp_servers/openscad-mcp-server/legacy/src/utils/cad_exporter.py +241 -0
  143. package/mcp_servers/openscad-mcp-server/legacy/src/utils/format_validator.py +206 -0
  144. package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_exporter.py +140 -0
  145. package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_repair.py +91 -0
  146. package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_validator.py +123 -0
  147. package/mcp_servers/openscad-mcp-server/legacy/src/visualization/__init__.py +0 -0
  148. package/mcp_servers/openscad-mcp-server/legacy/src/visualization/headless_renderer.py +52 -0
  149. package/mcp_servers/openscad-mcp-server/legacy/src/visualization/renderer.py +177 -0
  150. package/mcp_servers/openscad-mcp-server/legacy/src/visualization/web_interface.py +639 -0
  151. package/mcp_servers/openscad-mcp-server/legacy/src/workflow/image_approval.py +148 -0
  152. package/mcp_servers/openscad-mcp-server/legacy/src/workflow/multi_view_to_model_pipeline.py +338 -0
  153. package/mcp_servers/openscad-mcp-server/legacy/test_complete_workflow.py +374 -0
  154. package/mcp_servers/openscad-mcp-server/legacy/test_cuda_mvs.py +191 -0
  155. package/mcp_servers/openscad-mcp-server/legacy/test_gemini_api.py +168 -0
  156. package/mcp_servers/openscad-mcp-server/legacy/test_image_approval.py +192 -0
  157. package/mcp_servers/openscad-mcp-server/legacy/test_image_approval_workflow.py +251 -0
  158. package/mcp_servers/openscad-mcp-server/legacy/test_image_to_model_pipeline.py +145 -0
  159. package/mcp_servers/openscad-mcp-server/legacy/test_model_selection.py +41 -0
  160. package/mcp_servers/openscad-mcp-server/legacy/test_multi_view_pipeline.py +290 -0
  161. package/mcp_servers/openscad-mcp-server/legacy/test_primitives.sh +13 -0
  162. package/mcp_servers/openscad-mcp-server/legacy/test_rabbit_direct.py +71 -0
  163. package/mcp_servers/openscad-mcp-server/legacy/test_remote_cuda_mvs.py +283 -0
  164. package/mcp_servers/openscad-mcp-server/legacy/test_venice_example.py +69 -0
  165. package/mcp_servers/openscad-mcp-server/pyproject.toml +33 -0
  166. package/mcp_servers/openscad-mcp-server/requirements.txt +2 -0
  167. package/mcp_servers/openscad-mcp-server/scad/simple_cube.scad +2 -0
  168. package/mcp_servers/openscad-mcp-server/scripts/test_docker.py +220 -0
  169. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/__init__.py +3 -0
  170. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/__main__.py +3 -0
  171. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/engine.py +94 -0
  172. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/geometry.py +242 -0
  173. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/server.py +245 -0
  174. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/service.py +190 -0
  175. package/mcp_servers/openscad-mcp-server/tests/conftest.py +17 -0
  176. package/mcp_servers/openscad-mcp-server/tests/test_engine.py +37 -0
  177. package/mcp_servers/openscad-mcp-server/tests/test_geometry.py +106 -0
  178. package/mcp_servers/openscad-mcp-server/tests/test_integration.py +172 -0
  179. package/mcp_servers/openscad-mcp-server/tests/test_transports.py +314 -0
  180. package/mcp_servers/openscad-mcp-server/uv.lock +1123 -0
  181. package/package.json +44 -0
  182. package/scripts/install_mcps.sh +86 -0
  183. package/scripts/openscad_wrapper.sh +62 -0
  184. package/scripts/run_kicad_mcp.sh +25 -0
  185. package/scripts/run_openscad_mcp.sh +41 -0
  186. package/scripts/test_mcp_connection.py +626 -0
  187. package/scripts/test_mcp_connection.sh +168 -0
  188. package/skills/code-first-hardware-design/SKILL.md +260 -0
  189. package/skills/pcb-constraint-definition/SKILL.md +236 -0
  190. package/skills/pcb-layout-routing-automation/SKILL.md +153 -0
  191. package/skills/pcb-validation-dfm-signoff/SKILL.md +194 -0
  192. package/skills/schematic-datasheet-analysis/SKILL.md +202 -0
@@ -0,0 +1,234 @@
1
+ """KiCad netlist parser for accurate component network tracking."""
2
+
3
+ import xml.etree.ElementTree as ET
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class NetlistComponent:
11
+ """Component from netlist file."""
12
+
13
+ reference: str
14
+ value: str
15
+ library: str
16
+ footprint: str | None = None
17
+ pins: dict[str, str] = field(default_factory=dict) # pin_number -> net_name
18
+
19
+
20
+ @dataclass
21
+ class NetlistNet:
22
+ """Net from netlist file."""
23
+
24
+ name: str
25
+ code: int
26
+ pins: list[tuple[str, str]] = field(default_factory=list) # (reference, pin_number)
27
+
28
+
29
+ class NetlistParser:
30
+ """Parser for KiCad netlist files (.xml)."""
31
+
32
+ def __init__(self, file_path: str) -> None:
33
+ """Initialize parser with netlist file.
34
+
35
+ Args:
36
+ file_path: Path to .xml netlist file
37
+
38
+ Raises:
39
+ FileNotFoundError: If file doesn't exist
40
+ ValueError: If file is not a .xml file
41
+ """
42
+ self.file_path = Path(file_path)
43
+ if not self.file_path.exists():
44
+ raise FileNotFoundError(f"Netlist file not found: {file_path}")
45
+
46
+ if self.file_path.suffix != ".xml":
47
+ raise ValueError(f"Netlist file must be .xml, got: {self.file_path.suffix}")
48
+
49
+ self._data: dict[str, Any] | None = None
50
+
51
+ def _parse_file(self) -> dict[str, Any]:
52
+ """Parse the netlist XML file.
53
+
54
+ Returns:
55
+ Parsed data structure
56
+ """
57
+ if self._data is not None:
58
+ return self._data
59
+
60
+ tree = ET.parse(self.file_path)
61
+ root = tree.getroot()
62
+
63
+ # Parse components (KiCad 9.0+ uses <comp> not <component>)
64
+ components = {}
65
+ for comp in root.findall(".//comp"):
66
+ ref = comp.get("ref")
67
+ value = comp.findtext("value", "")
68
+ # KiCad XML: footprint is a direct <footprint>Lib:Footprint</footprint>
69
+ # element (NOT footprint/libpart), and libsource is an attribute-only
70
+ # element <libsource lib="..." part="..."/> (NOT libsource/libpart).
71
+ footprint = comp.findtext("footprint") or ""
72
+ libsource = comp.find("libsource")
73
+ library = libsource.get("lib", "") if libsource is not None else ""
74
+
75
+ # Pins will be populated from nets later
76
+ components[ref] = NetlistComponent(
77
+ reference=ref,
78
+ value=value,
79
+ library=library,
80
+ footprint=footprint,
81
+ pins={}, # Empty initially, will populate from nets
82
+ )
83
+
84
+ # Parse nets and populate component pins
85
+ nets = {}
86
+ for net in root.findall(".//net"):
87
+ code_attr = net.get("code")
88
+ code = int(code_attr) if code_attr and code_attr.lstrip("-").isdigit() else -1
89
+ name = net.get("name")
90
+
91
+ pins_list = []
92
+ for node in net.findall("node"):
93
+ ref = node.get("ref")
94
+ pin_num = node.get("pin")
95
+ if ref and pin_num:
96
+ pins_list.append((ref, pin_num))
97
+
98
+ # Populate component pins
99
+ if ref in components:
100
+ components[ref].pins[pin_num] = name
101
+
102
+ nets[name] = NetlistNet(name=name, code=code, pins=pins_list)
103
+
104
+ self._data = {
105
+ "components": components,
106
+ "nets": nets,
107
+ }
108
+
109
+ return self._data
110
+
111
+ def _get_net_name(self, root: ET.Element, net_code: str) -> str:
112
+ """Get net name from net code.
113
+
114
+ Args:
115
+ root: XML root element
116
+ net_code: Net code string (e.g., "3")
117
+
118
+ Returns:
119
+ Net name
120
+ """
121
+ net_elem = root.find(f".//net[@code='{net_code}']")
122
+ if net_elem is not None:
123
+ return net_elem.get("name", f"net_{net_code}")
124
+ return f"net_{net_code}"
125
+
126
+ def get_components(self) -> dict[str, NetlistComponent]:
127
+ """Get all components from netlist.
128
+
129
+ Returns:
130
+ Dictionary mapping reference to component
131
+ """
132
+ data = self._parse_file()
133
+ return data["components"]
134
+
135
+ def get_nets(self) -> dict[str, NetlistNet]:
136
+ """Get all nets from netlist.
137
+
138
+ Returns:
139
+ Dictionary mapping net name to net
140
+ """
141
+ data = self._parse_file()
142
+ return data["nets"]
143
+
144
+ def get_component_nets(self, reference: str) -> dict[str, list[str]]:
145
+ """Get all networks for a component.
146
+
147
+ Args:
148
+ reference: Component reference (e.g., "R16")
149
+
150
+ Returns:
151
+ Dictionary mapping net name to list of connected pins
152
+ """
153
+ components = self.get_components()
154
+ if reference not in components:
155
+ return {}
156
+
157
+ comp = components[reference]
158
+ net_pins = {}
159
+
160
+ for pin_num, net_name in comp.pins.items():
161
+ if net_name not in net_pins:
162
+ net_pins[net_name] = []
163
+ net_pins[net_name].append(pin_num)
164
+
165
+ return net_pins
166
+
167
+ def get_net_components(self, net_name: str) -> list[tuple[str, str]]:
168
+ """Get all components connected to a net.
169
+
170
+ Args:
171
+ net_name: Net name (e.g., "PMIC_I2C_SCL")
172
+
173
+ Returns:
174
+ List of (reference, pin_number) tuples
175
+ """
176
+ nets = self.get_nets()
177
+ if net_name not in nets:
178
+ return []
179
+
180
+ return nets[net_name].pins
181
+
182
+ def trace_connection(self, reference: str, pin_number: str | None = None) -> dict[str, Any]:
183
+ """Trace connections from a component pin.
184
+
185
+ Args:
186
+ reference: Component reference
187
+ pin_number: Optional pin number (if None, trace all pins)
188
+
189
+ Returns:
190
+ Connection information
191
+ """
192
+ components = self.get_components()
193
+ if reference not in components:
194
+ return {"error": f"Component {reference} not found in netlist"}
195
+
196
+ comp = components[reference]
197
+
198
+ if pin_number:
199
+ # Trace specific pin
200
+ if pin_number not in comp.pins:
201
+ return {"error": f"Pin {pin_number} not found in component {reference}"}
202
+
203
+ net_name = comp.pins[pin_number]
204
+ nets = self.get_nets()
205
+ connected = nets[net_name].pins if net_name in nets else []
206
+
207
+ return {
208
+ "component": reference,
209
+ "pin": pin_number,
210
+ "net": net_name,
211
+ "connected_to": [(ref, pin) for ref, pin in connected if ref != reference],
212
+ }
213
+
214
+ else:
215
+ # Trace all pins. A component can have several pins on the same net
216
+ # (relay contacts, multiple GND pins), so group as a list per net
217
+ # instead of overwriting earlier pins.
218
+ net_connections: dict[str, list[dict[str, Any]]] = {}
219
+ for pin_num, net_name in comp.pins.items():
220
+ nets = self.get_nets()
221
+ connected = nets[net_name].pins if net_name in nets else []
222
+ net_connections.setdefault(net_name, []).append(
223
+ {
224
+ "pin": pin_num,
225
+ "connected_to": [
226
+ (ref, pin) for ref, pin in connected if ref != reference
227
+ ],
228
+ }
229
+ )
230
+
231
+ return {
232
+ "component": reference,
233
+ "nets": net_connections,
234
+ }
@@ -0,0 +1,375 @@
1
+ """PCB file parser wrapper using kicad-skip."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ..utils.file_handlers import validate_kicad_file
9
+
10
+
11
+ @dataclass
12
+ class PCBFootprint:
13
+ """Footprint from PCB file."""
14
+
15
+ reference: str
16
+ footprint_id: str
17
+ value: str
18
+ layer: str = "F.Cu"
19
+ position: tuple[float, float] = (0.0, 0.0)
20
+ rotation: float = 0.0
21
+ pad_count: int = 0
22
+
23
+ @classmethod
24
+ def from_dict(cls, data: dict[str, Any]) -> "PCBFootprint":
25
+ """Create from parsed data structure."""
26
+ at = data.get("at", {})
27
+ position = (float(at.get("x", 0)), float(at.get("y", 0)))
28
+ rotation = float(at.get("rotation", 0))
29
+
30
+ return cls(
31
+ reference=data.get("reference", ""),
32
+ footprint_id=data.get("footprint_id", ""),
33
+ value=data.get("value", ""),
34
+ layer=data.get("layer", "F.Cu"),
35
+ position=position,
36
+ rotation=rotation,
37
+ pad_count=len(data.get("pads", [])),
38
+ )
39
+
40
+
41
+ def _read_file_with_encoding_fallback(file_path: Path) -> str:
42
+ """Read file with multiple encoding fallback support.
43
+
44
+ Args:
45
+ file_path: Path to the file to read
46
+
47
+ Returns:
48
+ File content as string
49
+
50
+ Note:
51
+ Tries multiple encodings to handle different KiCad file formats
52
+ and potential encoding issues.
53
+ """
54
+ encodings = ['utf-8', 'latin-1', 'cp1252', 'iso-8859-1']
55
+ content = None
56
+
57
+ for encoding in encodings:
58
+ try:
59
+ content = file_path.read_text(encoding=encoding)
60
+ break
61
+ except UnicodeDecodeError:
62
+ continue
63
+
64
+ if content is None:
65
+ # Last resort: read with error handling
66
+ content = file_path.read_text(encoding='utf-8', errors='ignore')
67
+
68
+ return content
69
+
70
+
71
+ class PCBParser:
72
+ """Parser for KiCad PCB files (.kicad_pcb)."""
73
+
74
+ def __init__(self, file_path: str) -> None:
75
+ """Initialize parser with PCB file.
76
+
77
+ Args:
78
+ file_path: Path to .kicad_pcb file
79
+
80
+ Raises:
81
+ FileNotFoundError: If file doesn't exist
82
+ ValueError: If file is not a .kicad_pcb file
83
+ """
84
+ self.file_path = validate_kicad_file(file_path, ".kicad_pcb")
85
+ self._data: dict[str, Any] | None = None
86
+
87
+ def _parse_file(self) -> dict[str, Any]:
88
+ """Parse the PCB file.
89
+
90
+ Returns:
91
+ Parsed data structure
92
+ """
93
+ if self._data is not None:
94
+ return self._data
95
+
96
+ content = _read_file_with_encoding_fallback(self.file_path)
97
+
98
+ # Extract basic information
99
+ self._data = {
100
+ "path": str(self.file_path),
101
+ "general": self._parse_general(content),
102
+ "footprints": self._parse_footprints(content),
103
+ "tracks": self._parse_tracks(content),
104
+ "vias": self._parse_vias(content),
105
+ "zones": self._parse_zones(content),
106
+ "setup": self._parse_setup(content),
107
+ }
108
+
109
+ return self._data
110
+
111
+ def _parse_general(self, content: str) -> dict[str, Any]:
112
+ """Parse general section."""
113
+ general = {"thickness": 1.6, "layers": 2}
114
+
115
+ # Extract thickness
116
+ thickness_match = re.search(r'thickness\s+([\d.]+)', content)
117
+ if thickness_match:
118
+ general["thickness"] = float(thickness_match.group(1))
119
+
120
+ # Count layers
121
+ layer_pattern = r'\(layer\s+"([^"]+)"'
122
+ layers = re.findall(layer_pattern, content)
123
+ # Filter for copper layers. A startswith("F."/"B.") check is wrong:
124
+ # F.SilkS / F.Paste / F.Mask / F.Fab / F.CrtYd / F.Adhes all start with
125
+ # "F." but are NOT copper. Only F.Cu, B.Cu and In<N>.Cu are copper.
126
+ copper_layers = [
127
+ layer for layer in set(layers)
128
+ if layer in ("F.Cu", "B.Cu") or re.match(r"In\d+\.Cu$", layer)
129
+ ]
130
+ general["layers"] = len(copper_layers) or 2
131
+
132
+ return general
133
+
134
+ def _parse_footprints(self, content: str) -> list[dict[str, Any]]:
135
+ """Parse footprints from PCB.
136
+
137
+ Uses multi-line regex to match KiCad footprint instances.
138
+ """
139
+ footprints = []
140
+
141
+ # Simpler pattern: Match (footprint "..." followed by (at ... sometime later)
142
+ # We'll find the footprint line, then search forward for the (at ...) line
143
+ footprint_lines_pattern = r'\(footprint\s+"([^"]+)"'
144
+
145
+ for match in re.finditer(footprint_lines_pattern, content):
146
+ footprint_id = match.group(1)
147
+ start_pos = match.start()
148
+
149
+ # Search forward for the (at ...) line, within a reasonable distance
150
+ search_area = content[start_pos:start_pos + 500]
151
+ at_match = re.search(r'\(at\s+([\d.]+)\s+([\d.]+)(?:\s+([\d.\-]+))?\)', search_area)
152
+
153
+ if at_match:
154
+ x = float(at_match.group(1))
155
+ y = float(at_match.group(2))
156
+ rotation = float(at_match.group(3)) if at_match.group(3) else 0.0
157
+
158
+ # Extract a larger block for reference and value
159
+ # Find the matching closing parenthesis for the footprint
160
+ depth = 0
161
+ found_open = False
162
+ end_pos = start_pos
163
+ block_start = start_pos
164
+
165
+ # Find where the (footprint block starts (first '(' after the keyword)
166
+ while end_pos < len(content) and end_pos < start_pos + 20:
167
+ if content[end_pos] == '(':
168
+ found_open = True
169
+ block_start = end_pos
170
+ break
171
+ end_pos += 1
172
+
173
+ if found_open:
174
+ # Now find the matching closing parenthesis
175
+ depth = 1
176
+ end_pos = block_start + 1
177
+ while end_pos < len(content):
178
+ if content[end_pos] == '(':
179
+ depth += 1
180
+ elif content[end_pos] == ')':
181
+ depth -= 1
182
+ if depth == 0:
183
+ break
184
+ end_pos += 1
185
+
186
+ footprint_block = content[block_start:end_pos]
187
+
188
+ # Extract reference: KiCad 9+ uses (property "Reference" "..."),
189
+ # KiCad 4/5 used (fp_text reference "..."). Support both.
190
+ ref_match = (re.search(r'\(property\s+"Reference"\s+"([^"]+)"', footprint_block)
191
+ or re.search(r'\(fp_text\s+reference\s+"([^"]+)"', footprint_block))
192
+ reference = ref_match.group(1) if ref_match else ""
193
+
194
+ # Extract value (same version split)
195
+ val_match = (re.search(r'\(property\s+"Value"\s+"([^"]+)"', footprint_block)
196
+ or re.search(r'\(fp_text\s+value\s+"([^"]+)"', footprint_block))
197
+ value = val_match.group(1) if val_match else ""
198
+
199
+ # Extract layer (default to F.Cu if not found)
200
+ layer_match = re.search(r'\(layer\s+"([^"]+)"', footprint_block)
201
+ layer = layer_match.group(1) if layer_match else "F.Cu"
202
+
203
+ # Count pads
204
+ pad_count = len(re.findall(r'\(pad\s+', footprint_block))
205
+
206
+ footprints.append({
207
+ "footprint_id": footprint_id,
208
+ "reference": reference,
209
+ "value": value,
210
+ "layer": layer,
211
+ "at": {"x": x, "y": y, "rotation": rotation},
212
+ "pads": [],
213
+ "pad_count": pad_count,
214
+ })
215
+
216
+ return footprints
217
+
218
+ def _parse_tracks(self, content: str) -> list[dict[str, Any]]:
219
+ """Parse track segments."""
220
+ tracks = []
221
+
222
+ # KiCad 10 format: (segment (start x y) (end x y) (width w) (layer "...") (net "name") (uuid "..."))
223
+ # The net field uses quoted string: (net "netname")
224
+ segment_pattern = (
225
+ r'\(segment\s+'
226
+ r'\(start\s+([\d.]+)\s+([\d.]+)\)\s+'
227
+ r'\(end\s+([\d.]+)\s+([\d.]+)\)\s+'
228
+ r'\(width\s+([\d.]+)\)\s+'
229
+ r'\(layer\s+"([^"]+)"\)\s+'
230
+ r'\(net\s+"([^"]*)"\)'
231
+ )
232
+
233
+ for match in re.finditer(segment_pattern, content):
234
+ x1, y1 = float(match.group(1)), float(match.group(2))
235
+ x2, y2 = float(match.group(3)), float(match.group(4))
236
+ dx, dy = x2 - x1, y2 - y1
237
+ tracks.append({
238
+ "start": {"x": x1, "y": y1},
239
+ "end": {"x": x2, "y": y2},
240
+ "width": float(match.group(5)),
241
+ "layer": match.group(6),
242
+ "net": match.group(7),
243
+ "length": (dx * dx + dy * dy) ** 0.5,
244
+ })
245
+
246
+ return tracks
247
+
248
+ def _parse_vias(self, content: str) -> list[dict[str, Any]]:
249
+ """Parse vias."""
250
+ vias = []
251
+
252
+ # KiCad 10 format: (via (at x y) (size s) (drill d) (layers "L1" "L2") (free yes) (net "name") (uuid "..."))
253
+ via_pattern = (
254
+ r'\(via\s+'
255
+ r'\(at\s+([\d.]+)\s+([\d.]+)\)\s+'
256
+ r'\(size\s+([\d.]+)\)\s+'
257
+ r'\(drill\s+([\d.]+)\)\s+'
258
+ r'\(layers\s+"([^"]+)"\s+"([^"]+)"\)\s+'
259
+ r'(?:\(free\s+yes\)\s+)?'
260
+ r'(?:\(net\s+"([^"]*)"\)\s+)?'
261
+ )
262
+
263
+ for match in re.finditer(via_pattern, content):
264
+ vias.append({
265
+ "at": {"x": float(match.group(1)), "y": float(match.group(2))},
266
+ "size": float(match.group(3)),
267
+ "drill": float(match.group(4)),
268
+ "top_layer": match.group(5),
269
+ "bottom_layer": match.group(6),
270
+ "net": match.group(7) or "",
271
+ })
272
+
273
+ # Fallback: simpler pattern for single-layer via or older format
274
+ if not vias:
275
+ via_pattern_simple = r'\(via\s+\(at\s+([\d.]+)\s+([\d.]+)\)\s+\(size\s+([\d.]+)\)\s+\(drill\s+([\d.]+)'
276
+ for match in re.finditer(via_pattern_simple, content):
277
+ vias.append({
278
+ "at": {"x": float(match.group(1)), "y": float(match.group(2))},
279
+ "size": float(match.group(3)),
280
+ "drill": float(match.group(4)),
281
+ })
282
+
283
+ return vias
284
+
285
+ def _parse_zones(self, content: str) -> list[dict[str, Any]]:
286
+ """Parse copper zones."""
287
+ zones = []
288
+
289
+ # KiCad 10 format: (zone (net "name") (layer "...") ...)
290
+ zone_pattern = r'\(zone\s+\(net\s+"([^"]+)"\)\s+\(layer\s+"([^"]+)"\)'
291
+
292
+ for match in re.finditer(zone_pattern, content):
293
+ zones.append({
294
+ "net_name": match.group(1),
295
+ "layer": match.group(2),
296
+ })
297
+
298
+ # Fallback: KiCad 9 format (net code + net_name)
299
+ if not zones:
300
+ zone_pattern_v9 = r'\(zone\s+\(net\s+(\d+)\)\s*\(net_name\s+"([^"]+)"'
301
+ for match in re.finditer(zone_pattern_v9, content):
302
+ zones.append({
303
+ "net_name": match.group(2),
304
+ "layer": "",
305
+ })
306
+
307
+ return zones
308
+
309
+ def _parse_setup(self, content: str) -> dict[str, Any]:
310
+ """Parse setup section for design rules."""
311
+ setup = {
312
+ "trace_min": 0.2,
313
+ "via_min": 0.4,
314
+ "clearance": 0.15,
315
+ }
316
+
317
+ # Extract design rules
318
+ setup_match = re.search(
319
+ r'\(setup.*?\(min_resolution\s+([\d.]+)\)', content, re.DOTALL
320
+ )
321
+ if setup_match:
322
+ # Try to extract trace and via sizes
323
+ trace_match = re.search(
324
+ r'\(trace_min\s+([\d.]+)', content[: content.find(setup_match.group(0)) + 500]
325
+ )
326
+ if trace_match:
327
+ setup["trace_min"] = float(trace_match.group(1))
328
+
329
+ return setup
330
+
331
+ def get_footprints(self) -> list[PCBFootprint]:
332
+ """Get all footprints from PCB.
333
+
334
+ Returns:
335
+ List of footprints
336
+ """
337
+ data = self._parse_file()
338
+ return [PCBFootprint.from_dict(f) for f in data["footprints"]]
339
+
340
+ def get_statistics(self) -> dict[str, Any]:
341
+ """Get PCB statistics.
342
+
343
+ Returns:
344
+ Dictionary with statistics
345
+ """
346
+ data = self._parse_file()
347
+
348
+ # Calculate board dimensions from footprint positions
349
+ footprints = self.get_footprints()
350
+ if footprints:
351
+ x_coords = [f.position[0] for f in footprints]
352
+ y_coords = [f.position[1] for f in footprints]
353
+ min_x, max_x = min(x_coords), max(x_coords)
354
+ min_y, max_y = min(y_coords), max(y_coords)
355
+ # Add margin for board edge
356
+ margin = 5.0
357
+ width = max_x - min_x + (2 * margin)
358
+ height = max_y - min_y + (2 * margin)
359
+ else:
360
+ width = height = 0.0
361
+
362
+ # Calculate total pads from pad_count field
363
+ total_pads = sum(f.get("pad_count", 0) for f in data["footprints"])
364
+
365
+ return {
366
+ "total_footprints": len(data["footprints"]),
367
+ "total_pads": total_pads,
368
+ "total_tracks": len(data["tracks"]),
369
+ "total_vias": len(data["vias"]),
370
+ "total_zones": len(data["zones"]),
371
+ "board_width": width,
372
+ "board_height": height,
373
+ "layers": data["general"]["layers"],
374
+ "thickness": data["general"]["thickness"],
375
+ }