traceforge-pcb 1.0.0__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 (219) hide show
  1. traceforge/__init__.py +3 -0
  2. traceforge/__main__.py +3 -0
  3. traceforge/acp_server.py +570 -0
  4. traceforge/agent.py +680 -0
  5. traceforge/altium/__init__.py +6 -0
  6. traceforge/altium/cfb.py +70 -0
  7. traceforge/altium/cli.py +60 -0
  8. traceforge/altium/model.py +163 -0
  9. traceforge/altium/outputs.py +24 -0
  10. traceforge/altium/pcb.py +301 -0
  11. traceforge/altium/project.py +328 -0
  12. traceforge/altium/schematic.py +400 -0
  13. traceforge/altium/tools.py +166 -0
  14. traceforge/approval.py +219 -0
  15. traceforge/attachments.py +346 -0
  16. traceforge/background.py +253 -0
  17. traceforge/bench/__init__.py +5 -0
  18. traceforge/bench/adapters.py +337 -0
  19. traceforge/bench/checks.py +410 -0
  20. traceforge/bench/cli.py +121 -0
  21. traceforge/bench/design.py +439 -0
  22. traceforge/bench/kicad.py +99 -0
  23. traceforge/bench/partscheck.py +159 -0
  24. traceforge/bench/review.py +100 -0
  25. traceforge/bench/runner.py +226 -0
  26. traceforge/bench/score.py +204 -0
  27. traceforge/bench/tasks.py +102 -0
  28. traceforge/chat.py +1185 -0
  29. traceforge/checks.py +328 -0
  30. traceforge/checks_sides.py +118 -0
  31. traceforge/cli.py +2224 -0
  32. traceforge/clipboard.py +107 -0
  33. traceforge/config.py +299 -0
  34. traceforge/context.py +276 -0
  35. traceforge/design/__init__.py +1 -0
  36. traceforge/design/constraints.py +397 -0
  37. traceforge/design/generate.py +1789 -0
  38. traceforge/design/outline.py +905 -0
  39. traceforge/design/schematic_layout.py +932 -0
  40. traceforge/design/schematic_lint.py +329 -0
  41. traceforge/design/schematic_route.py +207 -0
  42. traceforge/design/sheets.py +854 -0
  43. traceforge/design/sides.py +328 -0
  44. traceforge/design/spec.py +462 -0
  45. traceforge/design/specsheet.py +2351 -0
  46. traceforge/design/specsheet_render.py +183 -0
  47. traceforge/desktop/__init__.py +109 -0
  48. traceforge/desktop/keys.py +90 -0
  49. traceforge/desktop/project.py +443 -0
  50. traceforge/desktop/review.py +20 -0
  51. traceforge/desktop/server.py +1277 -0
  52. traceforge/desktop/static/app.css +223 -0
  53. traceforge/desktop/static/app.js +683 -0
  54. traceforge/desktop/static/highlight.js +77 -0
  55. traceforge/desktop/static/index.html +89 -0
  56. traceforge/desktop/static/md.js +90 -0
  57. traceforge/desktop/static/viewer.js +213 -0
  58. traceforge/desktop/symbols.py +212 -0
  59. traceforge/desktop/update.py +65 -0
  60. traceforge/diagnostics.py +67 -0
  61. traceforge/errata.py +219 -0
  62. traceforge/fab.py +108 -0
  63. traceforge/fab_layers.py +44 -0
  64. traceforge/features/__init__.py +6 -0
  65. traceforge/features/candidates.py +117 -0
  66. traceforge/features/cli.py +200 -0
  67. traceforge/features/package.py +433 -0
  68. traceforge/features/revdiff.py +224 -0
  69. traceforge/features/sarif.py +145 -0
  70. traceforge/features/viewer.py +265 -0
  71. traceforge/firmware/__init__.py +3 -0
  72. traceforge/firmware/cli.py +174 -0
  73. traceforge/firmware/drivers.py +372 -0
  74. traceforge/firmware/drivers_data.py +256 -0
  75. traceforge/firmware/mcus.py +177 -0
  76. traceforge/firmware/netlist.py +55 -0
  77. traceforge/firmware/pinmap.py +296 -0
  78. traceforge/firmware/scaffold.py +466 -0
  79. traceforge/firmware/toolchain.py +262 -0
  80. traceforge/hooks.py +237 -0
  81. traceforge/kicad/__init__.py +0 -0
  82. traceforge/kicad/cli.py +519 -0
  83. traceforge/kicad/diffpair.py +174 -0
  84. traceforge/kicad/edgegeom.py +191 -0
  85. traceforge/kicad/edit.py +405 -0
  86. traceforge/kicad/escape.py +1410 -0
  87. traceforge/kicad/formats.py +1073 -0
  88. traceforge/kicad/impedance.py +320 -0
  89. traceforge/kicad/install.py +75 -0
  90. traceforge/kicad/libraries.py +490 -0
  91. traceforge/kicad/models3d.py +942 -0
  92. traceforge/kicad/outputs.py +81 -0
  93. traceforge/kicad/pcb.py +787 -0
  94. traceforge/kicad/planes.py +1242 -0
  95. traceforge/kicad/project.py +136 -0
  96. traceforge/kicad/route.py +2698 -0
  97. traceforge/kicad/routegeom.py +164 -0
  98. traceforge/kicad/schematic.py +268 -0
  99. traceforge/kicad/sides.py +34 -0
  100. traceforge/kicad/stackup.py +424 -0
  101. traceforge/kicad_plugin/__init__.py +12 -0
  102. traceforge/kicad_plugin/actions.py +92 -0
  103. traceforge/kicad_plugin/cli.py +66 -0
  104. traceforge/kicad_plugin/client.py +86 -0
  105. traceforge/kicad_plugin/context.py +257 -0
  106. traceforge/kicad_plugin/discovery.py +81 -0
  107. traceforge/kicad_plugin/install.py +203 -0
  108. traceforge/kicad_plugin/overlay.py +60 -0
  109. traceforge/kicad_plugin/payload/action_plugin.py +65 -0
  110. traceforge/kicad_plugin/payload/ipc_main.py +42 -0
  111. traceforge/kicad_plugin/payload/plugin.json +90 -0
  112. traceforge/kicad_plugin/payload/requirements.txt +1 -0
  113. traceforge/kicad_plugin/views.py +202 -0
  114. traceforge/langchain.py +57 -0
  115. traceforge/local.py +580 -0
  116. traceforge/mcp_server.py +304 -0
  117. traceforge/models.py +310 -0
  118. traceforge/next_steps.py +110 -0
  119. traceforge/parts/__init__.py +52 -0
  120. traceforge/parts/alternates.py +488 -0
  121. traceforge/parts/bom.py +423 -0
  122. traceforge/parts/brief.py +705 -0
  123. traceforge/parts/catalog.py +297 -0
  124. traceforge/parts/cli_sourcing.py +156 -0
  125. traceforge/parts/data/duty_rates.yaml +37 -0
  126. traceforge/parts/data/hs_codes.yaml +103 -0
  127. traceforge/parts/datasheet.py +278 -0
  128. traceforge/parts/fx.py +100 -0
  129. traceforge/parts/model.py +185 -0
  130. traceforge/parts/net.py +187 -0
  131. traceforge/parts/offers.py +46 -0
  132. traceforge/parts/packages.py +397 -0
  133. traceforge/parts/pinout.py +714 -0
  134. traceforge/parts/providers/__init__.py +17 -0
  135. traceforge/parts/providers/base.py +100 -0
  136. traceforge/parts/providers/jlc.py +364 -0
  137. traceforge/parts/providers/keyed.py +661 -0
  138. traceforge/parts/report.py +508 -0
  139. traceforge/parts/supply.py +330 -0
  140. traceforge/parts/verify.py +455 -0
  141. traceforge/parts/web.py +575 -0
  142. traceforge/plans.py +70 -0
  143. traceforge/preferences.py +127 -0
  144. traceforge/prices.py +130 -0
  145. traceforge/prices.yaml +28 -0
  146. traceforge/prompts/__init__.py +54 -0
  147. traceforge/prompts/system.md +103 -0
  148. traceforge/providers.py +764 -0
  149. traceforge/remote/__init__.py +33 -0
  150. traceforge/remote/client.py +422 -0
  151. traceforge/remote/oidc.py +240 -0
  152. traceforge/remote/orgs.py +510 -0
  153. traceforge/remote/server.py +690 -0
  154. traceforge/remote/store.py +100 -0
  155. traceforge/review_changes.py +187 -0
  156. traceforge/routing.py +99 -0
  157. traceforge/runtime.py +435 -0
  158. traceforge/sessions.py +324 -0
  159. traceforge/sexpr.py +279 -0
  160. traceforge/sim/__init__.py +17 -0
  161. traceforge/sim/acmath.py +234 -0
  162. traceforge/sim/circuit.py +499 -0
  163. traceforge/sim/cli.py +162 -0
  164. traceforge/sim/engine.py +172 -0
  165. traceforge/sim/ltspice.py +175 -0
  166. traceforge/sim/measure.py +123 -0
  167. traceforge/sim/models.py +76 -0
  168. traceforge/sim/project.py +218 -0
  169. traceforge/sim/rawfile.py +123 -0
  170. traceforge/sim/results.py +298 -0
  171. traceforge/sim/tune.py +172 -0
  172. traceforge/skill_commands.py +165 -0
  173. traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
  174. traceforge/skill_library/compliance-review/SKILL.md +51 -0
  175. traceforge/skill_library/design-review/SKILL.md +47 -0
  176. traceforge/skill_library/errata-hunt/SKILL.md +30 -0
  177. traceforge/skill_library/firmware/SKILL.md +26 -0
  178. traceforge/skill_library/power-budget/SKILL.md +27 -0
  179. traceforge/skill_library/release-package/SKILL.md +29 -0
  180. traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
  181. traceforge/skill_library/thermal-review/SKILL.md +15 -0
  182. traceforge/skills.py +131 -0
  183. traceforge/thermal.py +557 -0
  184. traceforge/tools/__init__.py +48 -0
  185. traceforge/tools/agent_tools.py +213 -0
  186. traceforge/tools/base.py +303 -0
  187. traceforge/tools/batch.py +155 -0
  188. traceforge/tools/connections.py +245 -0
  189. traceforge/tools/files.py +777 -0
  190. traceforge/tools/firmware_tools.py +218 -0
  191. traceforge/tools/kicad_tools.py +1072 -0
  192. traceforge/tools/models3d_tools.py +141 -0
  193. traceforge/tools/parts_tools.py +473 -0
  194. traceforge/tools/processes.py +246 -0
  195. traceforge/tools/review_tools.py +178 -0
  196. traceforge/tools/sim_tools.py +230 -0
  197. traceforge/tui.py +620 -0
  198. traceforge/updates.py +124 -0
  199. traceforge/validate/__init__.py +77 -0
  200. traceforge/validate/builtins.py +1091 -0
  201. traceforge/validate/circuits.py +1240 -0
  202. traceforge/validate/config.py +410 -0
  203. traceforge/validate/default.yaml +113 -0
  204. traceforge/validate/design.py +191 -0
  205. traceforge/validate/explain.py +112 -0
  206. traceforge/validate/grounding.py +169 -0
  207. traceforge/validate/ipc2221.py +350 -0
  208. traceforge/validate/multilayer.py +805 -0
  209. traceforge/validate/netmodel.py +261 -0
  210. traceforge/validate/partcheck.py +472 -0
  211. traceforge/validate/report.py +280 -0
  212. traceforge/validate/review.py +322 -0
  213. traceforge/validate/runner.py +662 -0
  214. traceforge/workspace.py +171 -0
  215. traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
  216. traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
  217. traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
  218. traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
  219. traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,177 @@
1
+ """MCU pin tables (data). Package pin number -> pin name, and what each pin can do.
2
+
3
+ Sources are cited per entry. ``functions`` use these tokens: GPIO, ADC, PWM, I2C_SDA,
4
+ I2C_SCL, SPI_MOSI, SPI_MISO, SPI_SCK, UART_TX, UART_RX. Pins a design may use even
5
+ though the datasheet warns about them go in ``caution``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Mcu:
16
+ key: str
17
+ family: str # avr | esp32 | stm32
18
+ name: str
19
+ package: str
20
+ source: str # datasheet table the data was taken from
21
+ match: str # regex over "<symbol> <value> <footprint>"
22
+ pins: dict[str, str] = field(default_factory=dict) # package pin number -> pin name
23
+ functions: dict[str, tuple[str, ...]] = field(default_factory=dict) # pin name -> tokens
24
+ arduino: dict[str, int] = field(default_factory=dict) # pin name -> Arduino number
25
+ adc: dict[str, int] = field(default_factory=dict) # pin name -> ADC channel
26
+ caution: dict[str, str] = field(default_factory=dict) # pin name -> warning
27
+ power_pins: tuple[str, ...] = ()
28
+ platformio: dict[str, str] = field(default_factory=dict)
29
+ fqbn: str = ""
30
+ f_cpu: int = 0
31
+
32
+
33
+ _ATTINY85 = Mcu(
34
+ key="attiny85",
35
+ family="avr",
36
+ name="ATtiny85",
37
+ package="SOIC-8 / DIP-8",
38
+ source="Microchip ATtiny25/45/85 datasheet (DS40002586), section 1.1 Pin Descriptions, "
39
+ "section 10.3 Alternate Port Functions",
40
+ match=r"attiny85",
41
+ pins={
42
+ "1": "PB5",
43
+ "2": "PB3",
44
+ "3": "PB4",
45
+ "4": "GND",
46
+ "5": "PB0",
47
+ "6": "PB1",
48
+ "7": "PB2",
49
+ "8": "VCC",
50
+ },
51
+ functions={
52
+ # PB0: MOSI/DI/SDA/AIN0/OC0A; PB1: MISO/DO/AIN1/OC0B/OC1A; PB2: SCK/USCK/SCL/ADC1
53
+ "PB0": ("GPIO", "PWM", "I2C_SDA", "SPI_MOSI"),
54
+ "PB1": ("GPIO", "PWM", "SPI_MISO"),
55
+ "PB2": ("GPIO", "ADC", "I2C_SCL", "SPI_SCK"),
56
+ "PB3": ("GPIO", "ADC", "PWM"),
57
+ "PB4": ("GPIO", "ADC", "PWM"),
58
+ "PB5": ("GPIO", "ADC"),
59
+ },
60
+ arduino={"PB0": 0, "PB1": 1, "PB2": 2, "PB3": 3, "PB4": 4, "PB5": 5},
61
+ adc={"PB5": 0, "PB2": 1, "PB4": 2, "PB3": 3},
62
+ caution={"PB5": "RESET pin: using it as I/O needs the RSTDISBL fuse and loses ISP"},
63
+ power_pins=("VCC", "GND"),
64
+ platformio={"platform": "atmelavr", "board": "attiny85", "framework": "arduino"},
65
+ fqbn="ATTinyCore:avr:attinyx5",
66
+ f_cpu=8_000_000,
67
+ )
68
+
69
+ _ESP32S3_PINS = {
70
+ "1": "GND",
71
+ "2": "3V3",
72
+ "3": "EN",
73
+ "4": "IO4",
74
+ "5": "IO5",
75
+ "6": "IO6",
76
+ "7": "IO7",
77
+ "8": "IO15",
78
+ "9": "IO16",
79
+ "10": "IO17",
80
+ "11": "IO18",
81
+ "12": "IO8",
82
+ "13": "IO19",
83
+ "14": "IO20",
84
+ "15": "IO3",
85
+ "16": "IO46",
86
+ "17": "IO9",
87
+ "18": "IO10",
88
+ "19": "IO11",
89
+ "20": "IO12",
90
+ "21": "IO13",
91
+ "22": "IO14",
92
+ "23": "IO21",
93
+ "24": "IO47",
94
+ "25": "IO48",
95
+ "26": "IO45",
96
+ "27": "IO0",
97
+ "28": "IO35",
98
+ "29": "IO36",
99
+ "30": "IO37",
100
+ "31": "IO38",
101
+ "32": "IO39",
102
+ "33": "IO40",
103
+ "34": "IO41",
104
+ "35": "IO42",
105
+ "36": "IO44",
106
+ "37": "IO43",
107
+ "38": "IO2",
108
+ "39": "IO1",
109
+ "40": "GND",
110
+ "41": "GND",
111
+ }
112
+ _ESP32S3_GPIO = [n for n in range(49) if n not in range(22, 26)] # GPIO22-25 do not exist
113
+ _ESP32S3_FUNCS: dict[str, tuple[str, ...]] = {}
114
+ for _n in _ESP32S3_GPIO:
115
+ # The GPIO matrix routes any digital peripheral to any pin; ADC1 = 1-10, ADC2 = 11-20.
116
+ _f = ["GPIO", "PWM", "I2C_SDA", "I2C_SCL", "SPI_MOSI", "SPI_MISO", "SPI_SCK"]
117
+ _f += ["UART_TX", "UART_RX"]
118
+ if 1 <= _n <= 20:
119
+ _f.append("ADC")
120
+ _ESP32S3_FUNCS[f"IO{_n}"] = tuple(_f)
121
+ _ESP32S3_ADC = {f"IO{n}": n - 1 for n in range(1, 21)} # ADC1 ch0-9, ADC2 ch0-9 (n 11-20)
122
+ _ESP32S3_ADC.update({f"IO{n}": n - 11 for n in range(11, 21)})
123
+ _ESP32S3_CAUTION = {
124
+ **{f"IO{n}": "strapping pin: keep its boot level" for n in (0, 3, 45, 46)},
125
+ **{f"IO{n}": "connected to the module's flash/PSRAM: not usable" for n in range(26, 33)},
126
+ **{
127
+ f"IO{n}": "octal PSRAM on N8R8/N16R8 modules: not usable there"
128
+ for n in (33, 34, 35, 36, 37)
129
+ },
130
+ "IO19": "USB D- (native USB); using it as GPIO disables USB",
131
+ "IO20": "USB D+ (native USB); using it as GPIO disables USB",
132
+ }
133
+ _ESP32S3 = Mcu(
134
+ key="esp32s3",
135
+ family="esp32",
136
+ name="ESP32-S3-WROOM-1",
137
+ package="module, 41 pads",
138
+ source="Espressif ESP32-S3-WROOM-1 datasheet, Table 3-1 Pin Definitions; ESP32-S3 datasheet "
139
+ "section 2.2 (IO MUX, GPIO matrix, strapping pins)",
140
+ match=r"esp32-s3|esp32s3",
141
+ pins=_ESP32S3_PINS,
142
+ functions=_ESP32S3_FUNCS,
143
+ arduino={f"IO{n}": n for n in _ESP32S3_GPIO},
144
+ adc=_ESP32S3_ADC,
145
+ caution=_ESP32S3_CAUTION,
146
+ power_pins=("3V3", "GND", "EN"),
147
+ platformio={
148
+ "platform": "espressif32",
149
+ "board": "esp32-s3-devkitc-1",
150
+ "framework": "arduino",
151
+ },
152
+ fqbn="esp32:esp32:esp32s3",
153
+ f_cpu=240_000_000,
154
+ )
155
+
156
+ _STM32F103 = Mcu(
157
+ key="stm32f103",
158
+ family="stm32",
159
+ name="STM32F103C8T6",
160
+ package="LQFP-48",
161
+ source="ST STM32F103x8/xB datasheet (DS5319), Table 5 pin definitions. Only pin names "
162
+ "are used (e.g. PA5); package pin numbers are not tabulated here",
163
+ match=r"stm32f103",
164
+ functions={},
165
+ power_pins=("VDD", "VSS", "VBAT", "VDDA", "VSSA", "NRST", "BOOT0"),
166
+ platformio={"platform": "ststm32", "board": "bluepill_f103c8", "framework": "stm32cube"},
167
+ f_cpu=8_000_000,
168
+ )
169
+
170
+ MCUS: dict[str, Mcu] = {m.key: m for m in (_ATTINY85, _ESP32S3, _STM32F103)}
171
+
172
+
173
+ def find_mcu(text: str) -> Mcu | None:
174
+ for mcu in MCUS.values():
175
+ if re.search(mcu.match, text, re.I):
176
+ return mcu
177
+ return None
@@ -0,0 +1,55 @@
1
+ """Read the connections of a design: nets -> (reference, pin) and what each part is."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tempfile
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+
10
+ class NetlistError(Exception):
11
+ pass
12
+
13
+
14
+ @dataclass
15
+ class Netlist:
16
+ nets: dict[str, list[tuple[str, str]]]
17
+ parts: dict[str, str] = field(default_factory=dict) # reference -> "symbol value footprint"
18
+ source: str = ""
19
+
20
+
21
+ def load_netlist(path: str | Path) -> Netlist:
22
+ """Load a design spec (.yaml/.json), a schematic (.kicad_sch) or a KiCad netlist (.net)."""
23
+ path = Path(path)
24
+ if not path.exists():
25
+ raise NetlistError(f"{path} does not exist")
26
+ suffix = path.suffix.lower()
27
+ if suffix in {".yaml", ".yml", ".json"}:
28
+ from traceforge.design.spec import load_spec
29
+
30
+ spec = load_spec(path)
31
+ parts = {p.ref: f"{p.symbol} {p.value} {p.footprint}" for p in spec.parts}
32
+ return Netlist(spec.all_nets(), parts, str(path))
33
+ if suffix == ".kicad_sch":
34
+ from traceforge.kicad.cli import KicadCli, KicadCliError
35
+ from traceforge.kicad.schematic import read_schematic
36
+
37
+ cli = KicadCli()
38
+ if not cli.available:
39
+ raise NetlistError("exporting a netlist from a schematic needs kicad-cli")
40
+ try:
41
+ with tempfile.TemporaryDirectory() as tmp:
42
+ nets = cli.netlist(path, Path(tmp))
43
+ except KicadCliError as exc:
44
+ raise NetlistError(str(exc)) from exc
45
+ sch = read_schematic(path)
46
+ parts = {
47
+ ref: f"{units[0].lib_id} {units[0].value} {units[0].footprint}"
48
+ for ref, units in sch.components().items()
49
+ }
50
+ return Netlist(nets, parts, str(path))
51
+ if suffix in {".net", ".xml"}:
52
+ from traceforge.kicad.cli import parse_netlist
53
+
54
+ return Netlist(parse_netlist(path.read_text(encoding="utf-8")), {}, str(path))
55
+ raise NetlistError(f"unsupported design file {path.name} (use .yaml, .kicad_sch or .net)")
@@ -0,0 +1,296 @@
1
+ """Signal-to-pin assignments for an MCU from the netlist, and the pin headers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+
8
+ from traceforge.firmware.mcus import MCUS, Mcu, find_mcu
9
+ from traceforge.firmware.netlist import Netlist, NetlistError
10
+
11
+ _POWER = re.compile(
12
+ r"^(\+?\d+V\d*|\+?\d+\.\d+V|VCC\w*|VDD\w*|VBUS\w*|VIN|VBAT|GND\w*|AGND|VSS\w*)$", re.I
13
+ )
14
+
15
+ # (regex over the upper-case net name, peripheral function, direction)
16
+ _ROLES: list[tuple[str, str, str]] = [
17
+ (r"(^|_)(SDA|SIOD)(\d*)($|_)", "I2C_SDA", "io"),
18
+ (r"(^|_)(SCL|SIOC)(\d*)($|_)", "I2C_SCL", "io"),
19
+ (r"(^|_)(MOSI|COPI|SDI)($|_)", "SPI_MOSI", "out"),
20
+ (r"(^|_)(MISO|CIPO|SDO)($|_)", "SPI_MISO", "in"),
21
+ (r"(^|_)(SCK|SCLK|SPI_CLK)($|_)", "SPI_SCK", "out"),
22
+ (r"(^|_)(TX|TXD|UART_TX)\d*($|_)", "UART_TX", "out"),
23
+ (r"(^|_)(RX|RXD|UART_RX)\d*($|_)", "UART_RX", "in"),
24
+ (r"(^|_)(PWM|BL|BACKLIGHT)($|_)", "PWM", "out"),
25
+ (r"(^|_)(ADC|AIN|VSENSE|VBAT_SENSE|VMON|NTC)\d*($|_)", "ADC", "in"),
26
+ (r"(^|_)(BTN|BUTTON|SW|KEY|IRQ|INT|DRDY|PIR|BOOT|PCLK|VSYNC|HREF|Y\d)($|_)", "GPIO", "in"),
27
+ (r"(^|_)(LED|EN|CS|SS|NSS|CSN|RST|RESET|PWDN|XCLK|BUZZER|RELAY)($|_)", "GPIO", "out"),
28
+ ]
29
+ _SPI_CS = re.compile(r"(^|_)(CS|SS|NSS|CSN)($|_)")
30
+
31
+
32
+ @dataclass
33
+ class PinAssignment:
34
+ net: str
35
+ macro: str # identifier-safe upper-case name, unique in the map
36
+ ref: str
37
+ pin: str # as written in the netlist (number or name)
38
+ pin_name: str # the MCU pin name (PB3, IO4, PA5)
39
+ function: str # GPIO, ADC, PWM, I2C_SDA, ...
40
+ direction: str # in | out | io
41
+ pull_up: bool = False
42
+ capabilities: tuple[str, ...] = ()
43
+ port: str | None = None # AVR/STM32 port letter
44
+ bit: int | None = None
45
+ number: int | None = None # the number code uses (GPIO number, Arduino pin)
46
+ adc_channel: int | None = None
47
+ warnings: list[str] = field(default_factory=list)
48
+
49
+
50
+ @dataclass
51
+ class PinMap:
52
+ mcu: Mcu
53
+ ref: str
54
+ assignments: list[PinAssignment]
55
+ unconnected: list[str] = field(default_factory=list)
56
+ warnings: list[str] = field(default_factory=list)
57
+
58
+ def by_net(self, net: str) -> PinAssignment | None:
59
+ return next((a for a in self.assignments if a.net == net), None)
60
+
61
+ def as_dict(self) -> dict:
62
+ return {
63
+ "mcu": self.mcu.name,
64
+ "ref": self.ref,
65
+ "source": self.mcu.source,
66
+ "pins": [
67
+ {
68
+ "net": a.net,
69
+ "macro": f"PIN_{a.macro}",
70
+ "pin": a.pin,
71
+ "pin_name": a.pin_name,
72
+ "function": a.function,
73
+ "direction": a.direction,
74
+ "number": a.number,
75
+ "adc_channel": a.adc_channel,
76
+ "warnings": a.warnings,
77
+ }
78
+ for a in self.assignments
79
+ ],
80
+ "warnings": self.warnings,
81
+ }
82
+
83
+
84
+ def classify(net: str) -> tuple[str, str, bool]:
85
+ """Guess (function, direction, pull-up) from a net name."""
86
+ name = net.upper().lstrip("/")
87
+ for pattern, function, direction in _ROLES:
88
+ if re.search(pattern, name):
89
+ pull = function == "GPIO" and direction == "in" and "BTN" in name
90
+ if function == "SPI_SCK" and re.search(r"(^|_)SCL($|_)", name):
91
+ continue
92
+ return function, direction, pull
93
+ if _SPI_CS.search(name):
94
+ return "GPIO", "out", False
95
+ return "GPIO", "io", False
96
+
97
+
98
+ def macro_name(net: str) -> str:
99
+ return re.sub(r"[^A-Z0-9]+", "_", net.upper().replace("+", "P").replace("~", "N")).strip("_")
100
+
101
+
102
+ def _pin_name(mcu: Mcu, pin: str) -> str | None:
103
+ if pin in mcu.pins:
104
+ return mcu.pins[pin]
105
+ upper = pin.upper()
106
+ if mcu.family == "esp32":
107
+ upper = {"RXD0": "IO44", "TXD0": "IO43"}.get(upper, re.sub(r"^GPIO", "IO", upper))
108
+ names = set(mcu.pins.values())
109
+ return upper if upper in names else None
110
+ return (
111
+ upper if re.fullmatch(r"P[A-K]\d{1,2}", upper) or upper in set(mcu.pins.values()) else None
112
+ )
113
+
114
+
115
+ def _split(mcu: Mcu, name: str) -> tuple[str | None, int | None, int | None]:
116
+ """Return (port, bit, number) for a pin name."""
117
+ if mcu.family == "avr":
118
+ m = re.fullmatch(r"P([A-F])(\d)", name)
119
+ return (m.group(1), int(m.group(2)), mcu.arduino.get(name)) if m else (None, None, None)
120
+ if mcu.family == "esp32":
121
+ m = re.fullmatch(r"IO(\d+)", name)
122
+ return (None, None, int(m.group(1))) if m else (None, None, None)
123
+ m = re.fullmatch(r"P([A-K])(\d{1,2})", name)
124
+ if m:
125
+ return m.group(1), int(m.group(2)), (ord(m.group(1)) - 65) * 16 + int(m.group(2))
126
+ return None, None, None
127
+
128
+
129
+ def find_mcu_ref(netlist: Netlist, mcu: Mcu | None = None) -> tuple[str, Mcu]:
130
+ for ref, text in sorted(netlist.parts.items()):
131
+ found = find_mcu(text)
132
+ if found is not None and (mcu is None or found.key == mcu.key):
133
+ return ref, found
134
+ raise NetlistError(
135
+ "no known MCU in the design (supported: "
136
+ + ", ".join(m.name for m in MCUS.values())
137
+ + "); pass --mcu and --ref"
138
+ )
139
+
140
+
141
+ def build_pinmap(netlist: Netlist, ref: str | None = None, mcu: str | None = None) -> PinMap:
142
+ chosen = MCUS.get(mcu) if mcu else None
143
+ if mcu and chosen is None:
144
+ raise NetlistError(f"unknown MCU {mcu!r} (known: {', '.join(MCUS)})")
145
+ if ref is None:
146
+ ref, chosen = find_mcu_ref(netlist, chosen)
147
+ elif chosen is None:
148
+ chosen = find_mcu(netlist.parts.get(ref, ""))
149
+ if chosen is None:
150
+ raise NetlistError(f"{ref} is not a known MCU; pass --mcu")
151
+ pm = PinMap(chosen, ref, [])
152
+ used: set[str] = set()
153
+ for net, members in netlist.nets.items():
154
+ if _POWER.match(net.lstrip("/")) or net.lstrip("/").startswith("unconnected-"):
155
+ continue
156
+ for member_ref, pin in members:
157
+ if member_ref != ref:
158
+ continue
159
+ name = _pin_name(chosen, str(pin))
160
+ if name is None:
161
+ pm.warnings.append(f"{ref}.{pin} (net {net}): not a pin of {chosen.name}")
162
+ continue
163
+ if name in chosen.power_pins or name == "GND":
164
+ continue
165
+ function, direction, pull = classify(net)
166
+ port, bit, number = _split(chosen, name)
167
+ caps = chosen.functions.get(name, ())
168
+ macro = macro_name(net)
169
+ base, k = macro, 2
170
+ while macro in used:
171
+ macro, k = f"{base}_{k}", k + 1
172
+ used.add(macro)
173
+ item = PinAssignment(
174
+ net, macro, ref, str(pin), name, function, direction, pull, caps,
175
+ port, bit, number, chosen.adc.get(name),
176
+ ) # fmt: skip
177
+ if caps and function not in caps and function != "GPIO":
178
+ item.warnings.append(
179
+ f"{net} looks like {function} but {name} cannot do it (can: {', '.join(caps)})"
180
+ )
181
+ if function == "GPIO" and caps and "GPIO" not in caps:
182
+ item.warnings.append(f"{name} has no GPIO function")
183
+ if name in chosen.caution:
184
+ item.warnings.append(f"{name}: {chosen.caution[name]}")
185
+ pm.assignments.append(item)
186
+ pm.assignments.sort(key=lambda a: (a.number is None, a.number or 0, a.net))
187
+ mapped = {a.pin_name for a in pm.assignments}
188
+ pm.unconnected = sorted(
189
+ n
190
+ for n in set(chosen.pins.values())
191
+ if n not in mapped and n not in chosen.power_pins and n != "GND"
192
+ )
193
+ for a in pm.assignments:
194
+ pm.warnings.extend(a.warnings)
195
+ _check_peripherals(pm)
196
+ return pm
197
+
198
+
199
+ def _check_peripherals(pm: PinMap) -> None:
200
+ funcs: dict[str, list[PinAssignment]] = {}
201
+ for a in pm.assignments:
202
+ funcs.setdefault(a.function, []).append(a)
203
+ for function, items in funcs.items():
204
+ if function.startswith(("I2C", "SPI", "UART")) and len(items) > 1:
205
+ pm.warnings.append(
206
+ f"{function} appears on several nets: " + ", ".join(i.net for i in items)
207
+ )
208
+
209
+
210
+ # ---- headers -------------------------------------------------------------------------
211
+
212
+ _HEADER_NOTE = "Generated by traceforge firmware pins from {src}. Do not edit; regenerate."
213
+
214
+
215
+ def _entries(pm: PinMap) -> list[tuple[str, str, PinAssignment]]:
216
+ """(macro, value expression, assignment) for every pin."""
217
+ out = []
218
+ for a in pm.assignments:
219
+ value = str(a.number) if a.number is not None else f'"{a.pin_name}"'
220
+ out.append((f"PIN_{a.macro}", value, a))
221
+ return out
222
+
223
+
224
+ def _aliases(pm: PinMap) -> list[tuple[str, str]]:
225
+ """Peripheral aliases (PIN_I2C_SDA = PIN_<net>) and camera names for esp_camera."""
226
+ aliases: list[tuple[str, str]] = []
227
+ seen: set[str] = set()
228
+ for a in pm.assignments:
229
+ if a.function.startswith(("I2C_", "SPI_", "UART_")) and a.function not in seen:
230
+ seen.add(a.function)
231
+ aliases.append((f"PIN_{a.function}", f"PIN_{a.macro}"))
232
+ if a.macro.startswith("CAM_"):
233
+ aliases.append((f"CAM_PIN_{a.macro[4:]}", f"PIN_{a.macro}"))
234
+ return aliases
235
+
236
+
237
+ def render_header(pm: PinMap, style: str = "c", source: str = "the netlist") -> str:
238
+ """Render ``pins.h`` (style c), ``pins.hpp`` (cpp) or ``board_pins.rs`` (rust)."""
239
+ note = _HEADER_NOTE.format(src=source)
240
+ entries = _entries(pm)
241
+ aliases = _aliases(pm)
242
+ if style == "rust":
243
+ lines = [f"//! {note}", f"//! MCU {pm.mcu.name} ({pm.ref}); pin data: {pm.mcu.source}", ""]
244
+ for macro, value, a in entries:
245
+ lines.append(f"/// {a.net}: {pm.ref} pin {a.pin} = {a.pin_name}, {a.function}")
246
+ if a.number is not None:
247
+ lines.append(f"pub const {macro}: u8 = {value};")
248
+ else:
249
+ lines.append(f"pub const {macro}: &str = {value};")
250
+ if a.port is not None:
251
+ lines.append(f"pub const {macro}_PORT: char = '{a.port}';")
252
+ lines.append(f"pub const {macro}_BIT: u8 = {a.bit};")
253
+ if a.adc_channel is not None and a.function == "ADC":
254
+ lines.append(f"pub const {macro}_ADC: u8 = {a.adc_channel};")
255
+ for alias, target in aliases:
256
+ lines.append(f"pub const {alias}: u8 = {target};")
257
+ return "\n".join(lines) + "\n"
258
+ cpp = style == "cpp"
259
+ guard = "TRACEFORGE_PINS_HPP" if cpp else "TRACEFORGE_PINS_H"
260
+ lines = [f"/* {note} */", f"/* MCU {pm.mcu.name} ({pm.ref}); pin data: {pm.mcu.source} */"]
261
+ lines += [f"#ifndef {guard}", f"#define {guard}", ""]
262
+ if pm.mcu.family == "avr":
263
+ lines += ["#include <avr/io.h>", ""]
264
+ for macro, value, a in entries:
265
+ lines.append(f"/* {a.net}: {pm.ref} pin {a.pin} = {a.pin_name}, {a.function} */")
266
+ if cpp:
267
+ lines.append(f"constexpr int {macro} = {value};")
268
+ else:
269
+ lines.append(f"#define {macro} {value}")
270
+ extras: list[tuple[str, str]] = []
271
+ if a.port is not None:
272
+ extras.append((f"{macro}_BIT", str(a.bit)))
273
+ if pm.mcu.family == "avr":
274
+ extras += [
275
+ (f"{macro}_DDR", f"DDR{a.port}"),
276
+ (f"{macro}_PORT", f"PORT{a.port}"),
277
+ (f"{macro}_PINREG", f"PIN{a.port}"),
278
+ ]
279
+ else:
280
+ extras += [(f"{macro}_PORT", f"'{a.port}'"), (f"{macro}_PIN", str(a.bit))]
281
+ if a.adc_channel is not None and a.function == "ADC":
282
+ extras.append((f"{macro}_ADC", str(a.adc_channel)))
283
+ for name, val in extras:
284
+ if cpp and val.lstrip("-").isdigit():
285
+ lines.append(f"constexpr int {name} = {val};")
286
+ else:
287
+ lines.append(f"#define {name} {val}")
288
+ if aliases:
289
+ lines.append("")
290
+ for alias, target in aliases:
291
+ lines.append(f"constexpr int {alias} = {target};" if cpp else f"#define {alias} {target}")
292
+ lines += ["", f"#endif /* {guard} */", ""]
293
+ return "\n".join(lines)
294
+
295
+
296
+ HEADER_FILES = {"c": "pins.h", "cpp": "pins.hpp", "rust": "board_pins.rs"}