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,155 @@
1
+ """Run the tool calls of one model step concurrently where that is safe.
2
+
3
+ A model step can ask for several tools at once. Each call gets a scheduling class:
4
+
5
+ * ``read``: changes nothing (file reads, searches, KiCad queries, read-only shell
6
+ commands). Reads run concurrently, at most ``MAX_CONCURRENT_READS`` at a time.
7
+ * ``write``: changes one known file. It waits for every earlier call that touches the
8
+ same file (or an unknown set of files), so edits apply in the order the model gave.
9
+ * ``exclusive``: anything else (other shell commands, questions to the user, unknown
10
+ tools, writes whose target is unknown). It waits for every earlier call, and every
11
+ later call waits for it.
12
+
13
+ Results are returned in the order of the calls, whatever order they finish in.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import os
20
+ import shlex
21
+ from collections.abc import Awaitable, Callable, Sequence
22
+ from dataclasses import dataclass
23
+ from typing import Any, TypeVar
24
+
25
+ MAX_CONCURRENT_READS = 8
26
+
27
+ READ = "read"
28
+ WRITE = "write"
29
+ EXCLUSIVE = "exclusive"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class CallPlan:
34
+ kind: str # read | write | exclusive
35
+ # Normalised file the call reads or writes; None means "not scoped to one file".
36
+ path: str | None = None
37
+
38
+
39
+ def path_key(raw: Any, workspace: str | None = None) -> str | None:
40
+ if not isinstance(raw, str) or not raw.strip():
41
+ return None
42
+ path = raw.strip().replace("\\", "/")
43
+ if not os.path.isabs(path) and workspace:
44
+ path = os.path.join(workspace, path)
45
+ return os.path.normpath(path)
46
+
47
+
48
+ _READ_PROGRAMS = {
49
+ "ls",
50
+ "cat",
51
+ "head",
52
+ "wc",
53
+ "grep",
54
+ "rg",
55
+ "find",
56
+ "pwd",
57
+ "stat",
58
+ "file",
59
+ "du",
60
+ "diff",
61
+ "which",
62
+ }
63
+ _FIND_ACTIONS = {"-exec", "-execdir", "-ok", "-okdir", "-delete", "-fprint", "-fprintf", "-fls"}
64
+
65
+
66
+ def read_only_command(command: Any) -> bool:
67
+ """Conservatively decide that a shell command only reads."""
68
+ if not isinstance(command, str) or not command.strip():
69
+ return False
70
+ if any(token in command for token in ("\n", ";", "&", "|", "`", "<", ">", "$(", "${")):
71
+ return False
72
+ try:
73
+ words = shlex.split(command)
74
+ except ValueError:
75
+ return False
76
+ if not words or os.path.basename(words[0]) not in _READ_PROGRAMS:
77
+ return False
78
+ program, rest = os.path.basename(words[0]), words[1:]
79
+ if program == "find":
80
+ return not any(word in _FIND_ACTIONS for word in rest)
81
+ if program == "rg":
82
+ return not any(word.startswith("--pre") for word in rest)
83
+ return True
84
+
85
+
86
+ def _overlaps(left: CallPlan, right: CallPlan) -> bool:
87
+ if WRITE not in (left.kind, right.kind):
88
+ return False
89
+ if left.path is None or right.path is None:
90
+ return True
91
+ return left.path == right.path
92
+
93
+
94
+ def dependencies(plans: Sequence[CallPlan]) -> list[set[int]]:
95
+ """For each call, the indexes of earlier calls it has to wait for."""
96
+ waits: list[set[int]] = []
97
+ last_exclusive: int | None = None
98
+ for index, plan in enumerate(plans):
99
+ mine: set[int] = set()
100
+ if plan.kind == EXCLUSIVE:
101
+ mine.update(range(index))
102
+ last_exclusive = index
103
+ else:
104
+ if last_exclusive is not None:
105
+ mine.add(last_exclusive)
106
+ mine.update(i for i in range(index) if _overlaps(plans[i], plan))
107
+ waits.append(mine)
108
+ return waits
109
+
110
+
111
+ T = TypeVar("T")
112
+ R = TypeVar("R")
113
+
114
+
115
+ async def run_batch(
116
+ calls: Sequence[T],
117
+ plans: Sequence[CallPlan],
118
+ handler: Callable[[T], Awaitable[R]],
119
+ *,
120
+ max_concurrent_reads: int = MAX_CONCURRENT_READS,
121
+ ) -> list[R]:
122
+ """Run ``handler`` on every call under the rules above; results in call order.
123
+
124
+ ``handler`` must not raise for an ordinary tool failure (return an error result
125
+ instead). If it does raise, the remaining calls are cancelled and the error
126
+ propagates. Cancelling the batch cancels every call still running.
127
+ """
128
+ if len(calls) != len(plans):
129
+ raise ValueError("one plan per call")
130
+ waits = dependencies(plans)
131
+ finished = [asyncio.Event() for _ in calls]
132
+ reads = asyncio.Semaphore(max(1, max_concurrent_reads))
133
+ results: list[Any] = [None] * len(calls)
134
+
135
+ async def one(index: int) -> None:
136
+ try:
137
+ for earlier in sorted(waits[index]):
138
+ await finished[earlier].wait()
139
+ if plans[index].kind == READ:
140
+ async with reads:
141
+ results[index] = await handler(calls[index])
142
+ else:
143
+ results[index] = await handler(calls[index])
144
+ finally:
145
+ finished[index].set()
146
+
147
+ tasks = [asyncio.create_task(one(index)) for index in range(len(calls))]
148
+ try:
149
+ await asyncio.gather(*tasks)
150
+ finally:
151
+ for task in tasks:
152
+ if not task.done():
153
+ task.cancel()
154
+ await asyncio.gather(*tasks, return_exceptions=True)
155
+ return results
@@ -0,0 +1,245 @@
1
+ """``kicad_connections``: read-only connectivity questions about the design.
2
+
3
+ * ``pin`` (``U1.3`` or ``U1.VCC``): the pin's net and every other pin on it, with
4
+ pin names and electrical types.
5
+ * ``part`` (``U1``): each pin of the part and its net.
6
+ * ``net``: every pin on a net.
7
+ * ``from`` + ``to`` (pins or parts): shortest paths between them, walking from net
8
+ to net through two-terminal passives (R, C, L, ferrite, fuse, diode, jumper) but
9
+ not through ICs, connectors, ground or supply rails (which would connect everything).
10
+
11
+ Connectivity comes from the schematic netlist (kicad-cli) when available, else from
12
+ the board's pad nets; pin names from the schematic symbols, else the pads' functions.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections import deque
18
+ from typing import Any
19
+
20
+ from traceforge.tools.base import Tool, ToolContext, ToolError
21
+
22
+ _PASSIVE = {"R", "C", "L", "FB", "F", "D", "JP", "SJ", "RN"}
23
+ _MAX_NODES = 60
24
+
25
+
26
+ def _model(args: dict[str, Any], ctx: ToolContext):
27
+ from traceforge.kicad.cli import KicadCli, KicadCliError
28
+ from traceforge.kicad.pcb import read_board
29
+ from traceforge.kicad.project import discover
30
+ from traceforge.kicad.schematic import read_schematic
31
+ from traceforge.validate.netmodel import build_model
32
+
33
+ ws = discover(ctx.workspace)
34
+ sch_path = ctx.resolve(args["schematic"], must_exist=True) if args.get("schematic") else None
35
+ pcb_path = ctx.resolve(args["pcb"], must_exist=True) if args.get("pcb") else None
36
+ sch_path, pcb_path = sch_path or ws.schematic, pcb_path or ws.pcb
37
+ if sch_path is None and pcb_path is None:
38
+ if ws.others.get("altium"): # Altium project: use the converted model
39
+ from traceforge.altium import AltiumError, load_project
40
+ from traceforge.altium.model import to_design_model
41
+
42
+ try:
43
+ return to_design_model(load_project(ctx.workspace))
44
+ except AltiumError as exc:
45
+ raise ToolError(str(exc)) from exc
46
+ raise ToolError("no .kicad_sch or .kicad_pcb in the workspace")
47
+ source = args.get("source") or "auto"
48
+ schematic = read_schematic(sch_path) if sch_path else None
49
+ board = read_board(pcb_path) if pcb_path else None
50
+ netlist = None
51
+ if schematic is not None and source in {"auto", "schematic"}:
52
+ cli = KicadCli()
53
+ if cli.available:
54
+ try:
55
+ netlist = cli.netlist(sch_path, ctx.workspace / ".traceforge" / "reports")
56
+ except KicadCliError:
57
+ netlist = None
58
+ if netlist is None and (source == "schematic" or board is None):
59
+ raise ToolError("schematic connectivity needs kicad-cli; pass source=board")
60
+ if source == "board" and board is None:
61
+ raise ToolError("no board found")
62
+ return build_model(schematic, board, netlist)
63
+
64
+
65
+ def _pin_info(model, ref: str, pin: str) -> dict[str, Any]:
66
+ part = model.parts.get(ref)
67
+ info: dict[str, Any] = {"pin": f"{ref}.{pin}"}
68
+ definition = part.pin_defs.get(pin) if part else None
69
+ name = definition.name if definition else ""
70
+ if not name:
71
+ name = next((p.function for p in model.pads(ref, pin) if p.function), "")
72
+ if name and name not in {pin, "~"}:
73
+ info["name"] = name.replace("~{", "").replace("}", "")
74
+ if definition and definition.type:
75
+ info["type"] = definition.type
76
+ if part is not None and part.value:
77
+ info["value"] = part.value
78
+ return info
79
+
80
+
81
+ def _resolve_pin(model, text: str) -> tuple[str, str]:
82
+ ref, sep, pin = text.strip().replace(":", ".").partition(".")
83
+ if not sep or not pin:
84
+ raise ToolError(f"{text!r}: give a pin as REF.PIN, e.g. U1.3 or U1.VCC")
85
+ if ref not in model.parts and not model.part_nets(ref):
86
+ raise ToolError(f"no part {ref}")
87
+ nets = model.part_nets(ref)
88
+ part = model.parts.get(ref)
89
+ if pin in nets or (part and pin in part.pin_defs):
90
+ return ref, pin
91
+ wanted = pin.upper()
92
+ matches = []
93
+ for number in {*nets, *(part.pin_defs if part else {})}:
94
+ name = _pin_info(model, ref, number).get("name", "")
95
+ if name.upper() == wanted or wanted in name.upper().split("/"):
96
+ matches.append(number)
97
+ if len(matches) == 1:
98
+ return ref, matches[0]
99
+ if matches:
100
+ raise ToolError(f"{text}: several pins match ({', '.join(sorted(matches))}); use a number")
101
+ raise ToolError(f"{ref} has no pin {pin}; pins: {', '.join(sorted(nets)) or 'none connected'}")
102
+
103
+
104
+ def _is_rail(net: str) -> bool:
105
+ from traceforge.validate.circuits import is_ground, rail_voltage
106
+
107
+ return is_ground(net) or rail_voltage(net) is not None
108
+
109
+
110
+ def _net_nodes(model, net: str, exclude: tuple[str, str] | None = None) -> list[dict]:
111
+ nodes = [_pin_info(model, r, p) for r, p in model.nodes(net) if (r, p) != exclude]
112
+ return nodes[:_MAX_NODES]
113
+
114
+
115
+ def _find_net(model, name: str) -> str:
116
+ wanted = name.strip().lstrip("/").upper()
117
+ for net in model.nets:
118
+ if net.lstrip("/").upper() == wanted:
119
+ return net
120
+ close = [n for n in model.nets if wanted in n.upper()][:10]
121
+ raise ToolError(f"no net {name}" + (f"; similar: {', '.join(close)}" if close else ""))
122
+
123
+
124
+ def _endpoints(model, text: str) -> tuple[set[str], set[str]]:
125
+ """(start nets, owning refs) for a pin, a whole part or a net."""
126
+ if "." in text or ":" in text:
127
+ ref, pin = _resolve_pin(model, text)
128
+ net = model.net_of(ref, pin)
129
+ return ({net} if net else set()), {ref}
130
+ nets = set(model.part_nets(text).values())
131
+ if not nets:
132
+ return {_find_net(model, text)}, set()
133
+ signal = {n for n in nets if not _is_rail(n)}
134
+ return (signal or nets), {text} # a shared GND/supply says nothing about a path
135
+
136
+
137
+ def _paths(model, src: str, dst: str, max_hops: int) -> list[list[str]]:
138
+ starts, src_refs = _endpoints(model, src)
139
+ targets, dst_refs = _endpoints(model, dst)
140
+ if starts & targets:
141
+ net = sorted(starts & targets)[0]
142
+ return [[src, f"net {net}", dst]]
143
+ # BFS over nets; a step crosses one two-terminal passive to its other net.
144
+ queue = deque((net, [f"net {net}"]) for net in sorted(starts))
145
+ seen = set(starts)
146
+ found: list[list[str]] = []
147
+ while queue and len(found) < 3:
148
+ net, path = queue.popleft()
149
+ if len(path) // 2 >= max_hops:
150
+ continue
151
+ for ref, pin in model.nodes(net):
152
+ part = model.parts.get(ref)
153
+ if ref in src_refs or ref in dst_refs or part is None or part.prefix not in _PASSIVE:
154
+ continue
155
+ pins = model.part_nets(ref)
156
+ if len(pins) != 2:
157
+ continue
158
+ for other_pin, other in pins.items():
159
+ if other_pin == pin or other in seen:
160
+ continue
161
+ step = [*path, f"{ref} ({part.value}) pin {pin}->{other_pin}", f"net {other}"]
162
+ if other in targets:
163
+ found.append([src, *step, dst])
164
+ continue
165
+ if _is_rail(other):
166
+ continue # do not wander through GND/supply rails
167
+ seen.add(other)
168
+ queue.append((other, step))
169
+ return found
170
+
171
+
172
+ def kicad_connections(args: dict[str, Any], ctx: ToolContext) -> dict:
173
+ model = _model(args, ctx)
174
+ out: dict[str, Any] = {"source": model.source}
175
+ asked = False
176
+ if args.get("pin"):
177
+ asked = True
178
+ ref, pin = _resolve_pin(model, str(args["pin"]))
179
+ net = model.net_of(ref, pin)
180
+ out["pin"] = _pin_info(model, ref, pin)
181
+ out["net"] = net or "(unconnected)"
182
+ if net:
183
+ out["connected_to"] = _net_nodes(model, net, (ref, pin))
184
+ if args.get("part"):
185
+ asked = True
186
+ ref = str(args["part"]).strip()
187
+ nets = model.part_nets(ref)
188
+ if ref not in model.parts and not nets:
189
+ raise ToolError(f"no part {ref}")
190
+ part = model.parts.get(ref)
191
+ out["part"] = {"ref": ref, "value": part.value if part else "", "pins": []}
192
+ numbers = sorted({*nets, *(part.pin_defs if part else {})}, key=_pin_key)
193
+ for number in numbers:
194
+ row = _pin_info(model, ref, number)
195
+ net = nets.get(number)
196
+ row["net"] = net or "(unconnected)"
197
+ if net:
198
+ row["others"] = len(model.nodes(net)) - 1
199
+ out["part"]["pins"].append(row)
200
+ if args.get("net"):
201
+ asked = True
202
+ net = _find_net(model, str(args["net"]))
203
+ out["net_pins"] = {"net": net, "pins": _net_nodes(model, net)}
204
+ if args.get("from") or args.get("to"):
205
+ asked = True
206
+ if not (args.get("from") and args.get("to")):
207
+ raise ToolError("a path needs both 'from' and 'to'")
208
+ paths = _paths(model, str(args["from"]), str(args["to"]), int(args.get("max_hops") or 4))
209
+ out["paths"] = [" -> ".join(p) for p in paths]
210
+ if not paths:
211
+ out["note"] = (
212
+ "no path through passives within max_hops (ICs, connectors, GND and supply "
213
+ "rails are not traversed)"
214
+ )
215
+ if not asked:
216
+ raise ToolError("ask about a 'pin' (U1.3), a 'part' (U1), a 'net', or 'from' + 'to'")
217
+ return out
218
+
219
+
220
+ def _pin_key(number: str) -> tuple[int, str]:
221
+ return (int(number), "") if number.isdigit() else (10**6, number)
222
+
223
+
224
+ CONNECTIONS_TOOL = Tool(
225
+ "kicad_connections",
226
+ "Connectivity questions, read-only: pin='U1.3' or 'U1.VCC' -> its net and every other "
227
+ "pin on it (names, types); part='U1' -> each pin and its net; net='+3V3' -> its pins; "
228
+ "from='U2.PB3' to='D1' -> paths through passives (R/C/L/D/fuse; not ICs or rails). "
229
+ "Uses the schematic netlist (kicad-cli) when available, else the board.",
230
+ {
231
+ "type": "object",
232
+ "properties": {
233
+ "pin": {"type": "string"},
234
+ "part": {"type": "string"},
235
+ "net": {"type": "string"},
236
+ "from": {"type": "string"},
237
+ "to": {"type": "string"},
238
+ "max_hops": {"type": "integer", "description": "passives to cross (default 4)"},
239
+ "source": {"type": "string", "enum": ["auto", "schematic", "board"]},
240
+ "schematic": {"type": "string"},
241
+ "pcb": {"type": "string"},
242
+ },
243
+ },
244
+ kicad_connections,
245
+ )