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