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,145 @@
1
+ """Findings as SARIF 2.1.0, the format code-scanning and review bots read.
2
+
3
+ Feeds: the result of ``traceforge check`` (built-in findings plus KiCad ERC and DRC violations), the
4
+ JSON of ``traceforge validate`` (one entry per finding of each check), or a plain list of finding
5
+ dicts. A finding has no line number (boards are not text lines), so each result carries the file it is
6
+ about plus logical locations for the parts and nets it names.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
16
+ LEVELS = {"P0": "error", "P1": "warning", "P2": "note", "P3": "note"}
17
+ KICAD_LEVELS = {"error": "error", "warning": "warning", "exclusion": "none"}
18
+
19
+
20
+ def _norm_builtin(f: dict, source: str) -> dict[str, Any]:
21
+ return {
22
+ "rule": f.get("check") or f.get("id") or "traceforge.finding",
23
+ "level": LEVELS.get(str(f.get("severity")), "warning"),
24
+ "severity": f.get("severity", ""),
25
+ "message": f.get("message", ""),
26
+ "evidence": f.get("evidence", ""),
27
+ "action": f.get("action", ""),
28
+ "standard": f.get("rule", ""),
29
+ "refs": list(f.get("refs") or []),
30
+ "nets": list(f.get("nets") or []),
31
+ "source": source,
32
+ }
33
+
34
+
35
+ def normalize(data: dict | list) -> list[dict[str, Any]]:
36
+ """Any of the supported inputs as one flat list of finding records."""
37
+ if isinstance(data, list):
38
+ return [_norm_builtin(f, f.get("source", "")) for f in data if isinstance(f, dict)]
39
+ out: list[dict[str, Any]] = []
40
+ if "results" in data and isinstance(data["results"], list): # validate report
41
+ for r in data["results"]:
42
+ for f in r.get("findings") or []:
43
+ out.append(
44
+ _norm_builtin({**f, "check": f.get("id") or r.get("id")}, r.get("stage", ""))
45
+ )
46
+ return out
47
+ for section, body in data.items():
48
+ if not isinstance(body, dict):
49
+ continue
50
+ for f in (body.get("builtin") or {}).get("findings") or []:
51
+ out.append(_norm_builtin(f, section))
52
+ for key in ("kicad_erc", "kicad_drc"):
53
+ kicad = body.get(key)
54
+ if not isinstance(kicad, dict):
55
+ continue
56
+ tool = key.removeprefix("kicad_")
57
+ for v in kicad.get("violations") or []:
58
+ if v.get("excluded"):
59
+ continue
60
+ out.append(
61
+ {
62
+ "rule": f"kicad.{tool}.{v.get('type', 'violation')}",
63
+ "level": KICAD_LEVELS.get(str(v.get("severity")), "warning"),
64
+ "severity": str(v.get("severity", "")),
65
+ "message": v.get("description", ""),
66
+ "evidence": "; ".join(str(i) for i in v.get("items") or []),
67
+ "action": "",
68
+ "standard": "",
69
+ "refs": [],
70
+ "nets": [],
71
+ "source": f"{section}.{tool}",
72
+ }
73
+ )
74
+ return out
75
+
76
+
77
+ def _uri(source: str, files: dict[str, str]) -> str | None:
78
+ head = source.split(".")[0]
79
+ if head in ("erc", "layout", "schematic", "sch"):
80
+ return files.get("schematic")
81
+ if head in ("drc", "pcb"):
82
+ return files.get("pcb")
83
+ return files.get("pcb") or files.get("schematic")
84
+
85
+
86
+ def to_sarif(
87
+ data: dict | list,
88
+ files: dict[str, str | Path] | None = None,
89
+ version: str = "",
90
+ ) -> dict[str, Any]:
91
+ """The SARIF log. ``files`` maps ``schematic`` and ``pcb`` to repository-relative paths."""
92
+ paths = {k: Path(v).as_posix() for k, v in (files or {}).items() if v}
93
+ records = normalize(data)
94
+ rules: dict[str, dict[str, Any]] = {}
95
+ results = []
96
+ for r in records:
97
+ rid = r["rule"]
98
+ if rid not in rules:
99
+ rule: dict[str, Any] = {
100
+ "id": rid,
101
+ "shortDescription": {"text": rid},
102
+ "defaultConfiguration": {"level": r["level"]},
103
+ }
104
+ if r["standard"]:
105
+ rule["fullDescription"] = {"text": r["standard"]}
106
+ rules[rid] = rule
107
+ text = r["message"]
108
+ if r["evidence"]:
109
+ text += f"\nEvidence: {r['evidence']}"
110
+ if r["action"]:
111
+ text += f"\nFix: {r['action']}"
112
+ location: dict[str, Any] = {}
113
+ uri = _uri(r["source"], paths)
114
+ if uri:
115
+ location["physicalLocation"] = {"artifactLocation": {"uri": uri}}
116
+ logical = [{"name": n, "kind": "member"} for n in r["refs"]] + [
117
+ {"name": n, "kind": "variable"} for n in r["nets"]
118
+ ]
119
+ if logical:
120
+ location["logicalLocations"] = logical
121
+ fingerprint = hashlib.sha256(
122
+ "|".join([rid, r["message"], ",".join(r["refs"]), ",".join(r["nets"])]).encode()
123
+ ).hexdigest()[:32]
124
+ results.append(
125
+ {
126
+ "ruleId": rid,
127
+ "level": r["level"],
128
+ "message": {"text": text},
129
+ "locations": [location] if location else [],
130
+ "partialFingerprints": {"traceforge/v1": fingerprint},
131
+ "properties": {"severity": r["severity"], "source": r["source"]},
132
+ }
133
+ )
134
+ driver: dict[str, Any] = {
135
+ "name": "traceforge",
136
+ "informationUri": "https://github.com/ashutosh-rath02/traceforge",
137
+ "rules": list(rules.values()),
138
+ }
139
+ if version:
140
+ driver["version"] = version
141
+ return {
142
+ "$schema": SARIF_SCHEMA,
143
+ "version": "2.1.0",
144
+ "runs": [{"tool": {"driver": driver}, "results": results}],
145
+ }
@@ -0,0 +1,265 @@
1
+ """One self-contained HTML file to inspect a board without KiCad: layers, nets, BOM and findings.
2
+
3
+ The page has no external requests: the board is embedded as JSON and drawn on a canvas. Pan with the
4
+ mouse, zoom with the wheel, click a pad, track or via to select its net, click a BOM row or a finding to
5
+ highlight its parts. ``traceforge view`` writes it; the findings are optional.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections import defaultdict
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from traceforge.kicad.pcb import Board, read_board
16
+
17
+ COLORS = {
18
+ "F.Cu": "#c83434",
19
+ "B.Cu": "#4d7fc4",
20
+ "In1.Cu": "#c2c200",
21
+ "In2.Cu": "#c27d00",
22
+ "In3.Cu": "#00c2c2",
23
+ "In4.Cu": "#9a4dc4",
24
+ "In5.Cu": "#4dc47f",
25
+ "In6.Cu": "#c24d9a",
26
+ }
27
+
28
+
29
+ def _num(value: float) -> float:
30
+ return round(float(value), 3)
31
+
32
+
33
+ def _ref_key(ref: str) -> tuple[str, int]:
34
+ prefix = ref.rstrip("0123456789")
35
+ digits = ref[len(prefix) :]
36
+ return (prefix, int(digits) if digits.isdigit() else 0)
37
+
38
+
39
+ def board_data(board: Board, findings: list[dict] | None = None) -> dict[str, Any]:
40
+ """The board as plain JSON-able data for the page."""
41
+ outline = list(board.edge_segments or [])
42
+ if not outline and board.outline:
43
+ x0, y0, x1, y1 = board.outline
44
+ outline = [(x0, y0, x1, y0), (x1, y0, x1, y1), (x1, y1, x0, y1), (x0, y1, x0, y0)]
45
+ tracks: dict[str, list[list[Any]]] = defaultdict(list)
46
+ length: dict[str, float] = defaultdict(float)
47
+ for t in board.tracks:
48
+ if t.mid is not None:
49
+ mid = t.mid
50
+ pieces = [(t.x1, t.y1, mid[0], mid[1]), (mid[0], mid[1], t.x2, t.y2)]
51
+ else:
52
+ pieces = [(t.x1, t.y1, t.x2, t.y2)]
53
+ for a, b, c, d in pieces:
54
+ tracks[t.layer].append([_num(a), _num(b), _num(c), _num(d), _num(t.width), t.net])
55
+ length[t.net] += t.length
56
+ vias_per_net: dict[str, int] = defaultdict(int)
57
+ for v in board.vias:
58
+ vias_per_net[v.net] += 1
59
+ pads_per_net: dict[str, int] = defaultdict(int)
60
+ pads = []
61
+ parts = []
62
+ bom: dict[tuple[str, str], list[str]] = defaultdict(list)
63
+ for fp in board.footprints:
64
+ if fp.reference.startswith("#"):
65
+ continue
66
+ parts.append(
67
+ {
68
+ "ref": fp.reference,
69
+ "value": fp.value,
70
+ "lib": fp.lib_id,
71
+ "x": _num(fp.x),
72
+ "y": _num(fp.y),
73
+ "angle": _num(fp.angle),
74
+ "layer": fp.layer,
75
+ "dnp": fp.dnp,
76
+ "court": [_num(c) for c in fp.courtyard] if fp.courtyard else None,
77
+ }
78
+ )
79
+ if not ({"exclude_from_bom", "board_only"} & set(fp.attrs)):
80
+ bom[(fp.value, fp.lib_id.split(":")[-1])].append(fp.reference)
81
+ for pad in fp.pads:
82
+ pads_per_net[pad.net] += 1
83
+ pads.append(
84
+ [
85
+ _num(pad.x),
86
+ _num(pad.y),
87
+ _num(pad.width),
88
+ _num(pad.height),
89
+ pad.shape,
90
+ pad.layers,
91
+ pad.net,
92
+ fp.reference,
93
+ pad.number,
94
+ _num(pad.drill),
95
+ ]
96
+ )
97
+ nets = sorted(
98
+ {n for n in [*length, *vias_per_net, *pads_per_net] if n},
99
+ key=lambda n: (n.startswith("unconnected"), n),
100
+ )
101
+ bom_rows = [
102
+ {"value": v, "footprint": f, "qty": len(refs), "refs": sorted(refs, key=_ref_key)}
103
+ for (v, f), refs in sorted(bom.items(), key=lambda kv: _ref_key(kv[1][0]))
104
+ ]
105
+ size = board.size_mm
106
+ return {
107
+ "name": board.path.stem,
108
+ "layers": board.copper_layers,
109
+ "colors": {n: COLORS.get(n, "#888888") for n in board.copper_layers},
110
+ "outline": [[_num(v) for v in seg] for seg in outline],
111
+ "tracks": tracks,
112
+ "vias": [[_num(v.x), _num(v.y), _num(v.size), _num(v.drill), v.net] for v in board.vias],
113
+ "pads": pads,
114
+ "parts": parts,
115
+ "bom": bom_rows,
116
+ "nets": [
117
+ {
118
+ "name": n,
119
+ "length": round(length.get(n, 0.0), 2),
120
+ "vias": vias_per_net.get(n, 0),
121
+ "pads": pads_per_net.get(n, 0),
122
+ }
123
+ for n in nets
124
+ ],
125
+ "zones": [
126
+ {"net": z["net"], "layers": z["layers"], "filled": z["filled"]} for z in board.zones
127
+ ],
128
+ "size": [round(size[0], 2), round(size[1], 2)] if size else None,
129
+ "findings": findings or [],
130
+ "checked": findings is not None,
131
+ }
132
+
133
+
134
+ def findings_from_checks(data: dict | list) -> list[dict]:
135
+ """Flatten the ``traceforge check`` result (or a list of finding dicts) into one list."""
136
+ if isinstance(data, list):
137
+ return [dict(f) for f in data]
138
+ out: list[dict] = []
139
+ for section, body in data.items():
140
+ if not isinstance(body, dict):
141
+ continue
142
+ found = (body.get("builtin") or {}).get("findings") or body.get("findings") or []
143
+ for f in found:
144
+ if isinstance(f, dict):
145
+ out.append({**f, "source": section})
146
+ return out
147
+
148
+
149
+ def _escape(text: str) -> str:
150
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
151
+
152
+
153
+ def render_html(data: dict[str, Any]) -> str:
154
+ payload = json.dumps(data, separators=(",", ":")).replace("</", "<\\/")
155
+ return _PAGE.replace("__TITLE__", _escape(data["name"])).replace("__DATA__", payload)
156
+
157
+
158
+ def write_viewer(
159
+ pcb: str | Path, out: str | Path, findings: list[dict] | dict | None = None
160
+ ) -> Path:
161
+ """Write the viewer for ``pcb`` to ``out`` and return the path."""
162
+ board = read_board(Path(pcb))
163
+ data = board_data(board, findings_from_checks(findings) if findings is not None else None)
164
+ out = Path(out)
165
+ out.parent.mkdir(parents=True, exist_ok=True)
166
+ out.write_text(render_html(data), encoding="utf-8")
167
+ return out
168
+
169
+
170
+ _PAGE = """<!doctype html>
171
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
172
+ <title>__TITLE__ - board viewer</title>
173
+ <style>
174
+ :root{--bg:#10141a;--panel:#171d26;--ink:#dbe2ea;--mute:#8b97a6;--line:#2a3340;--acc:#5aa9ff}
175
+ @media (prefers-color-scheme: light){:root{--bg:#eef1f5;--panel:#fff;--ink:#1d2733;--mute:#5d6b7a;--line:#d5dbe3;--acc:#1668c9}}
176
+ *{box-sizing:border-box}body{margin:0;height:100vh;display:flex;background:var(--bg);color:var(--ink);font:13px system-ui,sans-serif}
177
+ #view{flex:1;position:relative;min-width:0}canvas{width:100%;height:100%;display:block;cursor:grab}
178
+ #side{width:340px;max-width:45vw;background:var(--panel);border-left:1px solid var(--line);display:flex;flex-direction:column}
179
+ #bar{padding:8px 10px;border-bottom:1px solid var(--line);display:flex;gap:10px;flex-wrap:wrap;align-items:center}
180
+ #bar label{display:inline-flex;align-items:center;gap:4px;cursor:pointer}
181
+ .sw{width:10px;height:10px;border-radius:2px;display:inline-block}
182
+ #tabs{display:flex;border-bottom:1px solid var(--line)}#tabs button{flex:1;padding:8px;border:0;background:none;color:var(--mute);cursor:pointer;font:inherit}
183
+ #tabs button.on{color:var(--ink);border-bottom:2px solid var(--acc)}
184
+ #list{flex:1;overflow:auto}#q{margin:8px;width:calc(100% - 16px);padding:6px;border:1px solid var(--line);background:var(--bg);color:var(--ink);border-radius:4px}
185
+ .row{padding:6px 10px;border-bottom:1px solid var(--line);cursor:pointer}.row:hover,.row.on{background:rgba(90,169,255,.14)}
186
+ .row small{color:var(--mute);display:block}.sev{font-weight:600}.P0{color:#e5484d}.P1{color:#f08c00}.P2{color:#c9a400}.P3{color:var(--mute)}
187
+ #info{position:absolute;left:10px;bottom:10px;background:var(--panel);border:1px solid var(--line);padding:6px 10px;border-radius:6px;max-width:60%}
188
+ @media (max-width:700px){body{flex-direction:column}#side{width:100%;max-width:none;height:45vh}}
189
+ </style></head><body>
190
+ <div id="view"><canvas id="c"></canvas><div id="info">Click a pad, track or via. Wheel zooms, drag pans.</div></div>
191
+ <div id="side"><div id="bar"></div>
192
+ <div id="tabs"><button data-t="bom" class="on">BOM</button><button data-t="nets">Nets</button><button data-t="find">Findings</button></div>
193
+ <input id="q" placeholder="filter"><div id="list"></div></div>
194
+ <script id="data" type="application/json">__DATA__</script>
195
+ <script>
196
+ const D=JSON.parse(document.getElementById('data').textContent);
197
+ const cv=document.getElementById('c'),ctx=cv.getContext('2d');
198
+ const vis={};D.layers.forEach(l=>vis[l]=true);vis.pads=true;vis.vias=true;vis.silk=true;
199
+ let flip=false,sel={net:null,refs:new Set()},tab='bom',view={s:1,x:0,y:0};
200
+ const xs=D.outline.flatMap(s=>[s[0],s[2]]),ys=D.outline.flatMap(s=>[s[1],s[3]]);
201
+ const bb=D.outline.length?[Math.min(...xs),Math.min(...ys),Math.max(...xs),Math.max(...ys)]:[0,0,100,80];
202
+ function fit(){const r=cv.getBoundingClientRect(),k=window.devicePixelRatio||1;cv.width=r.width*k;cv.height=r.height*k;
203
+ const w=bb[2]-bb[0]||1,h=bb[3]-bb[1]||1;view.s=Math.min(cv.width/(w*1.1),cv.height/(h*1.1));
204
+ view.x=(cv.width-w*view.s)/2-bb[0]*view.s;view.y=(cv.height-h*view.s)/2-bb[1]*view.s;draw()}
205
+ const P=(x,y)=>[(flip?(bb[0]+bb[2]-x):x)*view.s+view.x,y*view.s+view.y];
206
+ const W=(mm)=>Math.max(1,mm*view.s);
207
+ const dim=()=>sel.net||sel.refs.size;
208
+ function net(n){return sel.net&&n===sel.net}
209
+ function line(a,b,c,d,w,col){const p=P(a,b),q=P(c,d);ctx.strokeStyle=col;ctx.lineWidth=W(w);ctx.lineCap='round';ctx.beginPath();ctx.moveTo(p[0],p[1]);ctx.lineTo(q[0],q[1]);ctx.stroke()}
210
+ function draw(){ctx.clearRect(0,0,cv.width,cv.height);
211
+ ctx.globalAlpha=1;D.outline.forEach(s=>line(s[0],s[1],s[2],s[3],.15,'#d8c84a'));
212
+ const order=[...D.layers].reverse();if(flip)order.reverse();
213
+ order.forEach(l=>{if(!vis[l])return;const col=D.colors[l];(D.tracks[l]||[]).forEach(t=>{
214
+ ctx.globalAlpha=dim()&&!net(t[5])?.25:1;line(t[0],t[1],t[2],t[3],t[4],net(t[5])?'#fff':col)})});
215
+ ctx.globalAlpha=1;
216
+ if(vis.pads)D.pads.forEach(p=>{const top=p[5].some(n=>n==='F.Cu'||n==='*.Cu'||n==='F&B.Cu'),bot=p[5].some(n=>n==='B.Cu'||n==='*.Cu'||n==='F&B.Cu');
217
+ if(!((top&&vis['F.Cu'])||(bot&&vis['B.Cu'])||p[9]>0))return;
218
+ const hot=net(p[6])||sel.refs.has(p[7]);ctx.globalAlpha=dim()&&!hot?.3:1;
219
+ ctx.fillStyle=hot?'#fff':(top?D.colors['F.Cu']:D.colors['B.Cu'])||'#c83434';
220
+ const c=P(p[0],p[1]),w=W(p[2]),h=W(p[3]);ctx.beginPath();
221
+ if(p[4]==='circle'||p[4]==='oval'){ctx.ellipse(c[0],c[1],w/2,h/2,0,0,7);}else{ctx.rect(c[0]-w/2,c[1]-h/2,w,h)}
222
+ ctx.fill();if(p[9]>0){ctx.fillStyle='#10141a';ctx.beginPath();ctx.arc(c[0],c[1],W(p[9])/2,0,7);ctx.fill()}});
223
+ ctx.globalAlpha=1;
224
+ if(vis.vias)D.vias.forEach(v=>{const c=P(v[0],v[1]);ctx.globalAlpha=dim()&&!net(v[4])?.3:1;ctx.fillStyle=net(v[4])?'#fff':'#9aa5b1';
225
+ ctx.beginPath();ctx.arc(c[0],c[1],W(v[2])/2,0,7);ctx.fill();ctx.fillStyle='#10141a';ctx.beginPath();ctx.arc(c[0],c[1],W(v[3])/2,0,7);ctx.fill()});
226
+ ctx.globalAlpha=1;
227
+ D.parts.forEach(p=>{if(!sel.refs.has(p.ref)||!p.court)return;const a=P(p.court[0],p.court[1]),b=P(p.court[2],p.court[3]);
228
+ ctx.strokeStyle='#5aa9ff';ctx.lineWidth=2;ctx.strokeRect(Math.min(a[0],b[0]),Math.min(a[1],b[1]),Math.abs(b[0]-a[0]),Math.abs(b[1]-a[1]))});
229
+ if(vis.silk&&view.s>6)D.parts.forEach(p=>{const c=P(p.x,p.y);ctx.fillStyle='#e6e6e6';ctx.font=Math.max(8,view.s*.9)+'px sans-serif';ctx.textAlign='center';ctx.fillText(p.ref,c[0],c[1])})}
230
+ function toMM(ev){const r=cv.getBoundingClientRect(),k=cv.width/r.width;const px=(ev.clientX-r.left)*k,py=(ev.clientY-r.top)*k;
231
+ let x=(px-view.x)/view.s;if(flip)x=bb[0]+bb[2]-x;return[x,(py-view.y)/view.s]}
232
+ function pick(ev){const[x,y]=toMM(ev);const tol=6/view.s;
233
+ for(const p of D.pads){if(Math.abs(p[0]-x)<=p[2]/2+tol/3&&Math.abs(p[1]-y)<=p[3]/2+tol/3)return{net:p[6],ref:p[7],what:'pad '+p[7]+'.'+p[8]}}
234
+ for(const v of D.vias){if(Math.hypot(v[0]-x,v[1]-y)<=v[2]/2+tol/3)return{net:v[4],what:'via'}}
235
+ for(const l of D.layers){if(!vis[l])continue;for(const t of D.tracks[l]||[]){const dx=t[2]-t[0],dy=t[3]-t[1],L=dx*dx+dy*dy||1e-9;
236
+ let u=((x-t[0])*dx+(y-t[1])*dy)/L;u=Math.max(0,Math.min(1,u));if(Math.hypot(t[0]+u*dx-x,t[1]+u*dy-y)<=t[4]/2+tol/3)return{net:t[5],what:'track on '+l}}}return null}
237
+ function setSel(net,refs,text){sel.net=net||null;sel.refs=new Set(refs||[]);document.getElementById('info').textContent=text||'Click a pad, track or via.';draw();list()}
238
+ let drag=null,moved=false;
239
+ cv.onmousedown=e=>{drag=[e.clientX,e.clientY,view.x,view.y];moved=false;cv.style.cursor='grabbing'};
240
+ window.onmouseup=e=>{cv.style.cursor='grab';if(drag&&!moved){const h=pick(e);if(h){const n=D.nets.find(n=>n.name===h.net);
241
+ setSel(h.net,h.ref?[h.ref]:[],h.what+' - net '+(h.net||'(none)')+(n?' - '+n.length+' mm, '+n.vias+' vias, '+n.pads+' pads':''))}else setSel(null,[],'')}drag=null};
242
+ window.onmousemove=e=>{if(!drag)return;const k=cv.width/cv.getBoundingClientRect().width;
243
+ if(Math.abs(e.clientX-drag[0])+Math.abs(e.clientY-drag[1])>3)moved=true;view.x=drag[2]+(e.clientX-drag[0])*k;view.y=drag[3]+(e.clientY-drag[1])*k;draw()};
244
+ cv.onwheel=e=>{e.preventDefault();const r=cv.getBoundingClientRect(),k=cv.width/r.width,px=(e.clientX-r.left)*k,py=(e.clientY-r.top)*k,f=e.deltaY<0?1.15:1/1.15;
245
+ view.x=px-(px-view.x)*f;view.y=py-(py-view.y)*f;view.s*=f;draw()};
246
+ window.onresize=fit;
247
+ const bar=document.getElementById('bar');
248
+ function chk(name,key,col){const l=document.createElement('label');const i=document.createElement('input');i.type='checkbox';i.checked=true;
249
+ i.onchange=()=>{vis[key]=i.checked;draw()};const s=document.createElement('span');s.className='sw';s.style.background=col||'#999';
250
+ l.append(i,s,document.createTextNode(name));bar.append(l)}
251
+ D.layers.forEach(l=>chk(l,l,D.colors[l]));chk('pads','pads');chk('vias','vias');chk('labels','silk');
252
+ const fb=document.createElement('button');fb.textContent='flip';fb.onclick=()=>{flip=!flip;draw()};bar.append(fb);
253
+ document.querySelectorAll('#tabs button').forEach(b=>b.onclick=()=>{tab=b.dataset.t;document.querySelectorAll('#tabs button').forEach(x=>x.classList.toggle('on',x===b));list()});
254
+ document.getElementById('q').oninput=()=>list();
255
+ function list(){const q=document.getElementById('q').value.toLowerCase(),el=document.getElementById('list');el.textContent='';
256
+ const add=(title,sub,on,fn,cls)=>{const d=document.createElement('div');d.className='row'+(on?' on':'');const t=document.createElement('div');
257
+ t.textContent=title;if(cls)t.className=cls;d.append(t);if(sub){const s=document.createElement('small');s.textContent=sub;d.append(s)}d.onclick=fn;el.append(d)};
258
+ if(tab==='bom')D.bom.filter(r=>(r.value+r.footprint+r.refs.join(' ')).toLowerCase().includes(q)).forEach(r=>add(r.qty+' x '+(r.value||'(no value)'),r.footprint+' - '+r.refs.join(', '),r.refs.some(x=>sel.refs.has(x)),()=>setSel(null,r.refs,r.refs.join(', '))));
259
+ if(tab==='nets')D.nets.filter(n=>n.name.toLowerCase().includes(q)).forEach(n=>add(n.name,n.length+' mm, '+n.vias+' vias, '+n.pads+' pads',sel.net===n.name,()=>setSel(n.name,[],'net '+n.name)));
260
+ if(tab==='find'){if(!D.findings.length){add(D.checked?'No findings':'Checks were not run','Run: traceforge view --check',false,()=>{})}
261
+ D.findings.filter(f=>JSON.stringify(f).toLowerCase().includes(q)).forEach(f=>add((f.severity||'')+' '+(f.message||f.check||''),(f.check||'')+(f.action?' - '+f.action:''),false,
262
+ ()=>setSel((f.nets||[])[0],f.refs||[],f.message||''),'sev '+(f.severity||'')))}}
263
+ fit();list();
264
+ </script></body></html>
265
+ """
@@ -0,0 +1,3 @@
1
+ """Firmware that matches the hardware: pin maps, scaffolds, driver snippets, toolchains."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,174 @@
1
+ """``traceforge firmware pins|scaffold|driver|build|tools``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from rich.console import Console
11
+
12
+ from traceforge.firmware.drivers import generate_driver, supported_parts
13
+ from traceforge.firmware.netlist import NetlistError, load_netlist
14
+ from traceforge.firmware.pinmap import HEADER_FILES, build_pinmap, render_header
15
+ from traceforge.firmware.scaffold import TARGETS, ScaffoldError, scaffold
16
+ from traceforge.firmware.toolchain import (
17
+ INSTALL_GUIDANCE,
18
+ compile_project,
19
+ detect_tools,
20
+ flash_command,
21
+ flash_project,
22
+ size_project,
23
+ )
24
+
25
+ console = Console(highlight=False)
26
+ err = Console(stderr=True, highlight=False)
27
+
28
+
29
+ def add_firmware_parser(sub: argparse._SubParsersAction) -> None:
30
+ fw = sub.add_parser("firmware", help="pin map, scaffolds, drivers and builds for the board")
31
+ fsub = fw.add_subparsers(dest="firmware_command", required=True)
32
+
33
+ def design_args(p: argparse.ArgumentParser) -> None:
34
+ p.add_argument("design", help="design spec (.yaml), .kicad_sch or KiCad .net")
35
+ p.add_argument("--ref", help="MCU reference (auto-detected)")
36
+ p.add_argument("--mcu", help="attiny85 | esp32s3 | stm32f103 (auto-detected)")
37
+
38
+ pins = fsub.add_parser("pins", help="map netlist signals to MCU pins; write pin headers")
39
+ design_args(pins)
40
+ pins.add_argument("--style", choices=list(HEADER_FILES), help="header to write or print")
41
+ pins.add_argument("--out", help="write the header into this folder (default: print)")
42
+ pins.add_argument("--json", action="store_true")
43
+
44
+ sc = fsub.add_parser("scaffold", help="write a firmware project")
45
+ design_args(sc)
46
+ sc.add_argument("--target", choices=list(TARGETS))
47
+ sc.add_argument("--out", default="firmware")
48
+ sc.add_argument("--name")
49
+ sc.add_argument("--driver", action="append", default=[], help="part to include (repeatable)")
50
+ sc.add_argument("--overwrite", action="store_true")
51
+
52
+ dr = fsub.add_parser("driver", help="generate a driver for a common I2C/SPI part")
53
+ dr.add_argument("part", nargs="?", help=", ".join(supported_parts()))
54
+ dr.add_argument("--address", type=lambda v: int(v, 0), help="7-bit address override")
55
+ dr.add_argument("--out", help="write the files here (default: print)")
56
+
57
+ b = fsub.add_parser("build", help="compile, size or flash a firmware project")
58
+ b.add_argument("project", nargs="?", default=".")
59
+ b.add_argument("--size", action="store_true", help="report flash/RAM use only")
60
+ b.add_argument("--flash", action="store_true", help="flash after building")
61
+ b.add_argument("--port", help="serial port for flashing")
62
+ b.add_argument("-y", "--yes", action="store_true", help="approve flashing without asking")
63
+ b.add_argument("--json", action="store_true")
64
+
65
+ fsub.add_parser("tools", help="which firmware tools are installed, with install guidance")
66
+
67
+
68
+ def run_firmware(args: argparse.Namespace) -> int:
69
+ try:
70
+ return _COMMANDS[args.firmware_command](args)
71
+ except (NetlistError, ScaffoldError, KeyError, ValueError) as exc:
72
+ err.print(f"[red]error:[/] {exc.args[0] if isinstance(exc, KeyError) else exc}")
73
+ return 1
74
+
75
+
76
+ def _pins(args: argparse.Namespace) -> int:
77
+ pm = build_pinmap(load_netlist(args.design), args.ref, args.mcu)
78
+ if args.json:
79
+ print(json.dumps(pm.as_dict(), indent=1))
80
+ return 0
81
+ if args.style:
82
+ text = render_header(pm, args.style, args.design)
83
+ if args.out:
84
+ path = Path(args.out) / HEADER_FILES[args.style]
85
+ path.parent.mkdir(parents=True, exist_ok=True)
86
+ path.write_text(text, encoding="utf-8")
87
+ console.print(f"wrote {path}")
88
+ else:
89
+ sys.stdout.write(text)
90
+ return 0
91
+ console.print(f"{pm.mcu.name} ({pm.ref}) pin data: {pm.mcu.source}")
92
+ for a in pm.assignments:
93
+ console.print(f" {a.net:<14} {a.pin_name:<5} pin {a.pin:<4} {a.function:<9} {a.direction}")
94
+ for w in pm.warnings:
95
+ console.print(f"[yellow]warning:[/] {w}")
96
+ return 0
97
+
98
+
99
+ def _scaffold(args: argparse.Namespace) -> int:
100
+ pm = build_pinmap(load_netlist(args.design), args.ref, args.mcu)
101
+ files = scaffold(pm, args.out, args.target, args.name, args.driver, args.design, args.overwrite)
102
+ for f in files:
103
+ console.print(f"wrote {f}")
104
+ for w in pm.warnings:
105
+ console.print(f"[yellow]warning:[/] {w}")
106
+ return 0
107
+
108
+
109
+ def _driver(args: argparse.Namespace) -> int:
110
+ if not args.part:
111
+ console.print("\n".join(supported_parts()))
112
+ return 0
113
+ files = generate_driver(args.part, args.address)
114
+ if args.out:
115
+ out = Path(args.out)
116
+ out.mkdir(parents=True, exist_ok=True)
117
+ for name, text in files.items():
118
+ (out / name).write_text(text, encoding="utf-8")
119
+ console.print(f"wrote {out / name}")
120
+ return 0
121
+ for name, text in files.items():
122
+ sys.stdout.write(f"/* ===== {name} ===== */\n{text}\n")
123
+ return 0
124
+
125
+
126
+ def _build(args: argparse.Namespace) -> int:
127
+ result = size_project(args.project) if args.size else compile_project(args.project)
128
+ if args.json:
129
+ print(json.dumps(result.as_dict(), indent=1))
130
+ else:
131
+ console.print(f"{result.tool}: {' '.join(result.command)}")
132
+ for d in result.errors:
133
+ console.print(f"[red]{d.severity}[/] {d.file}:{d.line}: {d.message}")
134
+ if result.note:
135
+ console.print(result.note)
136
+ if result.size:
137
+ console.print("size: " + ", ".join(f"{k}={v}" for k, v in result.size.items()))
138
+ console.print("[green]ok[/]" if result.ok else "[red]failed[/]")
139
+ if not result.ok and not result.errors and result.output:
140
+ console.print(result.output[-1500:], markup=False)
141
+ if not result.ok:
142
+ return 1
143
+ if args.flash:
144
+ cmd = flash_command(args.project, args.port)
145
+ console.print("flash command: " + " ".join(cmd))
146
+ if not args.yes:
147
+ answer = input("run it now? [y/N] ").strip().lower() if sys.stdin.isatty() else ""
148
+ if answer != "y":
149
+ console.print("not flashed (pass -y to approve)")
150
+ return 0
151
+ flashed = flash_project(args.project, args.port, approved=True)
152
+ console.print(
153
+ flashed.note or ("flashed" if flashed.ok else flashed.output[-1500:]), markup=False
154
+ )
155
+ return 0 if flashed.ok else 1
156
+ return 0
157
+
158
+
159
+ def _tools(args: argparse.Namespace) -> int:
160
+ for t in detect_tools():
161
+ if t.found:
162
+ console.print(f"[green]found[/] {t.name:<18} {t.path} {t.version}")
163
+ else:
164
+ console.print(f"[yellow]missing[/] {t.name:<18} {INSTALL_GUIDANCE[t.name]}")
165
+ return 0
166
+
167
+
168
+ _COMMANDS = {
169
+ "pins": _pins,
170
+ "scaffold": _scaffold,
171
+ "driver": _driver,
172
+ "build": _build,
173
+ "tools": _tools,
174
+ }