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,17 @@
1
+ """Part data providers. See :mod:`traceforge.parts.catalog` for how they are combined."""
2
+
3
+ from traceforge.parts.providers.base import NotSupported, PartProvider, SearchQuery
4
+ from traceforge.parts.providers.jlc import JlcpcbProvider, JlcsearchProvider, LcscProvider
5
+ from traceforge.parts.providers.keyed import DigikeyProvider, MouserProvider, NexarProvider
6
+
7
+ __all__ = [
8
+ "DigikeyProvider",
9
+ "JlcpcbProvider",
10
+ "JlcsearchProvider",
11
+ "LcscProvider",
12
+ "MouserProvider",
13
+ "NexarProvider",
14
+ "NotSupported",
15
+ "PartProvider",
16
+ "SearchQuery",
17
+ ]
@@ -0,0 +1,100 @@
1
+ """Provider interface for part catalogues."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ from traceforge.parts.model import PartInfo
9
+ from traceforge.parts.net import Net
10
+
11
+
12
+ @dataclass
13
+ class SearchQuery:
14
+ text: str
15
+ package: str | None = None
16
+ basic_only: bool = False
17
+ in_stock: bool = True
18
+ limit: int = 10
19
+
20
+
21
+ class NotSupported(Exception):
22
+ """This provider cannot answer this kind of request."""
23
+
24
+
25
+ class PartProvider:
26
+ """A source of part data. Subclasses override what they support.
27
+
28
+ ``lookup_code`` answers for a distributor code (LCSC "C2286");
29
+ ``lookup_mpn`` for a manufacturer part number; ``search`` for free text or
30
+ parametric queries. Each returns None / [] when the provider answered and
31
+ has nothing, raises :class:`traceforge.parts.net.Offline` when it could not
32
+ be reached, and :class:`NotSupported` when it does not do that.
33
+ """
34
+
35
+ name = "provider"
36
+ # Environment variables that must be set for this provider to run.
37
+ env_keys: tuple[str, ...] = ()
38
+ codes = False # can look up LCSC codes
39
+ mpns = False # can look up manufacturer part numbers
40
+ searches = False
41
+
42
+ def __init__(self, net: Net):
43
+ self.net = net
44
+
45
+ def configured(self) -> bool:
46
+ return True
47
+
48
+ def lookup_code(self, code: str) -> PartInfo | None:
49
+ raise NotSupported(self.name)
50
+
51
+ def lookup_mpn(self, mpn: str) -> PartInfo | None:
52
+ if not self.searches:
53
+ raise NotSupported(self.name)
54
+ return best_mpn_match(mpn, self.search(SearchQuery(mpn, in_stock=False, limit=20)))
55
+
56
+ def search(self, query: SearchQuery) -> list[PartInfo]:
57
+ raise NotSupported(self.name)
58
+
59
+
60
+ def norm_mpn(text: str) -> str:
61
+ return re.sub(r"[^A-Z0-9]", "", (text or "").upper())
62
+
63
+
64
+ # Packaging suffixes that do not change the device (tape and reel, tray, lead-free).
65
+ _PACKAGING_SUFFIX = re.compile(r"(TR|T|R|RL|REEL|CT|ND|PBF|G4|E3|LF)$")
66
+
67
+
68
+ def same_device(a: str, b: str) -> bool:
69
+ """MPNs equal apart from punctuation, case and a packaging suffix."""
70
+ na, nb = norm_mpn(a), norm_mpn(b)
71
+ if not na or not nb:
72
+ return False
73
+ if na == nb:
74
+ return True
75
+ longer, shorter = (na, nb) if len(na) > len(nb) else (nb, na)
76
+ if longer.startswith(shorter):
77
+ rest = longer[len(shorter) :]
78
+ return bool(_PACKAGING_SUFFIX.fullmatch(rest))
79
+ return False
80
+
81
+
82
+ def best_mpn_match(mpn: str, results: list[PartInfo]) -> PartInfo | None:
83
+ exact = [p for p in results if norm_mpn(p.mpn) == norm_mpn(mpn)]
84
+ close = exact or [p for p in results if same_device(p.mpn, mpn)]
85
+ if not close:
86
+ return None
87
+ # Prefer what can actually be bought or assembled.
88
+ return sorted(
89
+ close,
90
+ key=lambda p: (
91
+ not (p.stock or 0) > 0,
92
+ {"basic": 0, "preferred": 1}.get(p.library_type, 2),
93
+ -(p.stock or 0),
94
+ ),
95
+ )[0]
96
+
97
+
98
+ def first_number(text: str) -> float | None:
99
+ match = re.search(r"-?\d+(?:\.\d+)?", (text or "").replace(",", ""))
100
+ return float(match.group(0)) if match else None
@@ -0,0 +1,364 @@
1
+ """Keyless providers: JLCPCB parts library, LCSC product data, tscircuit's jlcsearch.
2
+
3
+ These are the public JSON endpoints the respective web sites use. They need no
4
+ account, which is why they are the defaults; none of them is a contractual
5
+ API, so every parser here is defensive about missing fields.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Any
12
+ from urllib.parse import quote
13
+
14
+ from traceforge.parts.model import PartInfo, PriceBreak, is_lcsc_code, normalize_lcsc
15
+ from traceforge.parts.net import ProviderError
16
+ from traceforge.parts.providers.base import PartProvider, SearchQuery
17
+
18
+ JLC_DETAIL = "https://cart.jlcpcb.com/shoppingCart/smtGood/getComponentDetail?componentCode={code}"
19
+ JLC_SEARCH = (
20
+ "https://jlcpcb.com/api/overseas-pcb-order/v1/shoppingCart/smtGood/selectSmtComponentList"
21
+ )
22
+ LCSC_DETAIL = "https://wmsc.lcsc.com/ftps/wm/product/detail?productCode={code}"
23
+ JLCSEARCH = "https://jlcsearch.tscircuit.com"
24
+
25
+ CHIP_CODES = {"01005", "0201", "0402", "0603", "0805", "1206", "1210", "1812", "2010", "2512"}
26
+
27
+
28
+ def _library_type(raw: str | None, preferred: Any = False) -> str:
29
+ raw = (raw or "").lower()
30
+ if raw in {"base", "basic"}:
31
+ return "basic"
32
+ if preferred in (True, "true", 1):
33
+ return "preferred"
34
+ if raw in {"expand", "extended"}:
35
+ return "extended"
36
+ return ""
37
+
38
+
39
+ def _mounting(process: str | None) -> str:
40
+ process = (process or "").upper()
41
+ return {"SMT": "smd", "THT": "tht"}.get(process, "")
42
+
43
+
44
+ class JlcpcbProvider(PartProvider):
45
+ """JLCPCB's assembly parts library: library type, assembly stock, search."""
46
+
47
+ name = "jlcpcb"
48
+ codes = True
49
+ mpns = True
50
+ searches = True
51
+
52
+ def lookup_code(self, code: str) -> PartInfo | None:
53
+ code = normalize_lcsc(code)
54
+ data = self.net.json("GET", JLC_DETAIL.format(code=quote(code)))
55
+ if not isinstance(data, dict):
56
+ raise ProviderError("jlcpcb: unexpected response")
57
+ item = data.get("data")
58
+ if data.get("code") != 200 or not item:
59
+ return None # JLCPCB answers code 400 / data null for unknown parts
60
+ return self.parse(item)
61
+
62
+ def _query(self, keyword: str, query: SearchQuery, package: str | None, size: int):
63
+ body: dict[str, Any] = {
64
+ "keyword": keyword,
65
+ "currentPage": 1,
66
+ "pageSize": min(size, 100),
67
+ "searchSource": "search",
68
+ }
69
+ if query.basic_only:
70
+ body["componentLibraryType"] = "base"
71
+ if query.in_stock:
72
+ body["stockFlag"] = True
73
+ if package:
74
+ body["componentSpecificationList"] = [package]
75
+ data = self.net.json("POST", JLC_SEARCH, json=body)
76
+ page = ((data or {}).get("data") or {}).get("componentPageInfo") or {}
77
+ parts = [self.parse(item) for item in page.get("list") or []]
78
+ # C99xxxxxxxx entries are customer-consigned placeholders, not orderable parts.
79
+ return [p for p in parts if p.lcsc and not re.match(r"^C99\d{8,}$", p.lcsc)]
80
+
81
+ def search(self, query: SearchQuery) -> list[PartInfo]:
82
+ words = query.text.split()
83
+ package = query.package.strip() if query.package else None
84
+ if package not in CHIP_CODES:
85
+ package = None
86
+ chip = [w for w in words if w in CHIP_CODES]
87
+ if chip and package is None:
88
+ package = chip[0]
89
+ if package:
90
+ words = [w for w in words if w != package]
91
+ keyword = " ".join(words) or query.text
92
+ size = max(query.limit * 3, 25) if query.package else max(query.limit, 10)
93
+ parts = self._query(keyword, query, package, size)
94
+ if not parts and len(words) > 1:
95
+ # The index matches names and specs, not prose: drop one word at a time and
96
+ # require the dropped word to appear in what comes back.
97
+ for drop in sorted(words, key=lambda w: (any(c.isdigit() for c in w), len(w)))[:3]:
98
+ rest = [w for w in words if w != drop]
99
+ found = self._query(" ".join(rest), query, package, 50)
100
+ haystack = lambda p: f"{p.mpn} {p.description} {p.category}".lower() # noqa: E731
101
+ parts = [p for p in found if drop.lower() in haystack(p)]
102
+ if parts:
103
+ break
104
+ if not query.basic_only and parts and not any(p.is_basic for p in parts):
105
+ # Basic parts carry no setup fee at JLCPCB: look for them explicitly.
106
+ basic = SearchQuery(query.text, query.package, True, query.in_stock, query.limit)
107
+ try:
108
+ parts = self._query(keyword, basic, package, size) + parts
109
+ except Exception: # noqa: BLE001 - the main result is still good
110
+ pass
111
+ return parts
112
+
113
+ @staticmethod
114
+ def parse(item: dict[str, Any]) -> PartInfo:
115
+ attributes = {}
116
+ for attr in item.get("attributes") or []:
117
+ name = attr.get("attribute_name_en")
118
+ value = attr.get("attribute_value_name")
119
+ if name and value not in (None, "", "-"):
120
+ attributes[str(name)] = str(value)
121
+ prices = item.get("prices") or item.get("componentPrices") or []
122
+ breaks = [
123
+ PriceBreak(int(p.get("startNumber") or 1), float(p.get("productPrice")))
124
+ for p in prices
125
+ if p.get("productPrice") is not None
126
+ ]
127
+ breaks.sort(key=lambda b: b.quantity)
128
+ stock = item.get("stockCount")
129
+ code = str(item.get("componentCode") or "")
130
+ category = item.get("secondTypeNameEn") or item.get("secondSortName") or ""
131
+ first = item.get("firstTypeNameEn") or item.get("firstSortName") or ""
132
+ if category and first and category != first:
133
+ category = f"{first} / {category}"
134
+ status = str(item.get("componentStatus") or "").lower()
135
+ return PartInfo(
136
+ lcsc=code,
137
+ mpn=str(item.get("componentModelEn") or ""),
138
+ manufacturer=str(item.get("componentBrandEn") or ""),
139
+ description=str(item.get("describe") or item.get("componentNameEn") or ""),
140
+ package=str(item.get("componentSpecificationEn") or ""),
141
+ category=str(category or item.get("componentTypeEn") or ""),
142
+ stock=int(stock) if isinstance(stock, int | float) else None,
143
+ stock_by_source={"jlcpcb": int(stock)} if isinstance(stock, int | float) else {},
144
+ price_breaks=breaks,
145
+ library_type=_library_type(
146
+ item.get("componentLibraryType"), item.get("preferredComponentFlag")
147
+ ),
148
+ datasheet_url=str(item.get("dataManualUrl") or ""),
149
+ product_url=(
150
+ f"https://jlcpcb.com/partdetail/{item['urlSuffix']}"
151
+ if item.get("urlSuffix")
152
+ else (f"https://jlcpcb.com/partdetail/{code}" if code else "")
153
+ ),
154
+ lifecycle="discontinued" if status in {"no", "discontinued"} else "",
155
+ mounting=_mounting(item.get("assemblyProcess")),
156
+ attributes=attributes,
157
+ sources=["jlcpcb"],
158
+ )
159
+
160
+
161
+ class LcscProvider(PartProvider):
162
+ """LCSC product detail: package, lifecycle, distributor stock, datasheet, alternates."""
163
+
164
+ name = "lcsc"
165
+ codes = True
166
+
167
+ def lookup_code(self, code: str) -> PartInfo | None:
168
+ code = normalize_lcsc(code)
169
+ data = self.net.json("GET", LCSC_DETAIL.format(code=quote(code)))
170
+ if not isinstance(data, dict):
171
+ raise ProviderError("lcsc: unexpected response")
172
+ result = data.get("result")
173
+ if not result:
174
+ return None
175
+ return self.parse(result)
176
+
177
+ @staticmethod
178
+ def parse(result: dict[str, Any]) -> PartInfo:
179
+ attributes = {}
180
+ for param in result.get("paramVOList") or []:
181
+ name, value = param.get("paramNameEn"), param.get("paramValueEn")
182
+ if name and value not in (None, "", "-"):
183
+ attributes[str(name)] = str(value)
184
+ breaks = [
185
+ PriceBreak(int(p.get("ladder") or 1), float(p.get("usdPrice") or p.get("productPrice")))
186
+ for p in result.get("productPriceList") or []
187
+ if (p.get("usdPrice") or p.get("productPrice")) is not None
188
+ ]
189
+ stock = result.get("stockNumber")
190
+ catalog = result.get("catalogName") or ""
191
+ parent = result.get("parentCatalogName") or ""
192
+ alternates = [
193
+ str(a.get("productCode"))
194
+ for a in (result.get("alternatePartList") or [])
195
+ if a.get("productCode")
196
+ ]
197
+ label = str(result.get("smtLabel") or "").upper()
198
+ code = str(result.get("productCode") or "")
199
+ return PartInfo(
200
+ lcsc=code,
201
+ mpn=str(result.get("productModel") or ""),
202
+ manufacturer=str(result.get("brandNameEn") or ""),
203
+ description=str(result.get("productIntroEn") or result.get("productNameEn") or ""),
204
+ package=str(result.get("encapStandard") or ""),
205
+ category=f"{parent} / {catalog}" if parent and catalog else str(catalog),
206
+ stock=int(stock) if isinstance(stock, int | float) else None,
207
+ stock_by_source={"lcsc": int(stock)} if isinstance(stock, int | float) else {},
208
+ price_breaks=breaks,
209
+ library_type={"BASE": "basic", "EXPAND": "extended"}.get(label, ""),
210
+ datasheet_url=str(result.get("pdfUrl") or ""),
211
+ product_url=f"https://www.lcsc.com/product-detail/{code}.html" if code else "",
212
+ lifecycle=str(result.get("productCycle") or ""),
213
+ attributes=attributes,
214
+ alternatives=alternates[:10],
215
+ sources=["lcsc"],
216
+ )
217
+
218
+
219
+ # ------------------------------------------------------------------ jlcsearch
220
+
221
+ _PREFIX = {"p": 1e-12, "n": 1e-9, "u": 1e-6, "µ": 1e-6, "m": 1e-3, "k": 1e3, "K": 1e3, "M": 1e6}
222
+
223
+
224
+ def parse_value(text: str, unit: str = "") -> float | None:
225
+ """'10k' -> 10000, '4k7' -> 4700, '100nF' -> 1e-7, '0R1' -> 0.1, '10' -> 10."""
226
+ text = (text or "").strip().replace("Ω", "").replace("ohm", "").replace(",", ".")
227
+ if unit:
228
+ text = re.sub(unit + r"\b", "", text, flags=re.IGNORECASE)
229
+ match = re.match(r"^(\d+)([pnuµmkKMGR])(\d+)\b", text) # 4k7, 4R7
230
+ if match:
231
+ whole, prefix, frac = match.groups()
232
+ scale = 1.0 if prefix == "R" else _PREFIX.get(prefix, 1.0)
233
+ return float(f"{whole}.{frac}") * scale
234
+ match = re.match(r"^(\d+(?:\.\d+)?)\s*([pnuµmkKMG]?)", text)
235
+ if match and match.group(1):
236
+ value = float(match.group(1))
237
+ return value * _PREFIX.get(match.group(2), 1.0) if match.group(2) else value
238
+ return None
239
+
240
+
241
+ def _parametric(text: str) -> tuple[str, dict[str, str]] | None:
242
+ """Recognise '10k 0603 1%', '100nF 0402', 'red led 0603' for the category endpoints."""
243
+ lowered = text.lower()
244
+ package = next((t for t in re.findall(r"\b\d{4}\b", text) if t in CHIP_CODES), None)
245
+ params: dict[str, str] = {}
246
+ if package:
247
+ params["package"] = package
248
+ if re.search(r"\bleds?\b", lowered):
249
+ color = re.search(r"\b(red|green|blue|white|yellow|orange|amber|warm white)\b", lowered)
250
+ if color:
251
+ params["color"] = color.group(1)
252
+ return "leds", params
253
+ cap = re.search(r"\b(\d+(?:\.\d+)?\s*[pnuµ])f?\b", lowered)
254
+ if cap and ("cap" in lowered or re.search(r"\d\s*[pnuµ]f\b", lowered) or package):
255
+ value = parse_value(cap.group(1).replace(" ", ""))
256
+ if value:
257
+ params["capacitance"] = f"{value:.4g}"
258
+ return "capacitors", params
259
+ res = re.search(r"\b(\d+(?:\.\d+)?[kKmMR]?\d*)\s*(?:ohm|Ω|r\b)?", text)
260
+ if res and (
261
+ "res" in lowered or "ohm" in lowered or "Ω" in text or re.search(r"\d[kKM]\b|\dR\d", text)
262
+ ):
263
+ value = parse_value(res.group(1))
264
+ if value is not None:
265
+ params["resistance"] = f"{value:g}"
266
+ return "resistors", params
267
+ return None
268
+
269
+
270
+ class JlcsearchProvider(PartProvider):
271
+ """tscircuit's jlcsearch index of JLCPCB in-stock parts, with parametric category endpoints."""
272
+
273
+ name = "jlcsearch"
274
+ codes = True
275
+ searches = True
276
+
277
+ def lookup_code(self, code: str) -> PartInfo | None:
278
+ code = normalize_lcsc(code)
279
+ data = self.net.json("GET", f"{JLCSEARCH}/api/search", params={"q": code, "limit": 5})
280
+ for item in (data or {}).get("components") or []:
281
+ if f"C{item.get('lcsc')}" == code:
282
+ return self.parse(item)
283
+ return None
284
+
285
+ def search(self, query: SearchQuery) -> list[PartInfo]:
286
+ parametric = _parametric(query.text)
287
+ if parametric:
288
+ category, params = parametric
289
+ if query.package and query.package in CHIP_CODES:
290
+ params["package"] = query.package
291
+ params["limit"] = str(max(query.limit * 3, 20))
292
+ data = self.net.json("GET", f"{JLCSEARCH}/{category}/list.json", params=params)
293
+ items = (data or {}).get(category) or []
294
+ parts = [self.parse(i) for i in items]
295
+ tolerance = re.search(r"(\d+(?:\.\d+)?)\s*%", query.text)
296
+ if tolerance and category in {"resistors", "capacitors"}:
297
+ wanted = float(tolerance.group(1)) / 100
298
+ parts = [
299
+ p
300
+ for p, i in zip(parts, items)
301
+ if i.get("tolerance_fraction") is None
302
+ or float(i["tolerance_fraction"]) <= wanted + 1e-9
303
+ ]
304
+ return parts
305
+ params = {"q": query.text, "limit": str(max(query.limit * 3, 20))}
306
+ if query.package:
307
+ params["package"] = query.package
308
+ data = self.net.json("GET", f"{JLCSEARCH}/api/search", params=params)
309
+ parts = [self.parse(i) for i in (data or {}).get("components") or []]
310
+ # The free-text index returns loosely related parts; keep those that mention a query word.
311
+ words = [w.lower() for w in re.findall(r"[A-Za-z0-9.+-]{3,}", query.text)]
312
+ return [
313
+ p
314
+ for p in parts
315
+ if not words
316
+ or any(w in (p.mpn + " " + p.description + " " + p.lcsc).lower() for w in words)
317
+ ]
318
+
319
+ @staticmethod
320
+ def parse(item: dict[str, Any]) -> PartInfo:
321
+ import json as _json
322
+
323
+ attributes: dict[str, str] = {}
324
+ raw = item.get("attributes")
325
+ if isinstance(raw, str):
326
+ try:
327
+ raw = _json.loads(raw)
328
+ except ValueError:
329
+ raw = {}
330
+ if isinstance(raw, dict):
331
+ attributes = {str(k): str(v) for k, v in raw.items() if v not in (None, "", "-")}
332
+ price = item.get("price1", item.get("price"))
333
+ breaks: list[PriceBreak] = []
334
+ if isinstance(price, int | float):
335
+ breaks = [PriceBreak(1, float(price))]
336
+ elif isinstance(price, str): # "1-9:4.26,10-29:4.15"
337
+ for chunk in price.split(","):
338
+ match = re.match(r"(\d+)-\d*:(\d+(?:\.\d+)?)", chunk.strip())
339
+ if match:
340
+ breaks.append(PriceBreak(int(match.group(1)), float(match.group(2))))
341
+ stock = item.get("stock")
342
+ code = f"C{item['lcsc']}" if item.get("lcsc") else ""
343
+ if item.get("forward_voltage") and "Voltage - Forward(Vf)" not in attributes:
344
+ attributes["Voltage - Forward(Vf)"] = f"{item['forward_voltage']}V"
345
+ return PartInfo(
346
+ lcsc=code,
347
+ mpn=str(item.get("mfr") or ""),
348
+ description=str(item.get("description") or ""),
349
+ package=str(item.get("package") or ""),
350
+ category=" / ".join(str(item[k]) for k in ("category", "subcategory") if item.get(k)),
351
+ stock=int(stock) if isinstance(stock, int | float) else None,
352
+ stock_by_source={"jlcsearch": int(stock)} if isinstance(stock, int | float) else {},
353
+ price_breaks=breaks,
354
+ library_type=_library_type(
355
+ "base" if item.get("is_basic") else "expand", item.get("is_preferred")
356
+ ),
357
+ product_url=f"https://jlcpcb.com/partdetail/{code}" if code else "",
358
+ attributes=attributes,
359
+ sources=["jlcsearch"],
360
+ )
361
+
362
+
363
+ def looks_like_code(text: str) -> bool:
364
+ return is_lcsc_code(text)