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,257 @@
1
+ """The selected part as read from KiCad: from ``pcbnew`` (KiCad 7/8) or the IPC API (``kipy``).
2
+
3
+ Both readers are duck-typed on the objects they are handed, so tests can pass a fake module. The
4
+ board is a KiCad ``.kicad_pcb``; the project folder is its directory. Standard library only.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import asdict, dataclass, field
10
+ from typing import Any
11
+
12
+ NM_PER_MM = 1_000_000.0
13
+
14
+
15
+ class SelectionError(RuntimeError):
16
+ pass
17
+
18
+
19
+ @dataclass
20
+ class PartContext:
21
+ reference: str
22
+ value: str = ""
23
+ footprint: str = "" # library:name
24
+ datasheet: str = ""
25
+ description: str = ""
26
+ layer: str = ""
27
+ x_mm: float | None = None
28
+ y_mm: float | None = None
29
+ pad_count: int = 0
30
+ selected_pads: list[dict[str, str]] = field(default_factory=list)
31
+ fields: dict[str, str] = field(default_factory=dict)
32
+ board: str = ""
33
+ project_dir: str = ""
34
+ source: str = "" # "pcbnew" | "kipy"
35
+ kicad_version: str = ""
36
+
37
+ def to_dict(self) -> dict[str, Any]:
38
+ return asdict(self)
39
+
40
+ def describe(self) -> str:
41
+ """Plain lines for the model; only what KiCad told us."""
42
+ lines = [f"reference: {self.reference}"]
43
+ for label, value in (
44
+ ("value", self.value),
45
+ ("footprint", self.footprint),
46
+ ("datasheet", self.datasheet if self.datasheet not in ("", "~") else ""),
47
+ ("description", self.description),
48
+ ("layer", self.layer),
49
+ ):
50
+ if value:
51
+ lines.append(f"{label}: {value}")
52
+ if self.x_mm is not None and self.y_mm is not None:
53
+ lines.append(f"board position (mm): {self.x_mm:.3f}, {self.y_mm:.3f}")
54
+ if self.pad_count:
55
+ lines.append(f"pads: {self.pad_count}")
56
+ if self.selected_pads:
57
+ pads = ", ".join(f"{p['number']}={p['net'] or '-'}" for p in self.selected_pads)
58
+ lines.append(f"selected pads: {pads}")
59
+ for key, value in self.fields.items():
60
+ lines.append(f"{key}: {value}")
61
+ if self.board:
62
+ lines.append(f"board file: {self.board}")
63
+ return "\n".join(lines)
64
+
65
+
66
+ def _dirname(path: str) -> str:
67
+ import os
68
+
69
+ return os.path.dirname(os.path.abspath(path)) if path else ""
70
+
71
+
72
+ def _call(obj: Any, *names: str, default: Any = None) -> Any:
73
+ """The first of several method names that exists (KiCad renames these between versions)."""
74
+ for name in names:
75
+ method = getattr(obj, name, None)
76
+ if callable(method):
77
+ try:
78
+ return method()
79
+ except Exception: # noqa: BLE001 - a missing optional property is not an error
80
+ continue
81
+ return default
82
+
83
+
84
+ def _properties(fp: Any) -> dict[str, str]:
85
+ props = _call(fp, "GetProperties", default=None)
86
+ out: dict[str, str] = {}
87
+ if props:
88
+ try:
89
+ out = {str(k): str(v) for k, v in dict(props).items()}
90
+ except (TypeError, ValueError):
91
+ out = {}
92
+ if not out: # KiCad 8+: GetFields() returns PCB_FIELD objects
93
+ for item in _call(fp, "GetFields", default=None) or []:
94
+ name, text = _call(item, "GetName"), _call(item, "GetText")
95
+ if name:
96
+ out[str(name)] = str(text or "")
97
+ return out
98
+
99
+
100
+ # Fields the context already carries by name; the rest go to ``fields`` (MPN, LCSC, ...).
101
+ _KNOWN = {"reference", "value", "footprint", "datasheet", "description"}
102
+
103
+
104
+ def _footprint_context(pcbnew: Any, board: Any, fp: Any, pads: list[dict[str, str]]) -> PartContext:
105
+ props = _properties(fp)
106
+ datasheet = next((v for k, v in props.items() if k.lower() == "datasheet"), "")
107
+ pos = _call(fp, "GetPosition")
108
+ to_mm = getattr(pcbnew, "ToMM", None)
109
+ x = y = None
110
+ if pos is not None:
111
+ x = float(to_mm(pos.x)) if to_mm else pos.x / NM_PER_MM
112
+ y = float(to_mm(pos.y)) if to_mm else pos.y / NM_PER_MM
113
+ layer = ""
114
+ try:
115
+ layer = str(board.GetLayerName(fp.GetLayer()))
116
+ except Exception: # noqa: BLE001 - a fake or old board without layer names
117
+ layer = ""
118
+ fpid = _call(fp, "GetFPIDAsString") or ""
119
+ path = str(_call(board, "GetFileName") or "")
120
+ version = ""
121
+ get_version = getattr(pcbnew, "Version", None) or getattr(pcbnew, "GetMajorMinorVersion", None)
122
+ if callable(get_version):
123
+ try:
124
+ version = str(get_version())
125
+ except Exception: # noqa: BLE001
126
+ version = ""
127
+ return PartContext(
128
+ reference=str(_call(fp, "GetReference") or ""),
129
+ value=str(_call(fp, "GetValue") or ""),
130
+ footprint=str(fpid),
131
+ datasheet=datasheet,
132
+ description=str(_call(fp, "GetLibDescription", "GetDescription") or ""),
133
+ layer=str(layer),
134
+ x_mm=x,
135
+ y_mm=y,
136
+ pad_count=len(_call(fp, "Pads", default=[]) or []),
137
+ selected_pads=pads,
138
+ fields={k: v for k, v in props.items() if k.lower() not in _KNOWN and v},
139
+ board=path,
140
+ project_dir=_dirname(path),
141
+ source="pcbnew",
142
+ kicad_version=version,
143
+ )
144
+
145
+
146
+ def read_pcbnew_selection(pcbnew: Any, board: Any = None) -> list[PartContext]:
147
+ """Selected footprints (and the selected pads of each) from a ``pcbnew`` module.
148
+
149
+ A selected pad selects its footprint's context too. Raises SelectionError when nothing usable
150
+ is selected.
151
+ """
152
+ board = board if board is not None else pcbnew.GetBoard()
153
+ if board is None:
154
+ raise SelectionError("no board is open in the PCB editor")
155
+ chosen: dict[str, Any] = {}
156
+ pad_rows: dict[str, list[dict[str, str]]] = {}
157
+ for fp in board.GetFootprints():
158
+ ref = str(_call(fp, "GetReference") or "")
159
+ if bool(_call(fp, "IsSelected", default=False)):
160
+ chosen[ref] = fp
161
+ for pad in _call(fp, "Pads", default=[]) or []:
162
+ if bool(_call(pad, "IsSelected", default=False)):
163
+ chosen.setdefault(ref, fp)
164
+ pad_rows.setdefault(ref, []).append(
165
+ {
166
+ "number": str(_call(pad, "GetNumber") or ""),
167
+ "net": str(_call(pad, "GetNetname") or ""),
168
+ }
169
+ )
170
+ if not chosen:
171
+ raise SelectionError("select a footprint in the PCB editor first")
172
+ return [
173
+ _footprint_context(pcbnew, board, fp, pad_rows.get(ref, [])) for ref, fp in chosen.items()
174
+ ]
175
+
176
+
177
+ def _kipy_layer(layer: Any) -> str:
178
+ try:
179
+ from kipy.board_types import BoardLayer # type: ignore[import-not-found]
180
+
181
+ return str(BoardLayer.Name(layer)).removeprefix("BL_")
182
+ except Exception: # noqa: BLE001
183
+ return str(layer)
184
+
185
+
186
+ def read_kipy_selection(kicad: Any, board: Any = None) -> list[PartContext]:
187
+ """Selected footprints (and selected pads) through the KiCad IPC API.
188
+
189
+ ``kicad`` is a ``kipy.KiCad``; the selection is read from the open board.
190
+ """
191
+ board = board if board is not None else kicad.get_board()
192
+ try:
193
+ got = kicad.get_version()
194
+ version = str(getattr(got, "full_version", got))
195
+ except Exception: # noqa: BLE001
196
+ version = ""
197
+ items = list(board.get_selection())
198
+ if not items:
199
+ raise SelectionError("select a footprint in the PCB editor first")
200
+ footprints: dict[str, Any] = {}
201
+ pads_by_parent: dict[str, list[dict[str, str]]] = {}
202
+ loose_pads: list[Any] = []
203
+ for item in items:
204
+ if hasattr(item, "reference_field"):
205
+ footprints[str(item.id.value)] = item
206
+ elif hasattr(item, "number") and hasattr(item, "net"):
207
+ parent = getattr(item, "parent", None)
208
+ row = {"number": str(item.number), "net": str(getattr(item.net, "name", "") or "")}
209
+ if parent is not None and getattr(parent, "value", ""):
210
+ pads_by_parent.setdefault(str(parent.value), []).append(row)
211
+ else:
212
+ loose_pads.append(row)
213
+ if pads_by_parent.keys() - footprints.keys():
214
+ for fp in board.get_footprints(): # a pad selected on its own: fetch its footprint
215
+ key = str(fp.id.value)
216
+ if key in pads_by_parent:
217
+ footprints.setdefault(key, fp)
218
+ if not footprints:
219
+ raise SelectionError("the selection has no footprint; select a part")
220
+ path = str(getattr(board, "name", "") or "")
221
+ project_dir = ""
222
+ try:
223
+ project_dir = str(board.get_project().path or "")
224
+ except Exception: # noqa: BLE001 - older API builds
225
+ project_dir = ""
226
+ if not project_dir and path:
227
+ project_dir = _dirname(path)
228
+ out = []
229
+ for key, fp in footprints.items():
230
+ extra = {}
231
+ for item in getattr(fp, "texts_and_fields", []) or []:
232
+ name = getattr(item, "name", "")
233
+ text = getattr(getattr(item, "text", None), "value", "")
234
+ if name and text and name.lower() not in _KNOWN:
235
+ extra[str(name)] = str(text)
236
+ library = fp.definition.id
237
+ datasheet = getattr(getattr(fp.datasheet_field, "text", None), "value", "") or ""
238
+ out.append(
239
+ PartContext(
240
+ reference=str(fp.reference_field.text.value),
241
+ value=str(fp.value_field.text.value),
242
+ footprint=f"{library.library}:{library.name}" if library.name else "",
243
+ datasheet=str(datasheet),
244
+ description=str(getattr(getattr(fp.description_field, "text", None), "value", "")),
245
+ layer=_kipy_layer(fp.layer),
246
+ x_mm=fp.position.x / NM_PER_MM,
247
+ y_mm=fp.position.y / NM_PER_MM,
248
+ pad_count=len(fp.definition.pads),
249
+ selected_pads=pads_by_parent.get(key, []),
250
+ fields=extra,
251
+ board=path,
252
+ project_dir=project_dir,
253
+ source="kipy",
254
+ kicad_version=version,
255
+ )
256
+ )
257
+ return out
@@ -0,0 +1,81 @@
1
+ """How a plugin finds the running Traceforge app: a small file the app writes at launch.
2
+
3
+ ``traceforge app`` publishes its local URL and session token to ``app.json`` in the Traceforge
4
+ config folder (owner-only permissions) and removes it on exit. ``TRACEFORGE_APP_URL`` and
5
+ ``TRACEFORGE_APP_TOKEN`` override the file. Standard library only.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import time
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Endpoint:
19
+ url: str
20
+ token: str
21
+ workspace: str = ""
22
+ pid: int = 0
23
+
24
+
25
+ def config_dir() -> Path:
26
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
27
+ return Path(base) / "traceforge"
28
+
29
+
30
+ def endpoint_path() -> Path:
31
+ return config_dir() / "app.json"
32
+
33
+
34
+ def publish(*, url: str, token: str, workspace: Path | str | None = None) -> Path | None:
35
+ """Write the endpoint file (never raises: a read-only home must not stop the app)."""
36
+ path = endpoint_path()
37
+ row = {
38
+ "url": url.rstrip("/"),
39
+ "token": token,
40
+ "workspace": str(workspace or ""),
41
+ "pid": os.getpid(),
42
+ "started": time.time(),
43
+ }
44
+ try:
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ tmp = path.with_suffix(".tmp")
47
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
48
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
49
+ json.dump(row, handle)
50
+ os.replace(tmp, path)
51
+ return path
52
+ except OSError:
53
+ return None
54
+
55
+
56
+ def withdraw() -> None:
57
+ """Remove the endpoint file, but only if this process wrote it."""
58
+ path = endpoint_path()
59
+ try:
60
+ row = json.loads(path.read_text(encoding="utf-8"))
61
+ if row.get("pid") == os.getpid():
62
+ path.unlink()
63
+ except (OSError, ValueError):
64
+ pass
65
+
66
+
67
+ def find() -> Endpoint | None:
68
+ """The environment's endpoint, else the published one, else None."""
69
+ url, token = os.environ.get("TRACEFORGE_APP_URL"), os.environ.get("TRACEFORGE_APP_TOKEN")
70
+ if url and token:
71
+ return Endpoint(url.rstrip("/"), token)
72
+ try:
73
+ row = json.loads(endpoint_path().read_text(encoding="utf-8"))
74
+ return Endpoint(
75
+ str(row["url"]).rstrip("/"),
76
+ str(row["token"]),
77
+ str(row.get("workspace") or ""),
78
+ int(row.get("pid") or 0),
79
+ )
80
+ except (OSError, ValueError, KeyError, TypeError):
81
+ return None
@@ -0,0 +1,203 @@
1
+ """Install and remove the KiCad plugins in the user's plugin folders.
2
+
3
+ KiCad 7 and 8 load action plugins (Python packages) from ``<data>/<version>/scripting/plugins``;
4
+ KiCad 9 and 10 load IPC API plugins (a folder with ``plugin.json``) from ``<data>/<version>/plugins``.
5
+ KiCad 9 still loads action plugins, so it gets both. The user data folder depends on the OS:
6
+
7
+ * Linux: ``$XDG_DATA_HOME/kicad`` (``~/.local/share/kicad``), or the Flatpak's data folder
8
+ * macOS: ``~/Documents/KiCad`` (the version is detected from ``~/Library/Preferences/kicad``)
9
+ * Windows: ``%APPDATA%\\kicad`` for action plugins, ``Documents\\KiCad`` for IPC plugins
10
+
11
+ The installed plugin copies the standard-library modules of this package next to it, because
12
+ KiCad's Python does not have Traceforge installed. A marker file lets ``uninstall`` remove only
13
+ what this command wrote.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import re
21
+ import shutil
22
+ import sys
23
+ from collections.abc import Mapping
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+
27
+ from . import PLUGIN_VERSION
28
+
29
+ HERE = Path(__file__).parent
30
+ ACTION_PACKAGE = "traceforge_kicad"
31
+ IPC_IDENTIFIER = "org.traceforge.kicad"
32
+ MARKER = ".traceforge-plugin"
33
+ CORE_MODULES = ("actions", "client", "context", "discovery", "overlay", "views")
34
+ KNOWN_VERSIONS = ("7.0", "8.0", "9.0", "10.0")
35
+ ACTION_VERSIONS = {7, 8, 9} # SWIG action plugins are loaded by KiCad 7, 8 and 9
36
+ IPC_VERSIONS = {9, 10} # the IPC API exists from KiCad 9
37
+
38
+
39
+ class PluginInstallError(RuntimeError):
40
+ pass
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Target:
45
+ kind: str # "action" | "ipc"
46
+ version: str # "8.0"
47
+ folder: Path # the plugins folder
48
+
49
+ @property
50
+ def path(self) -> Path:
51
+ return self.folder / (ACTION_PACKAGE if self.kind == "action" else IPC_IDENTIFIER)
52
+
53
+ def to_dict(self) -> dict[str, str]:
54
+ return {"kind": self.kind, "kicad": self.version, "path": str(self.path)}
55
+
56
+
57
+ def _major(version: str) -> int:
58
+ return int(version.split(".")[0])
59
+
60
+
61
+ def _norm_version(value: str) -> str:
62
+ match = re.fullmatch(r"(\d+)(?:\.(\d+))?", value.strip())
63
+ if not match:
64
+ raise PluginInstallError(f"not a KiCad version: {value!r} (use 8 or 8.0)")
65
+ return f"{match.group(1)}.{match.group(2) or '0'}"
66
+
67
+
68
+ def version_roots(
69
+ platform: str | None = None,
70
+ home: Path | None = None,
71
+ env: Mapping[str, str] | None = None,
72
+ ) -> list[tuple[Path, Path, Path]]:
73
+ """``(config root, action-plugin root, ipc-plugin root)`` per candidate location.
74
+
75
+ A KiCad version counts as installed when ``<config root>/<version>`` exists.
76
+ """
77
+ platform = platform or sys.platform
78
+ home = home or Path.home()
79
+ env = os.environ if env is None else env
80
+ if platform == "win32":
81
+ appdata = Path(env.get("APPDATA") or home / "AppData" / "Roaming")
82
+ documents = Path(env.get("USERPROFILE") or home) / "Documents" / "KiCad"
83
+ return [(appdata / "kicad", appdata / "kicad", documents)]
84
+ if platform == "darwin":
85
+ documents = home / "Documents" / "KiCad"
86
+ return [(home / "Library" / "Preferences" / "kicad", documents, documents)]
87
+ data = Path(env.get("XDG_DATA_HOME") or home / ".local" / "share") / "kicad"
88
+ flatpak = home / ".var" / "app" / "org.kicad.KiCad" / "data" / "kicad"
89
+ return [(data, data, data), (flatpak, flatpak, flatpak)]
90
+
91
+
92
+ def targets_for(version: str, root: tuple[Path, Path, Path]) -> list[Target]:
93
+ major = _major(version)
94
+ found = []
95
+ if major in ACTION_VERSIONS:
96
+ found.append(Target("action", version, root[1] / version / "scripting" / "plugins"))
97
+ if major in IPC_VERSIONS:
98
+ found.append(Target("ipc", version, root[2] / version / "plugins"))
99
+ return found
100
+
101
+
102
+ def detect_targets(
103
+ version: str | None = None,
104
+ *,
105
+ platform: str | None = None,
106
+ home: Path | None = None,
107
+ env: Mapping[str, str] | None = None,
108
+ ) -> list[Target]:
109
+ """Plugin folders of every installed KiCad (or of ``version``, even if KiCad is not found)."""
110
+ roots = version_roots(platform, home, env)
111
+ if version:
112
+ wanted = _norm_version(version)
113
+ if _major(wanted) not in ACTION_VERSIONS | IPC_VERSIONS:
114
+ raise PluginInstallError(f"KiCad {wanted} has no plugin support here (7 to 10)")
115
+ # Prefer the location that already exists; else the first (the OS's normal one).
116
+ root = next((r for r in roots if (r[0] / wanted).is_dir()), roots[0])
117
+ return targets_for(wanted, root)
118
+ out: list[Target] = []
119
+ for root in roots:
120
+ for found in KNOWN_VERSIONS:
121
+ if (root[0] / found).is_dir():
122
+ out += targets_for(found, root)
123
+ if not out:
124
+ raise PluginInstallError(
125
+ "no KiCad user folder found; start KiCad once, or pass --kicad-version 8 "
126
+ "(or --dest <plugins folder> --type action|ipc)"
127
+ )
128
+ return out
129
+
130
+
131
+ def custom_targets(dest: Path, kind: str) -> list[Target]:
132
+ if kind not in ("action", "ipc"):
133
+ raise PluginInstallError("with --dest, say --type action or --type ipc")
134
+ return [Target(kind, "custom", dest)]
135
+
136
+
137
+ # ----------------------------------------------------------------------- payload files
138
+
139
+
140
+ def _core_files() -> dict[str, str]:
141
+ return {f"{m}.py": (HERE / f"{m}.py").read_text(encoding="utf-8") for m in CORE_MODULES}
142
+
143
+
144
+ def action_files() -> dict[str, str]:
145
+ files = _core_files()
146
+ files["__init__.py"] = (HERE / "payload" / "action_plugin.py").read_text(encoding="utf-8")
147
+ return files
148
+
149
+
150
+ def ipc_files() -> dict[str, str]:
151
+ """Paths relative to the plugin folder."""
152
+ files = {f"{ACTION_PACKAGE}/{name}": text for name, text in _core_files().items()}
153
+ files[f"{ACTION_PACKAGE}/__init__.py"] = (
154
+ '"""Traceforge KiCad plugin modules (standard library only)."""\n'
155
+ )
156
+ for name, target in (
157
+ ("plugin.json", "plugin.json"),
158
+ ("requirements.txt", "requirements.txt"),
159
+ ("ipc_main.py", "main.py"),
160
+ ):
161
+ files[target] = (HERE / "payload" / name).read_text(encoding="utf-8")
162
+ return files
163
+
164
+
165
+ def _owned(path: Path) -> bool:
166
+ return (path / MARKER).is_file()
167
+
168
+
169
+ def install_one(target: Target, *, force: bool = False) -> Path:
170
+ path = target.path
171
+ if path.exists():
172
+ if not _owned(path) and not force:
173
+ raise PluginInstallError(
174
+ f"{path} exists and was not installed by traceforge; use --force to replace it"
175
+ )
176
+ shutil.rmtree(path)
177
+ files = action_files() if target.kind == "action" else ipc_files()
178
+ for name, text in files.items():
179
+ out = path / name
180
+ out.parent.mkdir(parents=True, exist_ok=True)
181
+ out.write_text(text, encoding="utf-8", newline="\n")
182
+ (path / MARKER).write_text(
183
+ json.dumps({"version": PLUGIN_VERSION, "kind": target.kind}), encoding="utf-8"
184
+ )
185
+ return path
186
+
187
+
188
+ def uninstall_one(target: Target) -> bool:
189
+ """Remove the plugin; False when it was not installed. Refuses folders it did not write."""
190
+ path = target.path
191
+ if not path.exists():
192
+ return False
193
+ if not _owned(path):
194
+ raise PluginInstallError(f"{path} was not installed by traceforge; not removing it")
195
+ shutil.rmtree(path)
196
+ return True
197
+
198
+
199
+ def status(targets: list[Target]) -> list[dict[str, object]]:
200
+ rows = []
201
+ for t in targets:
202
+ rows.append({**t.to_dict(), "installed": t.path.exists(), "ours": _owned(t.path)})
203
+ return rows
@@ -0,0 +1,60 @@
1
+ """The chat overlay's logic, without a toolkit: the plugins' dialogs are thin views over this.
2
+
3
+ An :class:`Overlay` holds the selected parts and the conversation shown in the dialog. Each
4
+ action or question goes to the app's chat tab, so the answer also appears in the app window and
5
+ the thread continues there. Standard library only.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ from . import actions
13
+ from .client import Answer, AppClient, PluginError
14
+ from .context import PartContext
15
+
16
+
17
+ @dataclass
18
+ class Turn:
19
+ role: str # "you" | "traceforge" | "error"
20
+ text: str
21
+
22
+
23
+ @dataclass
24
+ class Overlay:
25
+ parts: list[PartContext]
26
+ client: AppClient = field(default_factory=AppClient)
27
+ index: int = 0
28
+ turns: list[Turn] = field(default_factory=list)
29
+
30
+ @property
31
+ def part(self) -> PartContext | None:
32
+ return self.parts[self.index] if self.parts else None
33
+
34
+ def titles(self) -> list[str]:
35
+ return [f"{p.reference} {p.value}".strip() for p in self.parts]
36
+
37
+ def run(self, action_id: str, question: str = "") -> Answer:
38
+ """Ask the app; the turn list records what was shown. Never raises for app errors."""
39
+ part = self.part
40
+ try:
41
+ prompt = actions.build_prompt(action_id, part, question)
42
+ except actions.ActionError as exc:
43
+ self.turns.append(Turn("error", str(exc)))
44
+ return Answer("", error=str(exc))
45
+ label = actions.get(action_id).label
46
+ self.turns.append(Turn("you", f"{label}: {question}" if question.strip() else label))
47
+ try:
48
+ answer = self.client.ask(prompt, project=part.project_dir if part else "")
49
+ except PluginError as exc:
50
+ self.turns.append(Turn("error", str(exc)))
51
+ return Answer("", error=str(exc))
52
+ if answer.error:
53
+ self.turns.append(Turn("error", answer.error))
54
+ else:
55
+ self.turns.append(Turn("traceforge", answer.text or "(no answer)"))
56
+ return answer
57
+
58
+ def transcript(self) -> str:
59
+ labels = {"you": "You", "traceforge": "Traceforge", "error": "Error"}
60
+ return "\n\n".join(f"{labels[t.role]}: {t.text}" for t in self.turns)
@@ -0,0 +1,65 @@
1
+ """Traceforge action plugins for KiCad 7 and 8 (Tools > External Plugins).
2
+
3
+ Installed as ``traceforge_kicad/__init__.py`` by ``traceforge kicad-plugin install``. Each action
4
+ reads the selected footprint from the PCB editor, asks the running Traceforge app (start it with
5
+ ``traceforge app <project folder>``) and shows the answer in a dialog with a chat box. KiCad
6
+ imports this file inside its own Python; only the standard library is needed besides ``pcbnew``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import pcbnew
12
+
13
+ from . import actions, context, views
14
+ from .overlay import Overlay
15
+
16
+ CATEGORY = "Traceforge"
17
+
18
+
19
+ def run_action(action_id: str, view: object = None, board: object = None) -> str:
20
+ """Read the selection, open the overlay, return the conversation text."""
21
+ try:
22
+ parts = context.read_pcbnew_selection(pcbnew, board)
23
+ except context.SelectionError as exc:
24
+ return _notify(str(exc))
25
+ overlay = Overlay(parts)
26
+ return (view or views.choose_view("wx")).show(overlay, action_id)
27
+
28
+
29
+ def _notify(message: str) -> str:
30
+ try:
31
+ import wx
32
+
33
+ wx.MessageBox(message, "Traceforge")
34
+ except Exception: # noqa: BLE001 - no GUI toolkit: the text is returned to the caller
35
+ pass
36
+ return message
37
+
38
+
39
+ def _make(action: actions.Action) -> type:
40
+ class _Plugin(pcbnew.ActionPlugin):
41
+ action_id = action.id
42
+
43
+ def defaults(self) -> None:
44
+ self.name = f"Traceforge: {action.label}"
45
+ self.category = CATEGORY
46
+ self.description = f"{action.label} for the selected part, answered by Traceforge"
47
+ self.show_toolbar_button = False
48
+
49
+ def Run(self) -> None: # noqa: N802 - KiCad's name
50
+ run_action(self.action_id)
51
+
52
+ _Plugin.__name__ = f"Traceforge_{action.id}"
53
+ return _Plugin
54
+
55
+
56
+ PLUGINS = [_make(a) for a in actions.ACTIONS]
57
+
58
+
59
+ def register_all() -> None:
60
+ for cls in PLUGINS:
61
+ cls().register()
62
+
63
+
64
+ register_all()
65
+ __all__ = ["PLUGINS", "register_all", "run_action"]