hw-codesign 0.1.3__py3-none-any.whl

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 (156) hide show
  1. hw_codesign/__init__.py +3 -0
  2. hw_codesign/artifacts.py +117 -0
  3. hw_codesign/backends/__init__.py +1 -0
  4. hw_codesign/backends/atopile.py +206 -0
  5. hw_codesign/backends/command.py +80 -0
  6. hw_codesign/backends/electronics.py +60 -0
  7. hw_codesign/backends/firmware_modules/__init__.py +161 -0
  8. hw_codesign/backends/firmware_modules/_base.py +15 -0
  9. hw_codesign/backends/firmware_modules/interface_stack.py +119 -0
  10. hw_codesign/backends/firmware_modules/periodic_transmit.py +127 -0
  11. hw_codesign/backends/firmware_modules/sensor_poll.py +149 -0
  12. hw_codesign/backends/firmware_modules/state_machine.py +142 -0
  13. hw_codesign/backends/firmware_modules/timeout_shutdown.py +106 -0
  14. hw_codesign/backends/freerouting.py +615 -0
  15. hw_codesign/backends/kicad.py +1214 -0
  16. hw_codesign/backends/mechanical.py +371 -0
  17. hw_codesign/backends/parts/__init__.py +88 -0
  18. hw_codesign/backends/parts/_base.py +212 -0
  19. hw_codesign/backends/parts/cable_clip.py +150 -0
  20. hw_codesign/backends/parts/custom_enclosure_variant.py +231 -0
  21. hw_codesign/backends/parts/din_rail_adapter.py +163 -0
  22. hw_codesign/backends/parts/pcb_mount_bracket.py +186 -0
  23. hw_codesign/backends/parts/robot_mechanisms.py +226 -0
  24. hw_codesign/backends/parts/standoff_tower.py +135 -0
  25. hw_codesign/backends/python_netlist.py +102 -0
  26. hw_codesign/backends/tscircuit.py +882 -0
  27. hw_codesign/backends/zephyr.py +313 -0
  28. hw_codesign/board_layout.py +871 -0
  29. hw_codesign/cli.py +334 -0
  30. hw_codesign/contracts/__init__.py +57 -0
  31. hw_codesign/contracts/_manifest.py +46 -0
  32. hw_codesign/contracts/_messages.py +125 -0
  33. hw_codesign/contracts/_schemas.py +1416 -0
  34. hw_codesign/contracts/_tools.py +1418 -0
  35. hw_codesign/contracts/_versions.py +32 -0
  36. hw_codesign/diagnostics.py +126 -0
  37. hw_codesign/electronics_design.py +1911 -0
  38. hw_codesign/errors.py +18 -0
  39. hw_codesign/footprint_library.py +325 -0
  40. hw_codesign/footprints/ATTRIBUTION.md +37 -0
  41. hw_codesign/footprints/C_0402_1005Metric.kicad_mod +112 -0
  42. hw_codesign/footprints/C_0603_1608Metric.kicad_mod +112 -0
  43. hw_codesign/footprints/Crystal_SMD_3225-4Pin_3.2x2.5mm.kicad_mod +128 -0
  44. hw_codesign/footprints/ESP32-S3-WROOM-1.kicad_mod +775 -0
  45. hw_codesign/footprints/IDC-Header_2x05_P2.54mm_Vertical.kicad_mod +377 -0
  46. hw_codesign/footprints/LICENSE.md +27 -0
  47. hw_codesign/footprints/QFN-56-1EP_7x7mm_P0.4mm_EP3.2x3.2mm.kicad_mod +772 -0
  48. hw_codesign/footprints/R_0603_1608Metric.kicad_mod +112 -0
  49. hw_codesign/footprints/SOIC-8_5.3x5.3mm_P1.27mm.kicad_mod +300 -0
  50. hw_codesign/footprints/SOT-23-5.kicad_mod +327 -0
  51. hw_codesign/footprints/SOT-23-6.kicad_mod +288 -0
  52. hw_codesign/footprints/USB_C_Receptacle_GCT_USB4105-xx-A_16P_TopMnt_Horizontal.kicad_mod +310 -0
  53. hw_codesign/generators.py +669 -0
  54. hw_codesign/io.py +38 -0
  55. hw_codesign/job_worker.py +156 -0
  56. hw_codesign/jobs.py +405 -0
  57. hw_codesign/mcp_server.py +651 -0
  58. hw_codesign/mechanical_contract.py +68 -0
  59. hw_codesign/models.py +141 -0
  60. hw_codesign/pcb_router.py +245 -0
  61. hw_codesign/placement.py +2345 -0
  62. hw_codesign/policy.py +19 -0
  63. hw_codesign/power_sim.py +207 -0
  64. hw_codesign/processes.py +49 -0
  65. hw_codesign/project_parts.py +69 -0
  66. hw_codesign/provenance.py +52 -0
  67. hw_codesign/reference_backend.py +1422 -0
  68. hw_codesign/resolver.py +357 -0
  69. hw_codesign/resources.py +20 -0
  70. hw_codesign/review_3d.py +229 -0
  71. hw_codesign/review_3d_viewer.bundle.js +4000 -0
  72. hw_codesign/review_3d_viewer.bundle.json +61 -0
  73. hw_codesign/review_3d_viewer.js +80 -0
  74. hw_codesign/review_report.py +1034 -0
  75. hw_codesign/review_viewer.py +914 -0
  76. hw_codesign/schematic_generator.py +93 -0
  77. hw_codesign/semantic_schematic.py +159 -0
  78. hw_codesign/service.py +8000 -0
  79. hw_codesign/sourcing_policy.py +27 -0
  80. hw_codesign/stdio.py +40 -0
  81. hw_codesign/supplier_adapters.py +104 -0
  82. hw_codesign/templates/__init__.py +1 -0
  83. hw_codesign/templates/avr_32u4_hid.yaml +199 -0
  84. hw_codesign/templates/bldc_esc.yaml +141 -0
  85. hw_codesign/templates/ble_sensor_node.yaml +105 -0
  86. hw_codesign/templates/esp32_wifi_gateway.yaml +192 -0
  87. hw_codesign/templates/lora_sensor_node.yaml +123 -0
  88. hw_codesign/templates/mini_servo_robot.parts.local.yaml +165 -0
  89. hw_codesign/templates/mini_servo_robot.yaml +247 -0
  90. hw_codesign/templates/nrf52840_dongle.yaml +197 -0
  91. hw_codesign/templates/robotics_controller_full.yaml +143 -0
  92. hw_codesign/templates/rp2040_usb_device.yaml +238 -0
  93. hw_codesign/templates/samd21_sensor_hub.yaml +233 -0
  94. hw_codesign/templates/sensor_data_logger.yaml +103 -0
  95. hw_codesign/templates/stm32g0_power_monitor.yaml +209 -0
  96. hw_codesign/templates/usb_hid_controller.yaml +139 -0
  97. hw_codesign/third_party/three/ATTRIBUTION.md +14 -0
  98. hw_codesign/third_party/three/LICENSE +21 -0
  99. hw_codesign/validation.py +3380 -0
  100. hw_codesign/workspace.py +126 -0
  101. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/components/avr_32u4_hid.yaml +76 -0
  102. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/components/bldc_esc.yaml +249 -0
  103. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/components/ble_sensor_node.yaml +294 -0
  104. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/components/common_passives.yaml +1208 -0
  105. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/components/esp32_wifi_gateway.yaml +120 -0
  106. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/components/interface_ics.yaml +196 -0
  107. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/components/lora_sensor_node.yaml +172 -0
  108. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/components/memory.yaml +65 -0
  109. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/components/motion_sensors.yaml +118 -0
  110. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/components/power_management.yaml +95 -0
  111. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/components/robotics_controller.yaml +24 -0
  112. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/components/samd21_sensor_hub.yaml +61 -0
  113. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/components/sensor_data_logger.yaml +64 -0
  114. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/components/stm32g0_power_monitor.yaml +152 -0
  115. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/components/usb_hid_controller.yaml +170 -0
  116. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/evidence/datasheets.yaml +160 -0
  117. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/role_sets/avr_32u4_hid.yaml +31 -0
  118. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/role_sets/bldc_esc.yaml +44 -0
  119. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/role_sets/ble_sensor_node.yaml +50 -0
  120. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/role_sets/esp32_wifi_gateway.yaml +31 -0
  121. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/role_sets/lora_sensor_node.yaml +33 -0
  122. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/role_sets/nrf52840_dongle.yaml +29 -0
  123. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/role_sets/robotics_controller.yaml +53 -0
  124. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/role_sets/rp2040_usb_device.yaml +55 -0
  125. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/role_sets/samd21_sensor_hub.yaml +42 -0
  126. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/role_sets/sensor_data_logger.yaml +35 -0
  127. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/role_sets/stm32g0_power_monitor.yaml +41 -0
  128. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/role_sets/usb_hid_controller.yaml +37 -0
  129. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/schemas/component.schema.json +59 -0
  130. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/schemas/evidence_catalog.schema.json +28 -0
  131. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/schemas/role_set.schema.json +47 -0
  132. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/schemas/supplier_catalog.schema.json +29 -0
  133. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/suppliers/curated.yaml +162 -0
  134. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/suppliers/digikey.yaml +2 -0
  135. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/suppliers/lcsc_jlcpcb.yaml +2 -0
  136. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/suppliers/mouser.yaml +2 -0
  137. hw_codesign-0.1.3.data/data/share/hw-codesign/parts/suppliers/octopart.yaml +2 -0
  138. hw_codesign-0.1.3.data/data/share/hw-codesign/schemas/electrical.schema.json +1 -0
  139. hw_codesign-0.1.3.data/data/share/hw-codesign/schemas/error.schema.json +50 -0
  140. hw_codesign-0.1.3.data/data/share/hw-codesign/schemas/firmware.schema.json +1 -0
  141. hw_codesign-0.1.3.data/data/share/hw-codesign/schemas/gate_event.schema.json +44 -0
  142. hw_codesign-0.1.3.data/data/share/hw-codesign/schemas/job_status.schema.json +78 -0
  143. hw_codesign-0.1.3.data/data/share/hw-codesign/schemas/job_submit.schema.json +46 -0
  144. hw_codesign-0.1.3.data/data/share/hw-codesign/schemas/mechanical.schema.json +18 -0
  145. hw_codesign-0.1.3.data/data/share/hw-codesign/schemas/project_parts.schema.json +133 -0
  146. hw_codesign-0.1.3.data/data/share/hw-codesign/schemas/release.schema.json +1 -0
  147. hw_codesign-0.1.3.data/data/share/hw-codesign/schemas/requirements.schema.json +109 -0
  148. hw_codesign-0.1.3.data/data/share/hw-codesign/schemas/review_bundle.schema.json +287 -0
  149. hw_codesign-0.1.3.data/data/share/hw-codesign/schemas/system.schema.json +160 -0
  150. hw_codesign-0.1.3.dist-info/METADATA +99 -0
  151. hw_codesign-0.1.3.dist-info/RECORD +156 -0
  152. hw_codesign-0.1.3.dist-info/WHEEL +5 -0
  153. hw_codesign-0.1.3.dist-info/entry_points.txt +3 -0
  154. hw_codesign-0.1.3.dist-info/licenses/LICENSE +201 -0
  155. hw_codesign-0.1.3.dist-info/licenses/NOTICE +44 -0
  156. hw_codesign-0.1.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """Agentic hardware design system with evidence-backed release gates."""
2
+
3
+ __version__ = "0.1.3"
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import math
5
+ import zipfile
6
+ from pathlib import Path
7
+ from typing import Any, Iterable
8
+
9
+ from .io import atomic_write_text, write_json
10
+
11
+
12
+ def sha256(path: Path) -> str:
13
+ digest = hashlib.sha256()
14
+ with path.open("rb") as handle:
15
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
16
+ digest.update(chunk)
17
+ return digest.hexdigest()
18
+
19
+
20
+ def deterministic_zip(output: Path, files: Iterable[tuple[Path, str]]) -> None:
21
+ output.parent.mkdir(parents=True, exist_ok=True)
22
+ with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
23
+ for source, archive_name in sorted(files, key=lambda item: item[1]):
24
+ info = zipfile.ZipInfo(archive_name, date_time=(1980, 1, 1, 0, 0, 0))
25
+ info.compress_type = zipfile.ZIP_DEFLATED
26
+ info.external_attr = 0o100644 << 16
27
+ archive.writestr(info, source.read_bytes())
28
+
29
+
30
+ def write_manifest(root: Path, output: Path, provenance: dict[str, Any] | None = None, candidate_only: bool = False) -> str:
31
+ entries = []
32
+ for path in sorted(root.rglob("*")):
33
+ if path.is_file() and path != output:
34
+ entries.append({"path": path.relative_to(root).as_posix(), "bytes": path.stat().st_size, "sha256": sha256(path), "provenance": provenance or {}})
35
+ write_json(output, {"algorithm": "sha256", "candidate_only": candidate_only, "release_eligible": not candidate_only, "provenance": provenance or {}, "artifacts": entries})
36
+ return str(output)
37
+
38
+
39
+ def simple_pdf(title: str, lines: list[str], output: Path) -> None:
40
+ escaped = [line.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") for line in [title, *lines]]
41
+ commands = ["BT", "/F1 16 Tf", "50 790 Td", f"({escaped[0]}) Tj", "/F1 10 Tf"]
42
+ for line in escaped[1:]:
43
+ commands.extend(("0 -16 Td", f"({line}) Tj"))
44
+ commands.append("ET")
45
+ stream = "\n".join(commands).encode("ascii", errors="replace")
46
+ objects = [
47
+ b"<< /Type /Catalog /Pages 2 0 R >>",
48
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
49
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
50
+ b"<< /Length %d >>\nstream\n" % len(stream) + stream + b"\nendstream",
51
+ b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
52
+ ]
53
+ content = bytearray(b"%PDF-1.4\n")
54
+ offsets = [0]
55
+ for index, obj in enumerate(objects, 1):
56
+ offsets.append(len(content)); content.extend(f"{index} 0 obj\n".encode()); content.extend(obj); content.extend(b"\nendobj\n")
57
+ xref = len(content); content.extend(f"xref\n0 {len(objects)+1}\n0000000000 65535 f \n".encode())
58
+ for offset in offsets[1:]: content.extend(f"{offset:010d} 00000 n \n".encode())
59
+ content.extend(f"trailer\n<< /Size {len(objects)+1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode())
60
+ output.parent.mkdir(parents=True, exist_ok=True); output.write_bytes(content)
61
+
62
+
63
+ def box_stl(width: float, depth: float, height: float, wall: float, output: Path) -> None:
64
+ """Write a watertight open-top enclosure shell as deterministic ASCII STL."""
65
+ outer = _box_triangles(0, 0, 0, width, depth, height)
66
+ inner = _box_triangles(wall, wall, wall, width - wall, depth - wall, height + wall)
67
+ triangles = outer + [(c, b, a) for a, b, c in inner]
68
+ lines = ["solid enclosure"]
69
+ for a, b, c in triangles:
70
+ normal = _normal(a, b, c)
71
+ lines.append(f" facet normal {normal[0]:.6f} {normal[1]:.6f} {normal[2]:.6f}")
72
+ lines.append(" outer loop")
73
+ lines.extend(f" vertex {p[0]:.6f} {p[1]:.6f} {p[2]:.6f}" for p in (a, b, c))
74
+ lines.extend((" endloop", " endfacet"))
75
+ lines.append("endsolid enclosure")
76
+ atomic_write_text(output, "\n".join(lines) + "\n")
77
+
78
+
79
+ def _box_triangles(x0: float, y0: float, z0: float, x1: float, y1: float, z1: float):
80
+ p = [(x0, y0, z0), (x1, y0, z0), (x1, y1, z0), (x0, y1, z0), (x0, y0, z1), (x1, y0, z1), (x1, y1, z1), (x0, y1, z1)]
81
+ faces = [(0, 2, 1), (0, 3, 2), (4, 5, 6), (4, 6, 7), (0, 1, 5), (0, 5, 4), (1, 2, 6), (1, 6, 5), (2, 3, 7), (2, 7, 6), (3, 0, 4), (3, 4, 7)]
82
+ return [(p[a], p[b], p[c]) for a, b, c in faces]
83
+
84
+
85
+ def _normal(a, b, c):
86
+ u = tuple(b[i] - a[i] for i in range(3))
87
+ v = tuple(c[i] - a[i] for i in range(3))
88
+ n = (u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0])
89
+ length = math.sqrt(sum(value * value for value in n)) or 1.0
90
+ return tuple(value / length for value in n)
91
+
92
+
93
+ def step_box(name: str, width: float, depth: float, height: float, output: Path) -> None:
94
+ """Write a compact AP214 faceted BREP box accepted by standard STEP readers."""
95
+ points = [(0, 0, 0), (width, 0, 0), (width, depth, 0), (0, depth, 0), (0, 0, height), (width, 0, height), (width, depth, height), (0, depth, height)]
96
+ faces = [(1, 4, 3, 2), (5, 6, 7, 8), (1, 2, 6, 5), (2, 3, 7, 6), (3, 4, 8, 7), (4, 1, 5, 8)]
97
+ data = ["ISO-10303-21;", "HEADER;", "FILE_DESCRIPTION(('HW co-design deterministic faceted model'),'2;1');", f"FILE_NAME('{name}.step','1980-01-01T00:00:00',('hw-codesign'),('hw-codesign'),'hw-codesign','hw-codesign','');", "FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));", "ENDSEC;", "DATA;"]
98
+ index = 1
99
+ point_ids = []
100
+ for x, y, z in points:
101
+ data.append(f"#{index}=CARTESIAN_POINT('',({x:.6f},{y:.6f},{z:.6f}));")
102
+ point_ids.append(index)
103
+ index += 1
104
+ face_ids = []
105
+ for face in faces:
106
+ loop_points = ",".join(f"#{point_ids[item - 1]}" for item in (*face, face[0]))
107
+ data.append(f"#{index}=POLY_LOOP('',({loop_points}));")
108
+ loop_id = index; index += 1
109
+ data.append(f"#{index}=FACE_OUTER_BOUND('',#{loop_id},.T.);")
110
+ bound_id = index; index += 1
111
+ data.append(f"#{index}=FACE('',(#{bound_id}));")
112
+ face_ids.append(index); index += 1
113
+ data.append(f"#{index}=CLOSED_SHELL('',({','.join(f'#{item}' for item in face_ids)}));")
114
+ shell_id = index; index += 1
115
+ data.append(f"#{index}=FACETED_BREP('{name}',#{shell_id});")
116
+ data.extend(("ENDSEC;", "END-ISO-10303-21;"))
117
+ atomic_write_text(output, "\n".join(data) + "\n")
@@ -0,0 +1 @@
1
+ """Deterministic backend adapters."""
@@ -0,0 +1,206 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ..io import atomic_write_text, write_json
10
+ from ..models import Failure, FailureCategory, GateReport, Status
11
+ from ..provenance import artifact_provenance
12
+ from .electronics import ElectronicsBackendAdapter
13
+
14
+ _RAIL_NAMES = frozenset(("GND", "V3V3", "VCC", "V5", "VBAT"))
15
+ _SIGNAL_RE = re.compile(r"^\s+signal\s+(\w+)", re.MULTILINE)
16
+
17
+ _FOOTPRINT_DEFERRED = Failure(
18
+ FailureCategory.TOOL_ERROR,
19
+ "footprint_assignment_deferred",
20
+ "Atopile assigns footprints through the KiCad plugin at layout time, not at compile time",
21
+ details={"atopile_version": "0.15.7", "deferred_to": "kicad_plugin_layout"},
22
+ )
23
+ _LAYOUT_BLOCKED = Failure(
24
+ FailureCategory.TOOL_ERROR,
25
+ "kicad_plugin_required",
26
+ "Atopile layout and manufacturing export require the KiCad plugin; install KiCad and configure the atopile plugin path",
27
+ details={"atopile_version": "0.15.7", "blocked_on": "kicad_plugin_path"},
28
+ )
29
+
30
+
31
+ def _ato_source(module_name: str, graph: dict[str, Any]) -> str:
32
+ nets = [net["name"] for net in graph.get("nets", []) if net.get("name")]
33
+ rails = sorted({n for n in nets if n in _RAIL_NAMES})
34
+ signals = sorted({n.lower().replace(" ", "_").replace("-", "_") for n in nets if n not in _RAIL_NAMES})
35
+ components = graph.get("components", [])
36
+
37
+ lines = [f"module {module_name}:"]
38
+ if rails:
39
+ lines.append(" # Power rails")
40
+ for rail in rails:
41
+ lines.append(f" signal {rail.lower()}")
42
+ if signals:
43
+ lines.append(" # Design signals")
44
+ for sig in signals:
45
+ lines.append(f" signal {sig}")
46
+ if components:
47
+ lines.append(" # Component placeholders (populate imports from atopile package registry)")
48
+ for comp in components:
49
+ ref = comp.get("ref", "?")
50
+ mpn = comp.get("mpn") or comp.get("category", "unknown")
51
+ lines.append(f" # {ref}: {mpn}")
52
+ return "\n".join(lines) + "\n"
53
+
54
+
55
+ def _ato_declared_signals(source: str) -> set[str]:
56
+ return {m.group(1) for m in _SIGNAL_RE.finditer(source)}
57
+
58
+
59
+ def _expected_ato_signals(graph: dict[str, Any]) -> set[str]:
60
+ nets = [net["name"] for net in graph.get("nets", []) if net.get("name")]
61
+ rails = {n.lower() for n in nets if n in _RAIL_NAMES}
62
+ signals = {n.lower().replace(" ", "_").replace("-", "_") for n in nets if n not in _RAIL_NAMES}
63
+ return rails | signals
64
+
65
+
66
+ def _source_parity_gates(ato_file: Path, graph: dict[str, Any]) -> list[GateReport]:
67
+ source_text = ato_file.read_text(encoding="utf-8")
68
+ declared = _ato_declared_signals(source_text)
69
+ expected = _expected_ato_signals(graph)
70
+ missing = expected - declared
71
+ parity_failures = [
72
+ Failure(
73
+ FailureCategory.EDA_ERROR,
74
+ "ato_signal_missing",
75
+ f"Net {sig!r} in graph but absent from generated .ato source",
76
+ details={"missing_signal": sig},
77
+ )
78
+ for sig in sorted(missing)[:20]
79
+ ]
80
+ return [
81
+ GateReport(
82
+ "atopile_netlist_extract",
83
+ Status.PASS,
84
+ [],
85
+ metrics={"declared_signals": len(declared)},
86
+ artifacts=[str(ato_file)],
87
+ backend={"name": "atopile", "method": "source_ast_extraction"},
88
+ ),
89
+ GateReport(
90
+ "atopile_graph_parity",
91
+ Status.FAIL if parity_failures else Status.PASS,
92
+ parity_failures,
93
+ metrics={"declared": len(declared), "expected": len(expected), "missing": len(missing)},
94
+ backend={"name": "atopile", "method": "source_ast_parity"},
95
+ ),
96
+ GateReport("atopile_footprint_parity", Status.BLOCKED, [_FOOTPRINT_DEFERRED], backend={"name": "atopile"}),
97
+ GateReport("atopile_layout_completeness", Status.BLOCKED, [_LAYOUT_BLOCKED], backend={"name": "atopile"}),
98
+ GateReport("atopile_manufacturing_export", Status.BLOCKED, [_LAYOUT_BLOCKED], backend={"name": "atopile"}),
99
+ ]
100
+
101
+
102
+ class AtopileBackend(ElectronicsBackendAdapter):
103
+ name = "atopile"
104
+
105
+ def __init__(self, platform_root: Path):
106
+ self.platform_root = platform_root
107
+
108
+ def generate_source(self, project: Path, spec: dict[str, Any], graph: dict[str, Any]) -> list[str]:
109
+ target = project / "electronics" / "source" / self.name
110
+ target.mkdir(parents=True, exist_ok=True)
111
+ project_name = spec.get("project", {}).get("name", project.name)
112
+ module_name = "".join(word.capitalize() for word in project_name.split("_")) or "Design"
113
+ ato_file = target / "design.ato"
114
+ atomic_write_text(ato_file, _ato_source(module_name, graph))
115
+ ato_yaml = target / "ato.yaml"
116
+ atomic_write_text(ato_yaml, "requires-atopile: \">=0.15.0\"\n")
117
+ manifest = target / "source_manifest.json"
118
+ write_json(manifest, {
119
+ "backend": self.name,
120
+ "backend_release_capable": True,
121
+ "source_release_eligible": True,
122
+ "release_tier": "hdl_source",
123
+ "netlist_release_eligible": False,
124
+ "hdl_source_release_eligible": True,
125
+ "fabrication_release_eligible": False,
126
+ "sources": self.source_entries(target, [ato_file, ato_yaml]),
127
+ "contract_gates": list(self.gate_names),
128
+ "release_blocking_gates": [
129
+ f"{self.name}_compile",
130
+ f"{self.name}_netlist_extract",
131
+ f"{self.name}_graph_parity",
132
+ ],
133
+ "provenance": artifact_provenance(
134
+ spec,
135
+ self.platform_root / "parts",
136
+ self.name,
137
+ command=[],
138
+ release_eligible=True,
139
+ release_tier="hdl_source",
140
+ ),
141
+ })
142
+ return [str(ato_file), str(ato_yaml), str(manifest)]
143
+
144
+ def evaluate(self, project: Path, graph: dict[str, Any]) -> list[GateReport]:
145
+ ato_bin = shutil.which("ato")
146
+ if not ato_bin:
147
+ compile_report = GateReport(
148
+ f"{self.name}_compile",
149
+ Status.BLOCKED,
150
+ [Failure(FailureCategory.TOOL_ERROR, "tool_unavailable", "atopile CLI not found; install with: brew install atopile")],
151
+ backend={"name": self.name},
152
+ )
153
+ return self.complete_contract([compile_report])
154
+ source_dir = project / "electronics" / "source" / self.name
155
+ ato_file = source_dir / "design.ato"
156
+ if not ato_file.is_file():
157
+ compile_report = GateReport(
158
+ f"{self.name}_compile",
159
+ Status.BLOCKED,
160
+ [Failure(FailureCategory.TOOL_ERROR, "source_not_generated", "Run generate_electronics first to produce atopile source")],
161
+ backend={"name": self.name},
162
+ )
163
+ return self.complete_contract([compile_report])
164
+
165
+ try:
166
+ result = subprocess.run(
167
+ [ato_bin, "build"],
168
+ cwd=source_dir,
169
+ capture_output=True,
170
+ text=True,
171
+ timeout=60,
172
+ )
173
+ except subprocess.TimeoutExpired:
174
+ compile_report = GateReport(
175
+ f"{self.name}_compile",
176
+ Status.FAIL,
177
+ [Failure(FailureCategory.TOOL_ERROR, "build_timeout", "atopile build timed out after 60 seconds")],
178
+ backend={"name": self.name, "ato_bin": ato_bin},
179
+ )
180
+ return self.complete_contract([compile_report])
181
+ except OSError as exc:
182
+ compile_report = GateReport(
183
+ f"{self.name}_compile",
184
+ Status.BLOCKED,
185
+ [Failure(FailureCategory.TOOL_ERROR, "tool_unavailable", str(exc))],
186
+ backend={"name": self.name},
187
+ )
188
+ return self.complete_contract([compile_report])
189
+
190
+ stderr_text = (result.stdout + "\n" + result.stderr).strip()
191
+ if result.returncode == 0:
192
+ compile_report = GateReport(
193
+ f"{self.name}_compile",
194
+ Status.PASS,
195
+ [],
196
+ backend={"name": self.name, "ato_bin": ato_bin},
197
+ )
198
+ return self.complete_contract([compile_report, *_source_parity_gates(ato_file, graph)])
199
+ else:
200
+ compile_report = GateReport(
201
+ f"{self.name}_compile",
202
+ Status.FAIL,
203
+ [Failure(FailureCategory.EDA_ERROR, "build_failed", stderr_text[:1000])],
204
+ backend={"name": self.name, "ato_bin": ato_bin},
205
+ )
206
+ return self.complete_contract([compile_report])
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from ..models import Failure, FailureCategory, GateReport, Status
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ToolResult:
14
+ command: list[str]
15
+ returncode: int | None
16
+ stdout: str
17
+ stderr: str
18
+ available: bool
19
+
20
+
21
+ def resolve_tool(executable: str) -> str | None:
22
+ resolved = shutil.which(executable)
23
+ if resolved is None and executable == "kicad-cli":
24
+ candidate = Path("/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli")
25
+ resolved = str(candidate) if candidate.is_file() else None
26
+ return resolved
27
+
28
+
29
+ def resolve_kicad_python() -> str | None:
30
+ """Return path to KiCad's bundled Python for pcbnew scripting access."""
31
+ env_path = os.environ.get("HW_KICAD_PYTHON")
32
+ if env_path:
33
+ p = Path(env_path)
34
+ if p.is_file():
35
+ return str(p)
36
+ for candidate in [
37
+ Path("/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.9/bin/python3.9"),
38
+ Path("/usr/lib/kicad/lib/python3/dist-packages/../../../bin/python3"),
39
+ ]:
40
+ if candidate.is_file():
41
+ return str(candidate)
42
+ return None
43
+
44
+
45
+ def tool_version(executable: str) -> str | None:
46
+ resolved = resolve_tool(executable)
47
+ if resolved is None:
48
+ return None
49
+ try:
50
+ timeout = float(os.environ.get("HW_TOOL_VERSION_TIMEOUT_SECONDS", "5"))
51
+ except ValueError:
52
+ timeout = 5.0
53
+ try:
54
+ completed = subprocess.run([resolved, "version"], capture_output=True, text=True, timeout=timeout, check=False)
55
+ except (OSError, subprocess.TimeoutExpired):
56
+ return None
57
+ return completed.stdout.strip() or completed.stderr.strip() or None
58
+
59
+
60
+ def run_tool(executable: str, arguments: list[str], cwd: Path, timeout: int = 300, env: dict[str, str] | None = None) -> ToolResult:
61
+ resolved = resolve_tool(executable)
62
+ command = [executable, *arguments]
63
+ if resolved is None:
64
+ return ToolResult(command, None, "", f"Executable not found: {executable}", False)
65
+ try:
66
+ completed = subprocess.run([resolved, *arguments], cwd=cwd, capture_output=True, text=True, timeout=timeout, check=False, env={**os.environ, **(env or {})})
67
+ return ToolResult(command, completed.returncode, completed.stdout, completed.stderr, True)
68
+ except subprocess.TimeoutExpired:
69
+ return ToolResult(command, None, "", f"{executable} timed out after {timeout}s", False)
70
+
71
+
72
+ def tool_report(gate: str, result: ToolResult, artifacts: list[str] | None = None) -> GateReport:
73
+ backend = {"command": result.command, "returncode": result.returncode, "stdout": result.stdout[-8000:], "stderr": result.stderr[-8000:], "available": result.available}
74
+ if not result.available:
75
+ failure = Failure(FailureCategory.TOOL_ERROR, "tool_unavailable", result.stderr, details={"command": result.command})
76
+ return GateReport(gate, Status.BLOCKED, [failure], backend=backend)
77
+ if result.returncode != 0:
78
+ failure = Failure(FailureCategory.TOOL_ERROR, "tool_failed", f"{result.command[0]} exited with status {result.returncode}", details=backend)
79
+ return GateReport(gate, Status.FAIL, [failure], backend=backend)
80
+ return GateReport(gate, Status.PASS, artifacts=artifacts or [], backend=backend)
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from hashlib import sha256
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ..models import Failure, FailureCategory, GateReport, Status
9
+
10
+ CONTRACT_STAGES = (
11
+ "compile",
12
+ "netlist_extract",
13
+ "graph_parity",
14
+ "footprint_parity",
15
+ "layout_completeness",
16
+ "manufacturing_export",
17
+ )
18
+
19
+
20
+ class ElectronicsBackendAdapter(ABC):
21
+ name: str
22
+
23
+ @property
24
+ def gate_names(self) -> tuple[str, ...]:
25
+ return tuple(f"{self.name}_{stage}" for stage in CONTRACT_STAGES)
26
+
27
+ @abstractmethod
28
+ def generate_source(self, project: Path, spec: dict[str, Any], graph: dict[str, Any]) -> list[str]:
29
+ raise NotImplementedError
30
+
31
+ @abstractmethod
32
+ def evaluate(self, project: Path, graph: dict[str, Any]) -> list[GateReport]:
33
+ raise NotImplementedError
34
+
35
+ def blocked_contract(self, code: str, message: str, *, category: FailureCategory = FailureCategory.TOOL_ERROR) -> list[GateReport]:
36
+ failure = Failure(category, code, message)
37
+ return [GateReport(gate, Status.BLOCKED, [failure], backend={"name": self.name}) for gate in self.gate_names]
38
+
39
+ def complete_contract(self, reports: list[GateReport]) -> list[GateReport]:
40
+ by_gate = {report.gate: report for report in reports}
41
+ for gate in self.gate_names:
42
+ if gate not in by_gate:
43
+ by_gate[gate] = GateReport(
44
+ gate,
45
+ Status.BLOCKED,
46
+ [Failure(FailureCategory.EDA_ERROR, "gate_not_run", f"Backend contract gate was not run: {gate}")],
47
+ backend={"name": self.name},
48
+ )
49
+ return [by_gate[gate] for gate in self.gate_names]
50
+
51
+ @staticmethod
52
+ def source_entries(root: Path, paths: list[Path]) -> list[dict[str, str]]:
53
+ return [
54
+ {"path": path.relative_to(root).as_posix(), "sha256": sha256(path.read_bytes()).hexdigest()}
55
+ for path in sorted(paths)
56
+ ]
57
+
58
+ @staticmethod
59
+ def graph_netlist(graph: dict[str, Any]) -> dict[str, list[str]]:
60
+ return {item["name"]: sorted(item.get("connected_pins", [])) for item in graph.get("nets", [])}
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from . import interface_stack, periodic_transmit, sensor_poll, state_machine, timeout_shutdown
6
+ from ._base import ModuleOutput
7
+
8
+ _RENDERERS = {
9
+ "timeout_shutdown": timeout_shutdown.render,
10
+ "periodic_transmit": periodic_transmit.render,
11
+ "state_machine": state_machine.render,
12
+ "sensor_poll": sensor_poll.render,
13
+ "interface_stack": interface_stack.render,
14
+ }
15
+
16
+ BEHAVIOR_DESCRIPTIONS: dict[str, str] = {
17
+ "timeout_shutdown": (
18
+ "Watchdog pattern: arms a timer on init; if not kicked within timeout_ms "
19
+ "or the trigger signal asserts, disables the specified outputs and asserts a fault pin."
20
+ ),
21
+ "periodic_transmit": (
22
+ "Scheduled frame transmit: sends a CAN/UART/I2C frame on a fixed interval. "
23
+ "Frame ID, DLC, and content are parametric."
24
+ ),
25
+ "state_machine": (
26
+ "Agent-specified state machine: states, transitions, and entry/exit actions are "
27
+ "parameterized; generates a thread-safe enum-based FSM."
28
+ ),
29
+ "sensor_poll": (
30
+ "Scheduled ADC/I2C/SPI read: polls a sensor every poll_interval_ms and writes "
31
+ "readings into a fixed-size ring buffer."
32
+ ),
33
+ "interface_stack": (
34
+ "Firmware stack binding: declares USB, QSPI/SPI, PIO, or similar stack ownership "
35
+ "over graph-grounded nets, required bring-up tests, and init order. It is an "
36
+ "interface contract stub, not behavior-level firmware proof."
37
+ ),
38
+ }
39
+
40
+ BEHAVIOR_SCHEMAS: dict[str, dict[str, Any]] = {
41
+ "timeout_shutdown": {
42
+ "type": "object",
43
+ "required": ["id", "behavior", "trigger", "action"],
44
+ "properties": {
45
+ "id": {"type": "string", "description": "Module identifier (used as C symbol prefix and file name)"},
46
+ "behavior": {"type": "string", "const": "timeout_shutdown"},
47
+ "trigger": {
48
+ "type": "object",
49
+ "properties": {
50
+ "signal": {"type": "string", "description": "GPIO signal name from pinmap (e.g. ESTOP_IN)"},
51
+ "timeout_ms": {"type": "integer", "description": "Watchdog timeout in milliseconds"},
52
+ },
53
+ "required": ["signal", "timeout_ms"],
54
+ },
55
+ "action": {
56
+ "type": "object",
57
+ "properties": {
58
+ "disable_all": {"type": "string", "description": "Logical group to disable (e.g. motor_enables)"},
59
+ "assert": {"type": "string", "description": "Signal to assert on trigger (e.g. FAULT_LED)"},
60
+ },
61
+ },
62
+ },
63
+ },
64
+ "periodic_transmit": {
65
+ "type": "object",
66
+ "required": ["id", "behavior", "interval_ms", "frame"],
67
+ "properties": {
68
+ "id": {"type": "string"},
69
+ "behavior": {"type": "string", "const": "periodic_transmit"},
70
+ "interval_ms": {"type": "integer", "description": "Transmit interval in milliseconds"},
71
+ "transport": {"type": "string", "enum": ["can", "uart", "i2c"], "default": "can"},
72
+ "frame": {
73
+ "type": "object",
74
+ "properties": {
75
+ "id": {"description": "Frame/register address (integer or hex string)"},
76
+ "dlc": {"type": "integer", "description": "Data length in bytes"},
77
+ "content": {"type": "string", "description": "Logical content label (e.g. motor_status)"},
78
+ },
79
+ "required": ["id", "dlc"],
80
+ },
81
+ },
82
+ },
83
+ "state_machine": {
84
+ "type": "object",
85
+ "required": ["id", "behavior", "states", "transitions"],
86
+ "properties": {
87
+ "id": {"type": "string"},
88
+ "behavior": {"type": "string", "const": "state_machine"},
89
+ "initial_state": {"type": "string", "description": "Name of the initial state"},
90
+ "states": {
91
+ "type": "array",
92
+ "items": {
93
+ "type": "object",
94
+ "properties": {
95
+ "name": {"type": "string"},
96
+ "entry": {"type": ["string", "null"]},
97
+ "exit": {"type": ["string", "null"]},
98
+ },
99
+ "required": ["name"],
100
+ },
101
+ },
102
+ "transitions": {
103
+ "type": "array",
104
+ "items": {
105
+ "type": "object",
106
+ "properties": {
107
+ "from": {"type": "string"},
108
+ "to": {"type": "string"},
109
+ "trigger": {"type": "string"},
110
+ },
111
+ "required": ["from", "to", "trigger"],
112
+ },
113
+ },
114
+ },
115
+ },
116
+ "sensor_poll": {
117
+ "type": "object",
118
+ "required": ["id", "behavior"],
119
+ "properties": {
120
+ "id": {"type": "string"},
121
+ "behavior": {"type": "string", "const": "sensor_poll"},
122
+ "bus": {"type": "string", "enum": ["i2c", "adc", "spi"], "default": "i2c"},
123
+ "sensor": {"type": "string", "description": "Sensor label (e.g. imu, temperature)"},
124
+ "address": {"type": "string", "description": "I2C address or SPI CS index (e.g. 0x68)"},
125
+ "poll_interval_ms": {"type": "integer", "default": 100},
126
+ "ring_buf_entries": {"type": "integer", "default": 16},
127
+ },
128
+ },
129
+ "interface_stack": {
130
+ "type": "object",
131
+ "required": ["id", "behavior", "stack", "required_nets"],
132
+ "properties": {
133
+ "id": {"type": "string"},
134
+ "behavior": {"type": "string", "const": "interface_stack"},
135
+ "stack": {"type": "string", "description": "Firmware stack or peripheral family, e.g. usb, qspi, pio"},
136
+ "library": {"type": "string", "description": "Implementation library or SDK binding, e.g. tinyusb, pico-sdk"},
137
+ "required_nets": {"type": "array", "items": {"type": "string"}},
138
+ "required_tests": {"type": "array", "items": {"type": "string"}},
139
+ "init_order": {"type": "integer", "default": 50},
140
+ "stack_size_bytes": {"type": "integer", "default": 1024},
141
+ },
142
+ },
143
+ }
144
+
145
+
146
+ def render_module(module: dict[str, Any]) -> ModuleOutput:
147
+ """Dispatch to the appropriate behavior renderer."""
148
+ behavior = module.get("behavior")
149
+ renderer = _RENDERERS.get(behavior)
150
+ if renderer is None:
151
+ raise ValueError(f"Unknown firmware module behavior: {behavior!r}. Available: {sorted(_RENDERERS)}")
152
+ return renderer(module)
153
+
154
+
155
+ __all__ = [
156
+ "ModuleOutput",
157
+ "render_module",
158
+ "BEHAVIOR_DESCRIPTIONS",
159
+ "BEHAVIOR_SCHEMAS",
160
+ "_RENDERERS",
161
+ ]
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class ModuleOutput:
8
+ id: str
9
+ behavior: str
10
+ c_source: str
11
+ h_source: str
12
+ dts_fragment: str | None = None
13
+ kconfig_flags: list[str] = field(default_factory=list)
14
+ stack_size_bytes: int = 2048
15
+ is_isr: bool = False