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.
- traceforge/__init__.py +3 -0
- traceforge/__main__.py +3 -0
- traceforge/acp_server.py +570 -0
- traceforge/agent.py +680 -0
- traceforge/altium/__init__.py +6 -0
- traceforge/altium/cfb.py +70 -0
- traceforge/altium/cli.py +60 -0
- traceforge/altium/model.py +163 -0
- traceforge/altium/outputs.py +24 -0
- traceforge/altium/pcb.py +301 -0
- traceforge/altium/project.py +328 -0
- traceforge/altium/schematic.py +400 -0
- traceforge/altium/tools.py +166 -0
- traceforge/approval.py +219 -0
- traceforge/attachments.py +346 -0
- traceforge/background.py +253 -0
- traceforge/bench/__init__.py +5 -0
- traceforge/bench/adapters.py +337 -0
- traceforge/bench/checks.py +410 -0
- traceforge/bench/cli.py +121 -0
- traceforge/bench/design.py +439 -0
- traceforge/bench/kicad.py +99 -0
- traceforge/bench/partscheck.py +159 -0
- traceforge/bench/review.py +100 -0
- traceforge/bench/runner.py +226 -0
- traceforge/bench/score.py +204 -0
- traceforge/bench/tasks.py +102 -0
- traceforge/chat.py +1185 -0
- traceforge/checks.py +328 -0
- traceforge/checks_sides.py +118 -0
- traceforge/cli.py +2224 -0
- traceforge/clipboard.py +107 -0
- traceforge/config.py +299 -0
- traceforge/context.py +276 -0
- traceforge/design/__init__.py +1 -0
- traceforge/design/constraints.py +397 -0
- traceforge/design/generate.py +1789 -0
- traceforge/design/outline.py +905 -0
- traceforge/design/schematic_layout.py +932 -0
- traceforge/design/schematic_lint.py +329 -0
- traceforge/design/schematic_route.py +207 -0
- traceforge/design/sheets.py +854 -0
- traceforge/design/sides.py +328 -0
- traceforge/design/spec.py +462 -0
- traceforge/design/specsheet.py +2351 -0
- traceforge/design/specsheet_render.py +183 -0
- traceforge/desktop/__init__.py +109 -0
- traceforge/desktop/keys.py +90 -0
- traceforge/desktop/project.py +443 -0
- traceforge/desktop/review.py +20 -0
- traceforge/desktop/server.py +1277 -0
- traceforge/desktop/static/app.css +223 -0
- traceforge/desktop/static/app.js +683 -0
- traceforge/desktop/static/highlight.js +77 -0
- traceforge/desktop/static/index.html +89 -0
- traceforge/desktop/static/md.js +90 -0
- traceforge/desktop/static/viewer.js +213 -0
- traceforge/desktop/symbols.py +212 -0
- traceforge/desktop/update.py +65 -0
- traceforge/diagnostics.py +67 -0
- traceforge/errata.py +219 -0
- traceforge/fab.py +108 -0
- traceforge/fab_layers.py +44 -0
- traceforge/features/__init__.py +6 -0
- traceforge/features/candidates.py +117 -0
- traceforge/features/cli.py +200 -0
- traceforge/features/package.py +433 -0
- traceforge/features/revdiff.py +224 -0
- traceforge/features/sarif.py +145 -0
- traceforge/features/viewer.py +265 -0
- traceforge/firmware/__init__.py +3 -0
- traceforge/firmware/cli.py +174 -0
- traceforge/firmware/drivers.py +372 -0
- traceforge/firmware/drivers_data.py +256 -0
- traceforge/firmware/mcus.py +177 -0
- traceforge/firmware/netlist.py +55 -0
- traceforge/firmware/pinmap.py +296 -0
- traceforge/firmware/scaffold.py +466 -0
- traceforge/firmware/toolchain.py +262 -0
- traceforge/hooks.py +237 -0
- traceforge/kicad/__init__.py +0 -0
- traceforge/kicad/cli.py +519 -0
- traceforge/kicad/diffpair.py +174 -0
- traceforge/kicad/edgegeom.py +191 -0
- traceforge/kicad/edit.py +405 -0
- traceforge/kicad/escape.py +1410 -0
- traceforge/kicad/formats.py +1073 -0
- traceforge/kicad/impedance.py +320 -0
- traceforge/kicad/install.py +75 -0
- traceforge/kicad/libraries.py +490 -0
- traceforge/kicad/models3d.py +942 -0
- traceforge/kicad/outputs.py +81 -0
- traceforge/kicad/pcb.py +787 -0
- traceforge/kicad/planes.py +1242 -0
- traceforge/kicad/project.py +136 -0
- traceforge/kicad/route.py +2698 -0
- traceforge/kicad/routegeom.py +164 -0
- traceforge/kicad/schematic.py +268 -0
- traceforge/kicad/sides.py +34 -0
- traceforge/kicad/stackup.py +424 -0
- traceforge/kicad_plugin/__init__.py +12 -0
- traceforge/kicad_plugin/actions.py +92 -0
- traceforge/kicad_plugin/cli.py +66 -0
- traceforge/kicad_plugin/client.py +86 -0
- traceforge/kicad_plugin/context.py +257 -0
- traceforge/kicad_plugin/discovery.py +81 -0
- traceforge/kicad_plugin/install.py +203 -0
- traceforge/kicad_plugin/overlay.py +60 -0
- traceforge/kicad_plugin/payload/action_plugin.py +65 -0
- traceforge/kicad_plugin/payload/ipc_main.py +42 -0
- traceforge/kicad_plugin/payload/plugin.json +90 -0
- traceforge/kicad_plugin/payload/requirements.txt +1 -0
- traceforge/kicad_plugin/views.py +202 -0
- traceforge/langchain.py +57 -0
- traceforge/local.py +580 -0
- traceforge/mcp_server.py +304 -0
- traceforge/models.py +310 -0
- traceforge/next_steps.py +110 -0
- traceforge/parts/__init__.py +52 -0
- traceforge/parts/alternates.py +488 -0
- traceforge/parts/bom.py +423 -0
- traceforge/parts/brief.py +705 -0
- traceforge/parts/catalog.py +297 -0
- traceforge/parts/cli_sourcing.py +156 -0
- traceforge/parts/data/duty_rates.yaml +37 -0
- traceforge/parts/data/hs_codes.yaml +103 -0
- traceforge/parts/datasheet.py +278 -0
- traceforge/parts/fx.py +100 -0
- traceforge/parts/model.py +185 -0
- traceforge/parts/net.py +187 -0
- traceforge/parts/offers.py +46 -0
- traceforge/parts/packages.py +397 -0
- traceforge/parts/pinout.py +714 -0
- traceforge/parts/providers/__init__.py +17 -0
- traceforge/parts/providers/base.py +100 -0
- traceforge/parts/providers/jlc.py +364 -0
- traceforge/parts/providers/keyed.py +661 -0
- traceforge/parts/report.py +508 -0
- traceforge/parts/supply.py +330 -0
- traceforge/parts/verify.py +455 -0
- traceforge/parts/web.py +575 -0
- traceforge/plans.py +70 -0
- traceforge/preferences.py +127 -0
- traceforge/prices.py +130 -0
- traceforge/prices.yaml +28 -0
- traceforge/prompts/__init__.py +54 -0
- traceforge/prompts/system.md +103 -0
- traceforge/providers.py +764 -0
- traceforge/remote/__init__.py +33 -0
- traceforge/remote/client.py +422 -0
- traceforge/remote/oidc.py +240 -0
- traceforge/remote/orgs.py +510 -0
- traceforge/remote/server.py +690 -0
- traceforge/remote/store.py +100 -0
- traceforge/review_changes.py +187 -0
- traceforge/routing.py +99 -0
- traceforge/runtime.py +435 -0
- traceforge/sessions.py +324 -0
- traceforge/sexpr.py +279 -0
- traceforge/sim/__init__.py +17 -0
- traceforge/sim/acmath.py +234 -0
- traceforge/sim/circuit.py +499 -0
- traceforge/sim/cli.py +162 -0
- traceforge/sim/engine.py +172 -0
- traceforge/sim/ltspice.py +175 -0
- traceforge/sim/measure.py +123 -0
- traceforge/sim/models.py +76 -0
- traceforge/sim/project.py +218 -0
- traceforge/sim/rawfile.py +123 -0
- traceforge/sim/results.py +298 -0
- traceforge/sim/tune.py +172 -0
- traceforge/skill_commands.py +165 -0
- traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
- traceforge/skill_library/compliance-review/SKILL.md +51 -0
- traceforge/skill_library/design-review/SKILL.md +47 -0
- traceforge/skill_library/errata-hunt/SKILL.md +30 -0
- traceforge/skill_library/firmware/SKILL.md +26 -0
- traceforge/skill_library/power-budget/SKILL.md +27 -0
- traceforge/skill_library/release-package/SKILL.md +29 -0
- traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
- traceforge/skill_library/thermal-review/SKILL.md +15 -0
- traceforge/skills.py +131 -0
- traceforge/thermal.py +557 -0
- traceforge/tools/__init__.py +48 -0
- traceforge/tools/agent_tools.py +213 -0
- traceforge/tools/base.py +303 -0
- traceforge/tools/batch.py +155 -0
- traceforge/tools/connections.py +245 -0
- traceforge/tools/files.py +777 -0
- traceforge/tools/firmware_tools.py +218 -0
- traceforge/tools/kicad_tools.py +1072 -0
- traceforge/tools/models3d_tools.py +141 -0
- traceforge/tools/parts_tools.py +473 -0
- traceforge/tools/processes.py +246 -0
- traceforge/tools/review_tools.py +178 -0
- traceforge/tools/sim_tools.py +230 -0
- traceforge/tui.py +620 -0
- traceforge/updates.py +124 -0
- traceforge/validate/__init__.py +77 -0
- traceforge/validate/builtins.py +1091 -0
- traceforge/validate/circuits.py +1240 -0
- traceforge/validate/config.py +410 -0
- traceforge/validate/default.yaml +113 -0
- traceforge/validate/design.py +191 -0
- traceforge/validate/explain.py +112 -0
- traceforge/validate/grounding.py +169 -0
- traceforge/validate/ipc2221.py +350 -0
- traceforge/validate/multilayer.py +805 -0
- traceforge/validate/netmodel.py +261 -0
- traceforge/validate/partcheck.py +472 -0
- traceforge/validate/report.py +280 -0
- traceforge/validate/review.py +322 -0
- traceforge/validate/runner.py +662 -0
- traceforge/workspace.py +171 -0
- traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
- traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
- traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
- traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
- 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"]
|