ecadlib 0.3.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.
- ecadlib/__init__.py +3 -0
- ecadlib/__main__.py +5 -0
- ecadlib/a365_sync.py +637 -0
- ecadlib/agent.py +1407 -0
- ecadlib/agent_queue.py +780 -0
- ecadlib/agent_watch.py +732 -0
- ecadlib/altium365.py +385 -0
- ecadlib/altium_dblib_sync.py +530 -0
- ecadlib/altium_export.py +209 -0
- ecadlib/altium_forms.py +768 -0
- ecadlib/altium_lib.py +1089 -0
- ecadlib/altium_menu.py +1751 -0
- ecadlib/altium_pkg.py +396 -0
- ecadlib/altium_project.py +1095 -0
- ecadlib/app.py +1458 -0
- ecadlib/approvals.py +395 -0
- ecadlib/assets/demo_bom.csv +8 -0
- ecadlib/assets/kicad_action_plugin.py +607 -0
- ecadlib/assets/kicad_reread.py +60 -0
- ecadlib/assets/pcblib_template.json.gz +0 -0
- ecadlib/bom_inputs.py +339 -0
- ecadlib/bom_triage.py +519 -0
- ecadlib/cad_model.py +528 -0
- ecadlib/cfb.py +171 -0
- ecadlib/cli.py +185 -0
- ecadlib/clihelp.py +149 -0
- ecadlib/cliutil.py +119 -0
- ecadlib/completion.py +173 -0
- ecadlib/costs.py +276 -0
- ecadlib/crosscheck.py +412 -0
- ecadlib/datasheet.py +2168 -0
- ecadlib/dedupe.py +572 -0
- ecadlib/detect.py +170 -0
- ecadlib/doctor.py +450 -0
- ecadlib/ds_route.py +541 -0
- ecadlib/eagle_export.py +1409 -0
- ecadlib/easy_setup.py +338 -0
- ecadlib/easyeda_convert.py +386 -0
- ecadlib/easyeda_pro_export.py +972 -0
- ecadlib/eval/__init__.py +0 -0
- ecadlib/eval/baselines/kicad-v1.json +404 -0
- ecadlib/eval/build_set.py +299 -0
- ecadlib/eval/cli.py +119 -0
- ecadlib/eval/gate.py +358 -0
- ecadlib/eval/golden/demo.txt +379 -0
- ecadlib/eval/golden/specs.txt +3120 -0
- ecadlib/eval/golden/testpack.txt +463 -0
- ecadlib/eval/golden.py +446 -0
- ecadlib/eval/replay/kicad-v1.json.gz +0 -0
- ecadlib/eval/runner.py +456 -0
- ecadlib/eval/score.py +179 -0
- ecadlib/eval/sets/kicad-v1.json +630 -0
- ecadlib/firstrun.py +400 -0
- ecadlib/footprint_adv.py +1006 -0
- ecadlib/footprint_bench.py +218 -0
- ecadlib/footprint_check.py +562 -0
- ecadlib/footprint_gen.py +582 -0
- ecadlib/footprint_rules.py +1092 -0
- ecadlib/forge.py +267 -0
- ecadlib/fp_share.py +95 -0
- ecadlib/fp_tidy.py +389 -0
- ecadlib/fsutil.py +62 -0
- ecadlib/house_style.py +1051 -0
- ecadlib/httpcache.py +124 -0
- ecadlib/hub.py +1490 -0
- ecadlib/hub_copy.py +161 -0
- ecadlib/hub_tools.py +79 -0
- ecadlib/kicad_dblib.py +257 -0
- ecadlib/kicad_export.py +837 -0
- ecadlib/kicad_import.py +913 -0
- ecadlib/kicad_index.py +160 -0
- ecadlib/kicad_menu.py +193 -0
- ecadlib/kicad_pcm.py +475 -0
- ecadlib/launcher.py +156 -0
- ecadlib/librarian.py +963 -0
- ecadlib/lifecycle.py +691 -0
- ecadlib/llm/__init__.py +276 -0
- ecadlib/llm/cli.py +70 -0
- ecadlib/llm/openai_compat.py +214 -0
- ecadlib/llm/profiles.py +245 -0
- ecadlib/llm/rails.py +570 -0
- ecadlib/llm/run.py +126 -0
- ecadlib/llm/sampling.py +49 -0
- ecadlib/mcp_server.py +723 -0
- ecadlib/messages.py +419 -0
- ecadlib/migrate.py +407 -0
- ecadlib/notify.py +329 -0
- ecadlib/package_schema.json +1238 -0
- ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
- ecadlib/package_specs/C_0402_1005Metric.json +22 -0
- ecadlib/package_specs/C_0805_2012Metric.json +22 -0
- ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
- ecadlib/package_specs/D_SMA.json +27 -0
- ecadlib/package_specs/D_SMB.json +27 -0
- ecadlib/package_specs/D_SOD-123.json +43 -0
- ecadlib/package_specs/D_SOD-323.json +43 -0
- ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
- ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
- ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
- ecadlib/package_specs/L_0805_2012Metric.json +22 -0
- ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
- ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
- ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
- ecadlib/package_specs/R_0402_1005Metric.json +22 -0
- ecadlib/package_specs/R_0603_1608Metric.json +22 -0
- ecadlib/package_specs/R_0805_2012Metric.json +22 -0
- ecadlib/package_specs/R_1206_3216Metric.json +22 -0
- ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
- ecadlib/package_specs/SOT-23-5.json +57 -0
- ecadlib/package_specs/SOT-23-6.json +62 -0
- ecadlib/package_specs/SOT-23.json +48 -0
- ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
- ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
- ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
- ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
- ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
- ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
- ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
- ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
- ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
- ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
- ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
- ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
- ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
- ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
- ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
- ecadlib/package_specs/standard.json.gz +0 -0
- ecadlib/parity.py +1204 -0
- ecadlib/parts_export.py +154 -0
- ecadlib/partview.py +215 -0
- ecadlib/plugins.py +231 -0
- ecadlib/providers.py +226 -0
- ecadlib/rebuild.py +471 -0
- ecadlib/release_check.py +437 -0
- ecadlib/reports.py +274 -0
- ecadlib/resolve.py +556 -0
- ecadlib/review_cli.py +292 -0
- ecadlib/rules.py +979 -0
- ecadlib/rules_infer.py +1205 -0
- ecadlib/sexpr.py +149 -0
- ecadlib/sheetsafe.py +65 -0
- ecadlib/sim_models.py +1087 -0
- ecadlib/sourcing.py +1161 -0
- ecadlib/stdparts.py +755 -0
- ecadlib/step_gen.py +929 -0
- ecadlib/symbol_layout.py +798 -0
- ecadlib/team.py +558 -0
- ecadlib/team_setup.py +288 -0
- ecadlib/term.py +420 -0
- ecadlib/term_views.py +64 -0
- ecadlib/testpack.py +334 -0
- ecadlib/ui/__init__.py +1 -0
- ecadlib/ui/__main__.py +3 -0
- ecadlib/ui/server.py +1029 -0
- ecadlib/ui/static/hairline.js +2 -0
- ecadlib/ui/static/index.html +2098 -0
- ecadlib/ui/static/setup.html +294 -0
- ecadlib/upkeep.py +717 -0
- ecadlib/upstream.py +573 -0
- ecadlib/userconfig.py +257 -0
- ecadlib/visual_check.py +1603 -0
- ecadlib/ws_status.py +203 -0
- ecadlib-0.3.0.dist-info/METADATA +2245 -0
- ecadlib-0.3.0.dist-info/RECORD +199 -0
- ecadlib-0.3.0.dist-info/WHEEL +5 -0
- ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
- ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
- ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
- ecadlib-0.3.0.dist-info/top_level.txt +1 -0
ecadlib/librarian.py
ADDED
|
@@ -0,0 +1,963 @@
|
|
|
1
|
+
"""One command from BOM to library: triage the BOM, fill every missing symbol and footprint, write the Altium
|
|
2
|
+
models and the altium365 plan, run the release check, and stop with a review list.
|
|
3
|
+
|
|
4
|
+
python3 -m ecadlib.librarian examples/sample_bom.csv --lib examples/kicad_lib --lib out/footprints -o out/librarian \
|
|
5
|
+
[--sources kicad,easyeda,standard,datasheet] [--allow PART] [--box] [--refresh [PART ...]] [--refresh-index] [--offline]
|
|
6
|
+
|
|
7
|
+
Per plan item (one per unique part), parts the triage found in a --lib library are converted from there; for
|
|
8
|
+
anything still missing, the sources are tried in --sources order, each filling only what is still missing:
|
|
9
|
+
kicad the official KiCad libraries on gitlab.com. A name index of kicad-symbols (KiCad 9 <Lib>.kicad_symdir/
|
|
10
|
+
<Name>.kicad_sym, so derived symbols/aliases are names too) and kicad-footprints is built once from the
|
|
11
|
+
GitLab tree API and cached (<cache>/kicad_library_index.json, 30 days; a build stopped by a timeout or
|
|
12
|
+
HTTP 429 resumes from the cached pages after a pause of 15 min / 1 h, in which runs skip gitlab.com).
|
|
13
|
+
Symbol by MPN: exact name, a name with KiCad's `x` wildcards (STM32F103C8Tx), or the MPN minus an
|
|
14
|
+
ordering suffix (TPS62162DSG for TPS62162DSGR); generic connectors by value (Conn_01x04). Footprint: the BOM footprint name, the symbol's
|
|
15
|
+
Footprint property, a footprint named after the MPN, or the shortest name starting with the BOM footprint.
|
|
16
|
+
R/C/L/LED use Device:R/C/C_Polarized/L/LED and <Family>_SMD:<X>_<imperial>_<metric>Metric by package.
|
|
17
|
+
easyeda LCSC number (BOM, or found by MPN in triage) -> EasyEDA component JSON -> easyeda_convert -> KiCad text
|
|
18
|
+
standard no network, no API: chip passives and standard packages (SOT-23, SOIC-8, QFN-32 5x5 ...) built from the
|
|
19
|
+
checked package table, symbols where the pinout is standard (ecadlib/stdparts.py); always after kicad
|
|
20
|
+
datasheet datasheet URL (BOM column, KiCad symbol, EasyEDA/LCSC link, LCSC pattern) + ANTHROPIC_API_KEY ->
|
|
21
|
+
ecadlib.datasheet; the result is marked `verify` so release_check holds it until a person has looked (or
|
|
22
|
+
--allow). --datasheet-batch sends every part that reaches this source as one Message Batch (half price)
|
|
23
|
+
after the other sources ran, then builds them; results are cached, so re-runs are free
|
|
24
|
+
Footprints without a KiCad STEP model get a package-shaped STEP from step_gen embedded (--box: extruded box).
|
|
25
|
+
Everything is converted through kicad_import into <out>/models/{SchLib,PcbLib} (plus the .kicad_mod in
|
|
26
|
+
<out>/models/KiCad for footprint_check). Outputs: <out>/plan.json (each item records `source`, `librarian.tried`
|
|
27
|
+
and the release-check result), <out>/review.{md,csv}, <out>/release_check.{md,csv}, <out>/triage.{md,csv}.
|
|
28
|
+
Resumable: <out>/librarian_state.json remembers finished parts; a re-run skips them unless --refresh (all, or the
|
|
29
|
+
named parts) or their BOM line changed. --allow PART is recorded in plan.json and survives re-runs, and so does a
|
|
30
|
+
symbol or footprint chosen with `ecadlib resolve` (held parts: see ecadlib/resolve.py).
|
|
31
|
+
A part already in the Altium 365 workspace (an item `ecadlib a365 pull` put in <out>/plan.json, matched by name or
|
|
32
|
+
MPN) is reused as it is: nothing is fetched or converted for it, and pulled items the BOM does not use stay in the plan."""
|
|
33
|
+
import argparse, csv, json, os, re, sys, time
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
import pandas as pd
|
|
37
|
+
|
|
38
|
+
from . import __version__
|
|
39
|
+
from . import (a365_sync as A365S, altium365 as A365, altium_lib as AL, bom_triage as T, datasheet as D, easyeda_convert as EC,
|
|
40
|
+
footprint_gen as F, fp_share as FS, kicad_import as K, providers as P, release_check as RC, rules as RU, sexpr)
|
|
41
|
+
from .fsutil import write_text_atomic
|
|
42
|
+
from .httpcache import CachedHTTP
|
|
43
|
+
from . import crosscheck as CC, messages as M, sheetsafe as SS
|
|
44
|
+
from . import fp_tidy as FT, symbol_layout as SL
|
|
45
|
+
from .kicad_index import KiCadIndex, METRIC_TO_IMPERIAL, norm_mpn, package_of
|
|
46
|
+
|
|
47
|
+
TREE = "https://gitlab.com/api/v4/projects/kicad%2Flibraries%2F{repo}/repository/tree?ref=master&recursive=true&per_page=100&page={page}"
|
|
48
|
+
SOURCES = ("kicad", "easyeda", "standard", "datasheet")
|
|
49
|
+
IMP2MET = {v: k for k, v in METRIC_TO_IMPERIAL.items()}
|
|
50
|
+
PASSIVE = {"R": ("Device:R", "Resistor_SMD:R_{}_{}Metric"), "C": ("Device:C", "Capacitor_SMD:C_{}_{}Metric"),
|
|
51
|
+
"CP": ("Device:C_Polarized", None), "L": ("Device:L", "Inductor_SMD:L_{}_{}Metric"), "LED": ("Device:LED", "LED_SMD:LED_{}_{}Metric")}
|
|
52
|
+
SPECS = Path(F.__file__).with_name("package_specs")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------- official KiCad library name index
|
|
56
|
+
class KiCadLibrary:
|
|
57
|
+
"""Name index of the official KiCad libraries (see the module docstring). Building it reads ~400 GitLab tree pages;
|
|
58
|
+
each page is kept in the HTTP cache, so a build that stops (timeout, HTTP 429) resumes where it stopped. After a
|
|
59
|
+
failed build, <index>.retry.json holds the next attempt back for RETRY_AFTER s (RETRY_AFTER_429 after a 429) so
|
|
60
|
+
every run in between goes straight to the other sources instead of waiting on gitlab.com again."""
|
|
61
|
+
PAGE_TIMEOUT = 20 # s per tree page (the client's own timeout is 60 s)
|
|
62
|
+
RETRY_AFTER, RETRY_AFTER_429 = 15 * 60, 60 * 60
|
|
63
|
+
|
|
64
|
+
def __init__(self, http, cache_file, refresh=False, max_age=30 * 86400, now=time.time):
|
|
65
|
+
self.http, self.error, self.symbols, self.footprints = http, None, {}, {}
|
|
66
|
+
f = Path(cache_file)
|
|
67
|
+
retry = f.with_name(f.name + ".retry.json")
|
|
68
|
+
wait = self._backoff(retry, now()) if not refresh else None
|
|
69
|
+
if f.exists() and not refresh and (getattr(http, "offline", False) or now() - f.stat().st_mtime < max_age):
|
|
70
|
+
self._load(f)
|
|
71
|
+
elif wait:
|
|
72
|
+
self.error = wait
|
|
73
|
+
if f.exists(): # a stale index beats none
|
|
74
|
+
self._load(f)
|
|
75
|
+
else:
|
|
76
|
+
try:
|
|
77
|
+
self.symbols = self._crawl("kicad-symbols", ".kicad_symdir", ".kicad_sym")
|
|
78
|
+
self.footprints = self._crawl("kicad-footprints", ".pretty", ".kicad_mod")
|
|
79
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
f.write_text(json.dumps({"built": time.strftime("%Y-%m-%d"), "symbols": self.symbols, "footprints": self.footprints}), encoding="utf-8")
|
|
81
|
+
retry.unlink(missing_ok=True)
|
|
82
|
+
except IOError as e:
|
|
83
|
+
self.error = str(e)
|
|
84
|
+
self.symbols, self.footprints = {}, {}
|
|
85
|
+
if not getattr(http, "offline", False):
|
|
86
|
+
try:
|
|
87
|
+
retry.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
hold = self.RETRY_AFTER_429 if "HTTP 429" in self.error else self.RETRY_AFTER
|
|
89
|
+
retry.write_text(json.dumps({"after": now() + hold, "error": self.error}), encoding="utf-8")
|
|
90
|
+
except OSError:
|
|
91
|
+
pass
|
|
92
|
+
if f.exists(): # a stale index beats none
|
|
93
|
+
self._load(f)
|
|
94
|
+
self._norm, self._wild, self._fpn = {}, [], []
|
|
95
|
+
for name, libs in self.symbols.items():
|
|
96
|
+
self._norm.setdefault(norm_mpn(name), []).extend(f"{l}:{name}" for l in libs)
|
|
97
|
+
if re.search(r"(?<![0-9])x|x(?![0-9])", name) and re.search(r"[A-Z0-9]x", name) and not any(l.startswith("Connector") for l in libs):
|
|
98
|
+
rx = "".join("[A-Z0-9]" if c == "x" else re.escape(c.upper()) for c in re.sub(r"[^A-Za-z0-9]", "", name))
|
|
99
|
+
self._wild.append((re.compile(rx), [f"{l}:{name}" for l in libs]))
|
|
100
|
+
self._fpn = [(norm_mpn(n), f"{libs[0]}:{n}") for n, libs in self.footprints.items()]
|
|
101
|
+
|
|
102
|
+
def _load(self, f):
|
|
103
|
+
d = json.loads(f.read_text(encoding="utf-8"))
|
|
104
|
+
self.symbols, self.footprints = d["symbols"], d["footprints"]
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _backoff(retry, t):
|
|
108
|
+
"""Why the index is not fetched now (a recent failed build), or None."""
|
|
109
|
+
try:
|
|
110
|
+
d = json.loads(retry.read_text(encoding="utf-8"))
|
|
111
|
+
after = float(d["after"])
|
|
112
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
113
|
+
return None
|
|
114
|
+
if t >= after:
|
|
115
|
+
return None
|
|
116
|
+
return (f"{d.get('error') or 'the last build failed'}; not retried until "
|
|
117
|
+
f"{time.strftime('%H:%M', time.localtime(after))} (--refresh-index retries now)")
|
|
118
|
+
|
|
119
|
+
def _get(self, url):
|
|
120
|
+
try:
|
|
121
|
+
return self.http.get(url, timeout=self.PAGE_TIMEOUT)
|
|
122
|
+
except TypeError: # a client without a per-request timeout
|
|
123
|
+
return self.http.get(url)
|
|
124
|
+
|
|
125
|
+
def _crawl(self, repo, dsuf, fsuf):
|
|
126
|
+
out = {}
|
|
127
|
+
for page in range(1, 2000):
|
|
128
|
+
r = self._get(TREE.format(repo=repo, page=page))
|
|
129
|
+
if not r.ok:
|
|
130
|
+
raise IOError(f"{repo} tree page {page}: {r.error or f'HTTP {r.status}'}")
|
|
131
|
+
items = r.json()
|
|
132
|
+
for it in items:
|
|
133
|
+
lib, _, fn = it.get("path", "").partition("/")
|
|
134
|
+
if it.get("type") == "blob" and lib.endswith(dsuf) and fn.endswith(fsuf) and "/" not in fn:
|
|
135
|
+
out.setdefault(fn[:-len(fsuf)], []).append(lib[:-len(dsuf)])
|
|
136
|
+
if len(items) < 100:
|
|
137
|
+
return out
|
|
138
|
+
raise IOError(f"{repo}: tree listing did not end")
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _pick(refs):
|
|
142
|
+
refs = sorted(dict.fromkeys(refs))
|
|
143
|
+
return refs[0], (f" (also in {', '.join(r.split(':')[0] for r in refs[1:])})" if len(refs) > 1 else "")
|
|
144
|
+
|
|
145
|
+
def find_symbol(self, mpn):
|
|
146
|
+
"""MPN -> ('Lib:Name', how) or (None, None)."""
|
|
147
|
+
nm = norm_mpn(mpn)
|
|
148
|
+
if len(nm) < 3:
|
|
149
|
+
return None, None
|
|
150
|
+
if nm in self._norm:
|
|
151
|
+
ref, more = self._pick(self._norm[nm])
|
|
152
|
+
return ref, "exact name" + more
|
|
153
|
+
hits = [r for rx, refs in self._wild if rx.fullmatch(nm) for r in refs]
|
|
154
|
+
if hits:
|
|
155
|
+
ref, more = self._pick(hits)
|
|
156
|
+
return ref, "name with KiCad x wildcards" + more
|
|
157
|
+
raw = [i for i, c in enumerate(str(mpn)) if c.isalnum()] # positions of the normalised characters
|
|
158
|
+
for k in range(len(nm) - 1, max(5, int(len(nm) * 0.6)) - 1, -1):
|
|
159
|
+
sep = raw[k] > raw[k - 1] + 1 # a '-', '/', '(' ... right before the suffix
|
|
160
|
+
if nm[:k] in self._norm and (len(nm) - k <= 2 or (sep and len(nm) - k <= 6)):
|
|
161
|
+
ref, more = self._pick(self._norm[nm[:k]])
|
|
162
|
+
return ref, f"name + ordering suffix '{str(mpn)[raw[k]:]}' (check the variant)" + more
|
|
163
|
+
return None, None
|
|
164
|
+
|
|
165
|
+
def generic_connector(self, value):
|
|
166
|
+
for n in (value, f"{value}_Odd_Even"):
|
|
167
|
+
libs = [l for l in self.symbols.get(n, []) if l == "Connector_Generic"]
|
|
168
|
+
if libs and re.fullmatch(r"Conn_\d\dx\d\d.*", n):
|
|
169
|
+
return f"{libs[0]}:{n}", f"generic connector symbol by value {value}"
|
|
170
|
+
return None, None
|
|
171
|
+
|
|
172
|
+
def footprint(self, ref):
|
|
173
|
+
if not ref:
|
|
174
|
+
return None
|
|
175
|
+
lib, _, name = ref.rpartition(":")
|
|
176
|
+
libs = self.footprints.get(name, [])
|
|
177
|
+
return f"{lib}:{name}" if lib in libs else f"{libs[0]}:{name}" if libs and not lib else None
|
|
178
|
+
|
|
179
|
+
def footprint_by_mpn(self, mpn):
|
|
180
|
+
nm = norm_mpn(re.sub(r"\(.*", "", str(mpn)))
|
|
181
|
+
hits = sorted((len(n), ref) for n, ref in self._fpn if len(nm) >= 6 and nm in n)
|
|
182
|
+
return hits[0][1] if hits else None
|
|
183
|
+
|
|
184
|
+
def footprint_by_prefix(self, name):
|
|
185
|
+
hits = sorted((len(n), f"{libs[0]}:{n}") for n, libs in self.footprints.items() if name and n.startswith(name + "_"))
|
|
186
|
+
return hits[0][1] if hits else None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------- helpers
|
|
190
|
+
def passive_kind(item):
|
|
191
|
+
cat, fp = item["folder"].split("/")[-1], item["footprint"].get("kicad") or item["footprint"]["ref"] or ""
|
|
192
|
+
if cat == "Resistors":
|
|
193
|
+
return "R"
|
|
194
|
+
if cat == "Capacitors":
|
|
195
|
+
return "CP" if fp.startswith("CP_") else "C"
|
|
196
|
+
if cat == "Inductors":
|
|
197
|
+
return "L"
|
|
198
|
+
if cat == "Diodes" and (fp.startswith("LED_") or re.search(r"\bLED\b", item.get("description") or "", re.I)):
|
|
199
|
+
return "LED"
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _par(item):
|
|
204
|
+
return {p["name"]: p["value"] for p in item.get("parameters", [])}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _set_par(item, k, v):
|
|
208
|
+
item["parameters"] = [p for p in item["parameters"] if p["name"] != k] + [{"name": k, "value": v}]
|
|
209
|
+
item["cleared"] = [c for c in item.get("cleared", []) if c != k]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _line_for(item, lines, rows):
|
|
213
|
+
"""The BOM line and triage row of a plan item: same MPN, else (no MPN) same value. Of several, a fitted line wins
|
|
214
|
+
over a DNP one (R5 DNP and R1 share the MPN), then the line whose footprint is the item's (10k 0603 and 10k 0402
|
|
215
|
+
are two items with one value)."""
|
|
216
|
+
par = _par(item)
|
|
217
|
+
mpn, val = par.get("Manufacturer Part Number", ""), par.get("Value", "")
|
|
218
|
+
fp = item.get("footprint") or {}
|
|
219
|
+
want = {AL.lib_name(str(x).split(":")[-1]) for x in (fp.get("kicad"), fp.get("bom_ref"), fp.get("ref")) if x}
|
|
220
|
+
best, score = ({}, {}), None
|
|
221
|
+
for ln, row in zip(lines, rows):
|
|
222
|
+
if (mpn and ln.get("mpn") == mpn) or (not mpn and not ln.get("mpn") and ln.get("value", "") == val):
|
|
223
|
+
fps = {AL.lib_name(str(x).split(" ")[0].split(":")[-1]) for x in (ln.get("footprint"), row.get("local_footprint")) if x}
|
|
224
|
+
s = (not ln.get("dnp") and not str(row.get("status") or "").startswith("dnp"), bool(fps & want))
|
|
225
|
+
if score is None or s > score:
|
|
226
|
+
best, score = (ln, row), s
|
|
227
|
+
return best
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class Run:
|
|
231
|
+
def __init__(self, out, http, index, libs, sources, api_key=None, kicad_cache=None, refresh_index=False, gen_step=True,
|
|
232
|
+
rules=None, reader=None):
|
|
233
|
+
self.out, self.http, self.index, self.libs, self.sources = Path(out), http, index, libs, sources
|
|
234
|
+
self.rules = rules or RU.DEFAULTS
|
|
235
|
+
self.models = self.out / "models"
|
|
236
|
+
for d in ("PcbLib", "SchLib", "KiCad"):
|
|
237
|
+
(self.models / d).mkdir(parents=True, exist_ok=True)
|
|
238
|
+
self.api_key, self.written, self.gen_step = api_key, {}, gen_step
|
|
239
|
+
self.syms, self.hashes = {}, {} # SchLib path -> symbol dict; kicad_mod -> geometry hash
|
|
240
|
+
self._kicad, self._kicad_args = None, (kicad_cache, refresh_index)
|
|
241
|
+
self.reader, self.waiting = reader, [] # datasheet reader; parts waiting for its batch
|
|
242
|
+
|
|
243
|
+
def kicad(self):
|
|
244
|
+
if self._kicad is None:
|
|
245
|
+
f = Path(self._kicad_args[0] or "")
|
|
246
|
+
held = self._kicad_args[0] and KiCadLibrary._backoff(f.with_name(f.name + ".retry.json"), time.time())
|
|
247
|
+
if self._kicad_args[0] and not self.http.offline and (self._kicad_args[1] or not f.exists()) and \
|
|
248
|
+
(self._kicad_args[1] or not held):
|
|
249
|
+
getattr(self, "log", print)(" building the KiCad library name index from gitlab.com (first run only, "
|
|
250
|
+
"can take a few minutes; cached for 30 days; resumes if it stops) ...")
|
|
251
|
+
self._kicad = KiCadLibrary(self.http, *self._kicad_args)
|
|
252
|
+
return self._kicad
|
|
253
|
+
|
|
254
|
+
def rel(self, p):
|
|
255
|
+
return os.path.relpath(p, self.out).replace(os.sep, "/")
|
|
256
|
+
|
|
257
|
+
# ---- writers: everything goes through kicad_import's converters
|
|
258
|
+
def put_fp(self, st, text, name, source, http=None, height=None, height_src="", spec=None):
|
|
259
|
+
"""One file per footprint, shared only by parts whose pads are the same (ecadlib/fp_share.py)."""
|
|
260
|
+
bom_name = name
|
|
261
|
+
name, pcb, same, h = FS.pick(self.written, self.models, name, text, source, st["name"], self.out, self.hashes)
|
|
262
|
+
renamed = {"renamed_from": bom_name} if name != bom_name else {}
|
|
263
|
+
if same is not None:
|
|
264
|
+
st["footprint"] = {**{k: v for k, v in same.items() if k not in ("renamed_from", "chosen")},
|
|
265
|
+
"notes": [f"shared with {same['by']}"], "name": name, "hash": h, **renamed}
|
|
266
|
+
self._relink(st, bom_name, name)
|
|
267
|
+
return
|
|
268
|
+
pcb, stem = Path(pcb), AL.file_stem(name)
|
|
269
|
+
notes = [f"named {name}: footprint {bom_name} is another part's, with other pads"] if renamed else []
|
|
270
|
+
fp, t = K.convert_footprint(text, name, source.split(":", 1)[-1], notes)
|
|
271
|
+
try:
|
|
272
|
+
K.add_body(fp, t, http, height, False, notes, gen_step=self.gen_step, spec=spec, # a part-specific
|
|
273
|
+
height_src=(height_src or "caller") if height else None, # height beats the table
|
|
274
|
+
kind=st.get("kind"), names=st.get("names", ()))
|
|
275
|
+
except ValueError as e:
|
|
276
|
+
notes.append(f"no 3D body: {e}")
|
|
277
|
+
kmod = self.models / "KiCad" / f"{stem}.kicad_mod"
|
|
278
|
+
text = re.sub(r'^(\s*\((?:footprint|module)\s+)("(?:[^"\\]|\\.)*"|[^\s()]+)', # KiCad links a footprint by its file name,
|
|
279
|
+
lambda m: m.group(1) + '"' + stem.replace('"', '\\"') + '"', text, count=1) # Altium by this name: one name
|
|
280
|
+
text = re.sub(r'(\((?:fp_text\s+value|property\s+"Value")\s+)("(?:[^"\\]|\\.)*"|[^\s()]+)', # Value = the name (KLC F9.1)
|
|
281
|
+
lambda m: m.group(1) + '"' + stem.replace('"', '\\"') + '"', text, count=1)
|
|
282
|
+
kmod.write_text(text, encoding="utf-8", newline="\n")
|
|
283
|
+
AL.write_pcblib(pcb, [fp], RU.layers(self.rules))
|
|
284
|
+
st["footprint"] = {"source": source, "file": self.rel(pcb), "kicad_mod": self.rel(kmod), "notes": notes, "by": st["name"],
|
|
285
|
+
"name": name, "hash": h, **renamed}
|
|
286
|
+
self.written[str(pcb)] = st["footprint"]
|
|
287
|
+
self._relink(st, bom_name, name)
|
|
288
|
+
|
|
289
|
+
def _relink(self, st, bom_name, name):
|
|
290
|
+
"""A footprint renamed after this part's symbol was written: point the symbol's footprint link at it."""
|
|
291
|
+
rec = st.get("symbol") or {}
|
|
292
|
+
path = str(self.out / rec["file"]) if rec.get("file") else ""
|
|
293
|
+
sym = self.syms.get(path)
|
|
294
|
+
if name == bom_name or rec.get("by") != st["name"] or rec.get("chosen") or not sym or sym.get("footprint") != bom_name:
|
|
295
|
+
return
|
|
296
|
+
self.syms[path] = sym = dict(sym, footprint=name)
|
|
297
|
+
AL.write_schlib(path, [sym])
|
|
298
|
+
rec.setdefault("notes", []).append(f"footprint link {name} (the BOM's {bom_name} is another footprint)")
|
|
299
|
+
|
|
300
|
+
def put_sym(self, st, sym, source, notes=()):
|
|
301
|
+
path = self.models / "SchLib" / f"{AL.file_stem(sym['name'])}.SchLib"
|
|
302
|
+
fr = st.get("footprint") or {}
|
|
303
|
+
if fr.get("renamed_from") and sym.get("footprint") == fr["renamed_from"]: # footprint renamed (fp_share)
|
|
304
|
+
sym = dict(sym, footprint=fr["name"])
|
|
305
|
+
if str(path) in self.written:
|
|
306
|
+
st["symbol"] = dict(self.written[str(path)], notes=[f"shared with {self.written[str(path)]['by']}"])
|
|
307
|
+
return
|
|
308
|
+
AL.write_schlib(path, [sym])
|
|
309
|
+
self.syms[str(path)] = sym
|
|
310
|
+
st["symbol"] = {"source": source, "file": self.rel(path), "notes": list(notes), "by": st["name"]}
|
|
311
|
+
self.written[str(path)] = st["symbol"]
|
|
312
|
+
|
|
313
|
+
def put_kicad_sym(self, st, chain, name, fp_name, source, extra=(), layout=False):
|
|
314
|
+
"""layout: re-lay plain IC symbols out by the house pin rule (EasyEDA symbols). KiCad library symbols are curated
|
|
315
|
+
and kept as drawn, unless their pin text would overlap in Altium (symbol_layout.text_problems)."""
|
|
316
|
+
notes = []
|
|
317
|
+
sym = K.convert_symbol(chain, name, fp_name, list(extra), None, notes)
|
|
318
|
+
npins = len({pr[1]["num"] for pr in sym["prims"] if pr[0] == "pin"})
|
|
319
|
+
clash = SL.text_problems(sym)
|
|
320
|
+
if (layout or clash) and SL.wants_layout(npins, self.rules) and SL.is_box_ic(sym):
|
|
321
|
+
sym = SL.relayout(sym, self.rules)
|
|
322
|
+
notes.append(f"{npins} pins grouped by function (ecadlib.symbol_layout)" + (f", {sym['parts']} parts" if sym.get("parts", 1) > 1 else "")
|
|
323
|
+
+ (f"; the drawn symbol's pin text overlapped ({clash[0]})" if clash else ""))
|
|
324
|
+
elif clash:
|
|
325
|
+
notes.append(f"pin text overlaps ({'; '.join(clash[:3])}); not a plain IC box, so kept as drawn: check it")
|
|
326
|
+
self.put_sym(st, sym, source, notes)
|
|
327
|
+
|
|
328
|
+
# ---- per part
|
|
329
|
+
def resolve(self, item, ln, row, keep=None):
|
|
330
|
+
"""keep: halves a person chose with `ecadlib resolve` ({kind: state record}); they are not re-fetched."""
|
|
331
|
+
par = _par(item)
|
|
332
|
+
mpn = par.get("Manufacturer Part Number", "")
|
|
333
|
+
st = {"name": item["name"], "symbol": None, "footprint": None, "tried": [], "review": [], "verify": [],
|
|
334
|
+
"found": {}, "hint_fp": ""}
|
|
335
|
+
for k, rec in (keep or {}).items():
|
|
336
|
+
st[k] = rec
|
|
337
|
+
st["tried"].append(f"resolve: {k} {rec['source']} chosen by user (kept)")
|
|
338
|
+
if (row.get("status") or "").startswith("dnp"):
|
|
339
|
+
st["tried"].append("DNP line: not fetched")
|
|
340
|
+
return st
|
|
341
|
+
kind, fp_name, sym_name = passive_kind(item), item["footprint"]["ref"], item["symbol"]["ref"]
|
|
342
|
+
st["kind"], st["names"] = kind, [item["footprint"].get("kicad") or "", ln.get("footprint") or "", item.get("description") or ""]
|
|
343
|
+
try:
|
|
344
|
+
self.local(st, row, kind, fp_name, sym_name)
|
|
345
|
+
except Exception as e:
|
|
346
|
+
st["tried"].append(f"local library: failed ({type(e).__name__}: {e})")
|
|
347
|
+
for s in self.sources:
|
|
348
|
+
if st["symbol"] and st["footprint"]:
|
|
349
|
+
break
|
|
350
|
+
try:
|
|
351
|
+
getattr(self, f"src_{s}")(st, item, ln, row, kind, mpn, fp_name, sym_name)
|
|
352
|
+
except Exception as e: # one bad source or part must not stop the run
|
|
353
|
+
st["tried"].append(f"{s}: failed ({type(e).__name__}: {e})")
|
|
354
|
+
lc = (row.get("lcsc") or "").split(" ")[0]
|
|
355
|
+
if row.get("src_description"): # the part search's own text (JLCPCB / LCSC), after a KiCad symbol's
|
|
356
|
+
st["found"].setdefault("description", (row["src_description"], f"LCSC/JLCPCB part data {lc}".strip()))
|
|
357
|
+
return st
|
|
358
|
+
|
|
359
|
+
def local(self, st, row, kind, fp_name, sym_name):
|
|
360
|
+
ls, lf = (row.get("local_symbol") or "").split(" ")[0], (row.get("local_footprint") or "").split(" ")[0]
|
|
361
|
+
if ls and not kind and not st["symbol"]:
|
|
362
|
+
lib, name = ls.split(":", 1)
|
|
363
|
+
f = next((p for r in self.libs for p in Path(r).rglob(f"{lib}.kicad_sym")), None)
|
|
364
|
+
if f:
|
|
365
|
+
chain, _ = K.load_symbol(f"{f}:{name}", None)
|
|
366
|
+
st["hint_fp"] = K._props(chain).get("Footprint", "")
|
|
367
|
+
self.put_kicad_sym(st, chain, sym_name, fp_name, f"local:{ls}")
|
|
368
|
+
if lf and lf in self.index.footprints and not st["footprint"]:
|
|
369
|
+
text = Path(self.index.footprints[lf]["path"]).read_text(encoding="utf-8")
|
|
370
|
+
spec = SPECS / f"{lf.split(':')[-1]}.json"
|
|
371
|
+
hm = json.loads(spec.read_text(encoding="utf-8")).get("height_max") if spec.exists() else None
|
|
372
|
+
self.put_fp(st, text, fp_name, f"local:{lf}", None, F.dim(hm)[2] if hm else None, f"package spec {spec.name}")
|
|
373
|
+
if ls or lf:
|
|
374
|
+
st["tried"].append(f"local library: symbol {'from ' + ls if st['symbol'] else 'not used' if ls else 'none'}, "
|
|
375
|
+
f"footprint {'from ' + lf if st['footprint'] else 'none'}")
|
|
376
|
+
|
|
377
|
+
def src_kicad(self, st, item, ln, row, kind, mpn, fp_name, sym_name):
|
|
378
|
+
lib = self.kicad()
|
|
379
|
+
if not lib.symbols:
|
|
380
|
+
st["tried"].append(f"kicad: library index unavailable ({lib.error})")
|
|
381
|
+
return
|
|
382
|
+
sym_ref = how = fp_ref = fp_how = None
|
|
383
|
+
if kind:
|
|
384
|
+
sym_ref, how = PASSIVE[kind][0], "generic passive symbol"
|
|
385
|
+
pkg = package_of(item["footprint"].get("kicad") or fp_name, item.get("description"))
|
|
386
|
+
if PASSIVE[kind][1] and pkg in IMP2MET:
|
|
387
|
+
fp_ref, fp_how = lib.footprint(PASSIVE[kind][1].format(pkg, IMP2MET[pkg])), f"by package {pkg}"
|
|
388
|
+
elif not st["symbol"]:
|
|
389
|
+
sym_ref, how = lib.find_symbol(mpn)
|
|
390
|
+
if not sym_ref:
|
|
391
|
+
sym_ref, how = lib.generic_connector(ln.get("value", ""))
|
|
392
|
+
chain = None
|
|
393
|
+
if sym_ref and not st["symbol"]:
|
|
394
|
+
chain, _ = K.load_symbol(sym_ref, self.http)
|
|
395
|
+
props = K._props(chain)
|
|
396
|
+
st["hint_fp"] = st["hint_fp"] or props.get("Footprint", "")
|
|
397
|
+
if str(props.get("Datasheet", "")).startswith("http"):
|
|
398
|
+
st["found"].setdefault("datasheet", (props["Datasheet"], f"KiCad symbol {sym_ref}"))
|
|
399
|
+
if props.get("Description"):
|
|
400
|
+
st["found"].setdefault("description", (props["Description"], f"KiCad symbol {sym_ref}"))
|
|
401
|
+
shared = kind in ("R", "C", "CP", "L") # one generic symbol for every value: no MPN, no footprint link
|
|
402
|
+
extra = [] if shared or not mpn else [("MPN", mpn)]
|
|
403
|
+
self.put_kicad_sym(st, chain, sym_name, None if shared else fp_name, f"kicad:{sym_ref}", extra)
|
|
404
|
+
st["tried"].append(f"kicad: symbol {sym_ref} ({how})")
|
|
405
|
+
if "check" in how:
|
|
406
|
+
st["review"].append(f"symbol {sym_ref} matched by {how}")
|
|
407
|
+
elif not st["symbol"]:
|
|
408
|
+
st["tried"].append(f"kicad: no symbol named like {mpn or '(no MPN)'}")
|
|
409
|
+
if st["footprint"]:
|
|
410
|
+
return
|
|
411
|
+
bom_fp = ln.get("footprint", "")
|
|
412
|
+
for cand, why in ((fp_ref, fp_how), (lib.footprint(st["hint_fp"]), "symbol's Footprint property"), (lib.footprint(bom_fp), "BOM footprint name"),
|
|
413
|
+
(None if kind else lib.footprint_by_mpn(mpn), "footprint named after the MPN"),
|
|
414
|
+
(lib.footprint_by_prefix(bom_fp.split(":")[-1]), f"shortest KiCad name starting with '{bom_fp}'")):
|
|
415
|
+
if cand:
|
|
416
|
+
text, _ = K.load_footprint(cand, self.http)
|
|
417
|
+
self.put_fp(st, text, fp_name, f"kicad:{cand}", self.http)
|
|
418
|
+
st["tried"].append(f"kicad: footprint {cand} ({why})")
|
|
419
|
+
if why.startswith(("shortest", "footprint named")):
|
|
420
|
+
st["review"].append(f"footprint {cand} chosen by {why}: compare with the datasheet land pattern")
|
|
421
|
+
return
|
|
422
|
+
st["tried"].append(f"kicad: no footprint for '{bom_fp or fp_name}'")
|
|
423
|
+
|
|
424
|
+
def src_easyeda(self, st, item, ln, row, kind, mpn, fp_name, sym_name):
|
|
425
|
+
m = re.match(r"C\d+", ln.get("lcsc") or row.get("lcsc") or "")
|
|
426
|
+
if not m:
|
|
427
|
+
st["tried"].append("easyeda: no LCSC number")
|
|
428
|
+
return
|
|
429
|
+
lcsc = m.group(0)
|
|
430
|
+
r = self.http.get(P.EasyEDA.URL.format(lcsc), headers=P.EasyEDA.HEADERS)
|
|
431
|
+
if not r.ok:
|
|
432
|
+
st["tried"].append(f"easyeda {lcsc}: {r.error or f'HTTP {r.status}'}")
|
|
433
|
+
return
|
|
434
|
+
data = r.json()
|
|
435
|
+
res = P.parse_easyeda(data, P.SourceResult("easyeda", lcsc=lcsc))
|
|
436
|
+
if not res.found:
|
|
437
|
+
st["tried"].append(f"easyeda {lcsc}: no component")
|
|
438
|
+
return
|
|
439
|
+
if mpn and res.mpn and norm_mpn(res.mpn) != norm_mpn(mpn):
|
|
440
|
+
st["tried"].append(f"easyeda {lcsc}: is {res.mpn}, BOM says {mpn}; not used")
|
|
441
|
+
st["review"].append(f"LCSC {lcsc} is {res.mpn}, not {mpn}: fix the BOM's LCSC number")
|
|
442
|
+
return
|
|
443
|
+
result = data["result"]
|
|
444
|
+
url = (result.get("lcsc") or {}).get("url") or ""
|
|
445
|
+
if url.startswith("http"):
|
|
446
|
+
st["found"].setdefault("datasheet", (url, f"EasyEDA {lcsc} (lcsc.url)"))
|
|
447
|
+
if res.manufacturer:
|
|
448
|
+
st["found"].setdefault("manufacturer", (res.manufacturer, f"EasyEDA {lcsc}"))
|
|
449
|
+
st["found"].setdefault("lcsc", (lcsc, f"EasyEDA {lcsc}" + ("" if ln.get("lcsc") else " (LCSC number found by ecadlib, not in the BOM)")))
|
|
450
|
+
if res.extra.get("description"):
|
|
451
|
+
st["found"].setdefault("description", (res.extra["description"], f"EasyEDA {lcsc}"))
|
|
452
|
+
if res.package:
|
|
453
|
+
st["found"].setdefault("package", (res.package, f"EasyEDA {lcsc}"))
|
|
454
|
+
got = []
|
|
455
|
+
if not st["footprint"]:
|
|
456
|
+
pkg = (result.get("packageDetail") or {})
|
|
457
|
+
notes = []
|
|
458
|
+
text = EC.footprint_to_kicad(pkg.get("dataStr") or {}, pkg.get("title") or fp_name, notes)
|
|
459
|
+
text = FT.tidy(text, notes) # to the rule book: silk reference, pin 1, rounded pads
|
|
460
|
+
self.put_fp(st, text, fp_name, f"easyeda:{lcsc}", None)
|
|
461
|
+
st["footprint"]["notes"] = notes + st["footprint"]["notes"]
|
|
462
|
+
got.append(f"footprint {pkg.get('title', '')}")
|
|
463
|
+
if not st["symbol"]:
|
|
464
|
+
ds = result.get("dataStr") or {}
|
|
465
|
+
if not ds.get("shape") and result.get("subparts"):
|
|
466
|
+
st["tried"].append(f"easyeda {lcsc}: multi-unit symbol (subparts) not converted")
|
|
467
|
+
else:
|
|
468
|
+
notes = []
|
|
469
|
+
text = EC.symbol_to_kicad(ds, sym_name, fp_name, {"MPN": mpn, "LCSC": lcsc, "Manufacturer": res.manufacturer}, notes)
|
|
470
|
+
chain = [K._find_symbol(sexpr.parse(text), sym_name)]
|
|
471
|
+
self.put_kicad_sym(st, chain, sym_name, fp_name, f"easyeda:{lcsc}", [("MPN", mpn)] if mpn else [], layout=True)
|
|
472
|
+
st["symbol"]["notes"] = notes + st["symbol"]["notes"]
|
|
473
|
+
got.append("symbol")
|
|
474
|
+
if got:
|
|
475
|
+
st["found"].setdefault("credit", (EC.CREDIT, f"EasyEDA {lcsc}"))
|
|
476
|
+
st["tried"].append(f"easyeda {lcsc}: " + (", ".join(got) or "nothing needed"))
|
|
477
|
+
st["review"].append(f"EasyEDA {lcsc} conversion: check pads and pins against the datasheet (native converter, format assumed)")
|
|
478
|
+
|
|
479
|
+
def src_standard(self, st, item, ln, row, kind, mpn, fp_name, sym_name):
|
|
480
|
+
"""Standard packages and pinouts, built offline (ecadlib.stdparts); fills only what is still missing."""
|
|
481
|
+
from . import stdparts as SP
|
|
482
|
+
desc = " ".join(dict.fromkeys(str(x) for x in (item.get("description"), ln.get("description")) if x))
|
|
483
|
+
k = kind or SP.part_kind(item["folder"], item["footprint"].get("kicad") or fp_name, desc, ln.get("designator"))
|
|
484
|
+
res = SP.resolve([st["hint_fp"], ln.get("footprint"), item["footprint"].get("kicad"), ln.get("value")], k, mpn, desc)
|
|
485
|
+
got = []
|
|
486
|
+
if not st["footprint"] and res["footprint"]:
|
|
487
|
+
sp = SP.spec(res["footprint"])
|
|
488
|
+
text, _ = SP.build(res["footprint"], RU.density_for(sp, self.rules))
|
|
489
|
+
h = F.dim(sp["height_max"])[2] if sp.get("height_max") else None
|
|
490
|
+
self.put_fp(st, text, fp_name, f"standard:{res['kicad_ref']}", None, h, "standard package table", sp)
|
|
491
|
+
got.append(f"footprint {res['kicad_ref']} ({res['fp_how']})")
|
|
492
|
+
fp_src = str((st["footprint"] or {}).get("source") or "")
|
|
493
|
+
same_fp = res["footprint"] and fp_src.endswith(":" + res["footprint"])
|
|
494
|
+
if not st["symbol"] and res["symbol"] and (k in ("R", "C", "CP", "L") or same_fp):
|
|
495
|
+
text, name = SP.symbol_text(res["symbol"], mpn)
|
|
496
|
+
shared = k in ("R", "C", "CP", "L")
|
|
497
|
+
self.put_kicad_sym(st, [K._find_symbol(sexpr.parse(text), name)], sym_name, None if shared else fp_name,
|
|
498
|
+
f"standard:{name}", [] if shared or not mpn else [("MPN", mpn)])
|
|
499
|
+
got.append(f"symbol {name} ({res['sym_how']})")
|
|
500
|
+
if ":" in res["symbol"]:
|
|
501
|
+
st["review"].append(f"symbol pinout {name} from the {mpn} family table (ecadlib.stdparts): check pins 1-3 against the datasheet")
|
|
502
|
+
elif not st["symbol"] and res["symbol"]:
|
|
503
|
+
res["sym_how"] = f"footprint came from {fp_src or 'nowhere'}, not {res['footprint']}: pin numbers not known to match"
|
|
504
|
+
why = [] if st["footprint"] else [f"footprint: {res['fp_how']}"]
|
|
505
|
+
why += [] if st["symbol"] else [f"symbol: {res['sym_how']}"]
|
|
506
|
+
st["tried"].append("standard: " + "; ".join(got + why))
|
|
507
|
+
|
|
508
|
+
def src_datasheet(self, st, item, ln, row, kind, mpn, fp_name, sym_name):
|
|
509
|
+
urls = D.datasheet_urls(item, ln, row, st["found"])
|
|
510
|
+
if not urls:
|
|
511
|
+
st["tried"].append("datasheet: no datasheet URL known")
|
|
512
|
+
return
|
|
513
|
+
if self.reader is None:
|
|
514
|
+
self.reader = D.Reader(self.out, Path(self._kicad_args[0] or self.out / "cache").parent, self.api_key, self.rules, self.http)
|
|
515
|
+
rd = self.reader
|
|
516
|
+
if not rd.key and not rd.estimate_only:
|
|
517
|
+
st["tried"].append(f"needs datasheet reader (no API key): {urls[0][0]} ({urls[0][1]})")
|
|
518
|
+
return
|
|
519
|
+
job = rd.job(mpn or item["name"], urls, fp_name, name=item["name"], fp_needed=not st["footprint"])
|
|
520
|
+
try:
|
|
521
|
+
res = hit = rd.cached(job)
|
|
522
|
+
if res is None and rd.deferred:
|
|
523
|
+
rd.submit(job)
|
|
524
|
+
self.waiting.append((st, job, fp_name, sym_name))
|
|
525
|
+
st["tried"].append(f"datasheet: queued for the batch ({job['url']}, {job['where']})")
|
|
526
|
+
return
|
|
527
|
+
res = res or rd.read_now(job)
|
|
528
|
+
except CC.Held as e:
|
|
529
|
+
self.cross_check_held(st, e)
|
|
530
|
+
return
|
|
531
|
+
self.put_datasheet(st, res, job, fp_name, sym_name, "cached" if hit else "")
|
|
532
|
+
|
|
533
|
+
@staticmethod
|
|
534
|
+
def cross_check_held(st, e):
|
|
535
|
+
"""A datasheet read whose cross-check disagreed: nothing is written; both values go to the review."""
|
|
536
|
+
st["cross_check"] = {"status": "disagreed", "summary": CC.summary_line(e.record)}
|
|
537
|
+
st["tried"].append("datasheet reader: held, the cross-check disagrees (see datasheet/*.review.md)")
|
|
538
|
+
st["review"].append(f"cross-check disagrees, nothing written: {CC.summary_line(e.record)}")
|
|
539
|
+
|
|
540
|
+
def flush_datasheets(self, log=print):
|
|
541
|
+
"""Send the parts queued for the datasheet batch and build what came back."""
|
|
542
|
+
if not self.waiting:
|
|
543
|
+
return
|
|
544
|
+
try:
|
|
545
|
+
done = self.reader.flush()
|
|
546
|
+
except D.DatasheetError as e:
|
|
547
|
+
done = {job["key"]: e for _, job, _, _ in self.waiting}
|
|
548
|
+
for st, job, fp_name, sym_name in self.waiting:
|
|
549
|
+
r = done.get(job["key"])
|
|
550
|
+
if isinstance(r, CC.Held):
|
|
551
|
+
self.cross_check_held(st, r)
|
|
552
|
+
elif isinstance(r, dict):
|
|
553
|
+
try:
|
|
554
|
+
self.put_datasheet(st, r, job, fp_name, sym_name, r["sidecar"].get("via") or "batch")
|
|
555
|
+
except Exception as e:
|
|
556
|
+
st["tried"].append(f"datasheet: failed ({type(e).__name__}: {e})")
|
|
557
|
+
elif isinstance(r, Exception):
|
|
558
|
+
st["tried"].append(f"datasheet reader: failed ({r})")
|
|
559
|
+
else:
|
|
560
|
+
st["tried"].append(f"datasheet: {r or 'not sent'}" + ("; run again to collect" if str(r).startswith("pending") else ""))
|
|
561
|
+
self.waiting = []
|
|
562
|
+
|
|
563
|
+
def put_datasheet(self, st, res, job, fp_name, sym_name, how=""):
|
|
564
|
+
url, where = job["url"], job["where"]
|
|
565
|
+
got = []
|
|
566
|
+
if not st["footprint"]:
|
|
567
|
+
text, _ = F.build(res["spec"], RU.density_for(res["spec"], self.rules))
|
|
568
|
+
h = F.dim(res["spec"]["height_max"])[2] if res["spec"].get("height_max") else None
|
|
569
|
+
self.put_fp(st, text, fp_name, f"datasheet:{url}", None, h, "datasheet height_max", res["spec"])
|
|
570
|
+
got.append("footprint")
|
|
571
|
+
if not st["symbol"]:
|
|
572
|
+
from . import house_style as HS # active-low pins in your library's notation ([style])
|
|
573
|
+
sym, renamed = HS.adapt_symbol(res["symbol"], HS.active(self.rules, self.out))
|
|
574
|
+
notes = [f"{len(sym['pins'])} pins grouped by function (ecadlib.symbol_layout)"] \
|
|
575
|
+
if SL.wants_layout(sym["pins"], self.rules) else []
|
|
576
|
+
if renamed:
|
|
577
|
+
notes.append("house style pin names: " + ", ".join(renamed[:6]) + (" ..." if len(renamed) > 6 else ""))
|
|
578
|
+
self.put_sym(st, AL.symbol_from_json(dict(sym, name=sym_name, footprint=fp_name), self.rules), f"datasheet:{url}",
|
|
579
|
+
notes)
|
|
580
|
+
got.append("symbol")
|
|
581
|
+
rt = res["sidecar"].get("route") or {}
|
|
582
|
+
n_ans = len(rt.get("attempts") or [])
|
|
583
|
+
st["tried"].append(f"datasheet reader ({where}{', ' + how if how else ''}): {', '.join(got) or 'nothing needed'}; "
|
|
584
|
+
f"{len(res['needs_review'])} review items"
|
|
585
|
+
+ (f"; read by {rt['model']}" + (f" after {n_ans - 1} earlier answers (see review.md)" if n_ans > 1 else "")
|
|
586
|
+
if rt.get("model") else ""))
|
|
587
|
+
cc = res.get("cross_check")
|
|
588
|
+
if cc:
|
|
589
|
+
st["cross_check"] = {"status": cc["status"], "summary": CC.summary_line(cc)}
|
|
590
|
+
st["tried"].append(f"cross-check ({cc['mode']}): {cc['status']}: {CC.summary_line(cc)}")
|
|
591
|
+
st["found"].setdefault("datasheet", (url, f"datasheet reader ({where})"))
|
|
592
|
+
st["verify"].append(f"datasheet reader output ({', '.join(got)}): compare with {url}; see datasheet/*.review.md"
|
|
593
|
+
+ (f" ({len(res['needs_review'])} items flagged)" if res["needs_review"] else ""))
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _fingerprint(item, ln, row):
|
|
597
|
+
return json.dumps([item["symbol"]["ref"], item["footprint"]["ref"], ln.get("mpn"), ln.get("lcsc"), ln.get("value"),
|
|
598
|
+
ln.get("footprint"), ln.get("datasheet"), row.get("local_symbol"), row.get("local_footprint")])
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
BUILD_REV = 2 # bump when a source's conversion changes what it writes (1: before the build_rev stamp)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _built_now(st, rules):
|
|
605
|
+
"""True when this ecadlib (version and BUILD_REV) built the part with these rules. A held part built otherwise is
|
|
606
|
+
built again on the next run: it is not in the library yet, and keeping it would make the release check judge the
|
|
607
|
+
old version's models and fields (silk not cut back, no Description). Ready parts are kept as they are (updates
|
|
608
|
+
never change a library's parts behind its back; `--refresh` rebuilds them)."""
|
|
609
|
+
from .rebuild import rules_hash
|
|
610
|
+
bw = st.get("built_with") or {}
|
|
611
|
+
return bw.get("ecadlib") == __version__ and st.get("build_rev") == BUILD_REV and bw.get("rules_sha256") == rules_hash(rules)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _done(st, out):
|
|
615
|
+
return all(st.get(k) and (Path(out) / st[k]["file"]).exists() for k in ("symbol", "footprint"))
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
GENERATED_DESCRIPTION = "written by ecadlib from the part's category and package (check it)"
|
|
619
|
+
SINGULAR = {"ICs": "IC", "Resistors": "Resistor", "Capacitors": "Capacitor", "Inductors": "Inductor", "Diodes": "Diode",
|
|
620
|
+
"Transistors": "Transistor", "Connectors": "Connector", "Crystals": "Crystal", "Oscillators": "Oscillator",
|
|
621
|
+
"Switches": "Switch", "Relays": "Relay", "Fuses": "Fuse", "LEDs": "LED", "Sensors": "Sensor"}
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _package_name(text, mpn=""):
|
|
625
|
+
"""'SOIC-8_3.9x4.9mm_P1.27mm' / 'SOIC-8_L4.9-W3.9-P1.27-LS6.0-BL' -> 'SOIC-8', 'CAP 0603 (1608 Metric)' -> '0603';
|
|
626
|
+
'' when it names no package ecadlib knows (an MPN, a maker's series)."""
|
|
627
|
+
t = str(text or "").split(":")[-1].strip()
|
|
628
|
+
pkg = package_of(t) if t and norm_mpn(t) != norm_mpn(mpn) else None
|
|
629
|
+
if not pkg:
|
|
630
|
+
return ""
|
|
631
|
+
first = re.split(r"[_ ]", t)[0]
|
|
632
|
+
return first if first.replace("-", "").upper() == pkg.upper() else pkg
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def generated_description(item, st, ln):
|
|
636
|
+
"""A plain description when no source gave one: '<category>, <package>, <manufacturer> <MPN>' from what the
|
|
637
|
+
plan already knows (nothing is guessed about what the part does). '' when there is not even a part number."""
|
|
638
|
+
par = _par(item)
|
|
639
|
+
mpn = str(par.get("Manufacturer Part Number") or ln.get("mpn") or "").strip()
|
|
640
|
+
if not mpn:
|
|
641
|
+
return ""
|
|
642
|
+
folder = str(item.get("folder") or "").rstrip("/").rsplit("/", 1)[-1]
|
|
643
|
+
cat = SINGULAR.get(folder, folder if folder not in ("", "Misc", "Components") else "")
|
|
644
|
+
pkg = next((p for p in (_package_name((st.get("found", {}).get("package") or ("",))[0], mpn),
|
|
645
|
+
_package_name((st.get("footprint") or {}).get("name"), mpn),
|
|
646
|
+
_package_name(item.get("footprint", {}).get("kicad"), mpn),
|
|
647
|
+
_package_name(ln.get("footprint"), mpn)) if p), "")
|
|
648
|
+
who = " ".join(x for x in (str(par.get("Manufacturer") or "").strip(), mpn) if x)
|
|
649
|
+
return ", ".join(x for x in (cat, pkg, who) if x)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def apply_state(item, st, ln):
|
|
653
|
+
"""Copy a part's state into its plan item: model files, sources, filled-in fields, what was tried."""
|
|
654
|
+
for k in ("symbol", "footprint"):
|
|
655
|
+
if st.get(k):
|
|
656
|
+
item[k]["file"] = st[k]["file"]
|
|
657
|
+
if k == "footprint":
|
|
658
|
+
item[k]["kicad_mod"] = st[k].get("kicad_mod")
|
|
659
|
+
was = item[k].get("bom_ref") or item[k].get("ref")
|
|
660
|
+
if st[k].get("name") and st[k].get("renamed_from") == was: # another footprint has the BOM's name
|
|
661
|
+
item[k]["bom_ref"], item[k]["ref"] = was, st[k]["name"]
|
|
662
|
+
item["source"] = {k: (st.get(k) or {}).get("source") for k in ("symbol", "footprint")}
|
|
663
|
+
par, fields = _par(item), {}
|
|
664
|
+
if ln.get("datasheet", "").startswith("http"):
|
|
665
|
+
_set_par(item, "ComponentLink2Description", "Datasheet"); _set_par(item, "ComponentLink2URL", ln["datasheet"]); fields["ComponentLink2URL"] = "BOM"
|
|
666
|
+
elif par.get("ComponentLink2URL"):
|
|
667
|
+
fields["ComponentLink2URL"] = "LCSC datasheet URL pattern (not fetched)"
|
|
668
|
+
elif "datasheet" in st.get("found", {}):
|
|
669
|
+
u, w = st["found"]["datasheet"]
|
|
670
|
+
_set_par(item, "ComponentLink2Description", "Datasheet"); _set_par(item, "ComponentLink2URL", u); fields["ComponentLink2URL"] = w
|
|
671
|
+
if not par.get("Supplier Part Number 1") and "lcsc" in st.get("found", {}): # an LCSC number the BOM did not give
|
|
672
|
+
v, w = st["found"]["lcsc"]
|
|
673
|
+
_set_par(item, "Supplier 1", "LCSC"); _set_par(item, "Supplier Part Number 1", v); fields["Supplier Part Number 1"] = w
|
|
674
|
+
if not par.get("ComponentLink1URL"):
|
|
675
|
+
_set_par(item, "ComponentLink1Description", "LCSC")
|
|
676
|
+
_set_par(item, "ComponentLink1URL", f"https://www.lcsc.com/product-detail/{v}.html"); fields["ComponentLink1URL"] = w
|
|
677
|
+
if not par.get("Manufacturer") and "manufacturer" in st.get("found", {}):
|
|
678
|
+
v, w = st["found"]["manufacturer"]
|
|
679
|
+
_set_par(item, "Manufacturer", v); fields["Manufacturer"] = w
|
|
680
|
+
if "credit" in st.get("found", {}): # EasyEDA's library data asks for this credit on every reuse
|
|
681
|
+
v, w = st["found"]["credit"]
|
|
682
|
+
_set_par(item, "Library Credit", v); fields["Library Credit"] = w
|
|
683
|
+
if not str(item.get("description") or "").strip() and "description" in st.get("found", {}):
|
|
684
|
+
item["description"], fields["Description"] = st["found"]["description"]
|
|
685
|
+
if not str(item.get("description") or "").strip(): # no source gave one: write a plain one to check
|
|
686
|
+
desc = generated_description(item, st, ln)
|
|
687
|
+
if desc:
|
|
688
|
+
item["description"], fields["Description"] = desc, GENERATED_DESCRIPTION
|
|
689
|
+
item["field_sources"] = fields
|
|
690
|
+
item["verify"] = list(st.get("verify", []))
|
|
691
|
+
if st.get("cross_check"): # ecadlib.crosscheck: agreed / disagreed (held) / unchecked / skipped
|
|
692
|
+
item["cross_check"] = dict(st["cross_check"])
|
|
693
|
+
else:
|
|
694
|
+
item.pop("cross_check", None)
|
|
695
|
+
item["librarian"] = {"tried": st.get("tried", []), "review": st.get("review", []),
|
|
696
|
+
"notes": [n for k in ("symbol", "footprint") for n in (st.get(k) or {}).get("notes", [])]}
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def review_rows(items):
|
|
700
|
+
rows = []
|
|
701
|
+
for it in items:
|
|
702
|
+
if it.get("a365") and it.get("status") in ("existing", "deleted_in_workspace"):
|
|
703
|
+
rows.append({"part": it["name"], "status": it["status"], "symbol_from": (it.get("source") or {}).get("symbol") or "",
|
|
704
|
+
"footprint_from": (it.get("source") or {}).get("footprint") or "",
|
|
705
|
+
"needs_human": "yes" if it["status"] != "existing" else "",
|
|
706
|
+
"why": "deleted in the Altium 365 workspace" if it["status"] != "existing" else "", "tried": ""})
|
|
707
|
+
continue
|
|
708
|
+
why = [f"no {k} found" for k in ("symbol", "footprint") if not it[k].get("file")]
|
|
709
|
+
if why:
|
|
710
|
+
why += it["librarian"]["tried"]
|
|
711
|
+
why += [h for h in it.get("hold_reasons", []) if not re.match(r"files: no (symbol|footprint) model", h)] + it["librarian"]["review"]
|
|
712
|
+
rows.append({"part": it["name"], "status": it["status"], "symbol_from": it["source"]["symbol"] or "",
|
|
713
|
+
"footprint_from": it["source"]["footprint"] or "", "needs_human": "yes" if why else "",
|
|
714
|
+
"why": "; ".join(dict.fromkeys(why)), "tried": "; ".join(it["librarian"]["tried"]),
|
|
715
|
+
"cross_check": (it.get("cross_check") or {}).get("status", "")})
|
|
716
|
+
return rows
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def write_review(rows, out, bom, items=None):
|
|
720
|
+
"""review.csv (machine columns, unchanged), review.md and review.html (ecadlib.reports: outcome first, held parts
|
|
721
|
+
with a plain reason and the next step). items: the plan items (default: <out>/plan.json, written just before)."""
|
|
722
|
+
out = Path(out)
|
|
723
|
+
with open(out / "review.csv", "w", newline="", encoding="utf-8-sig") as f:
|
|
724
|
+
w = SS.DictWriter(f, fieldnames=list(rows[0]) if rows else ["part"])
|
|
725
|
+
w.writeheader(); w.writerows(rows)
|
|
726
|
+
if items is None:
|
|
727
|
+
try:
|
|
728
|
+
items = json.loads((out / "plan.json").read_text(encoding="utf-8"))
|
|
729
|
+
except (OSError, ValueError):
|
|
730
|
+
items = [{"name": r["part"], "status": r["status"], "source": {"symbol": r["symbol_from"], "footprint": r["footprint_from"]},
|
|
731
|
+
"librarian": {"tried": r["tried"].split("; ") if r["tried"] else [], "review": []}} for r in rows]
|
|
732
|
+
src = lambda s: M.SOURCES.get((s or "-").split(":")[0], "none")
|
|
733
|
+
counts = {}
|
|
734
|
+
for r in rows:
|
|
735
|
+
k = (src(r["symbol_from"]), src(r["footprint_from"]))
|
|
736
|
+
counts[k] = counts.get(k, 0) + 1
|
|
737
|
+
from . import reports
|
|
738
|
+
details = ["### Where the parts came from", "", "| Symbol from | Footprint from | Parts |", "|---|---|---|"]
|
|
739
|
+
details += [f"| {a} | {b} | {n} |" for (a, b), n in sorted(counts.items())]
|
|
740
|
+
details += ["", "### What was tried, per part", ""] + [f"- **{r['part']}**: " + ("; ".join(reports.tried_line(t) for t in r["tried"].split("; ")) or "-") for r in rows]
|
|
741
|
+
details += CC.review_lines(rows) # which datasheet parts were cross-checked, agreed, or held
|
|
742
|
+
from . import ds_route
|
|
743
|
+
details += ds_route.review_lines(out) # datasheet reads: model, pages, tokens and USD per answer
|
|
744
|
+
reports.write_review_files(items, out, bom, details)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def run(bom, libs=(), out="out/librarian", sources=None, refresh=None, refresh_index=False, allow=(), offline=False,
|
|
748
|
+
cache="out/http_cache", rate=0.5, providers=None, http=None, api_key=None, log=print, gen_step=True,
|
|
749
|
+
rules=None, user=None, datasheet_batch=False, reader=None, progress=None):
|
|
750
|
+
"""Whole pipeline; returns the plan items. datasheet_batch: parts that reach the datasheet source are read as one
|
|
751
|
+
Message Batch after every other source ran (reader: a ready datasheet.Reader, e.g. for --no-wait / --estimate). refresh: None = resume, [] = redo all, [names] = redo those.
|
|
752
|
+
rules: library rules (a loaded dict or a path); None uses the ecadlib.toml found from `out` (defaults when none).
|
|
753
|
+
sources: None = the rules' [sources] order. user: who gives the --allow (default: approvals.identity()).
|
|
754
|
+
progress: a term.Progress-like object (start(name) / done(name, status, detail)) for the per-part line."""
|
|
755
|
+
out = Path(out); out.mkdir(parents=True, exist_ok=True)
|
|
756
|
+
from . import migrate as MG, rebuild as LK # versioned files (backed up, migrated) and library.lock.json
|
|
757
|
+
MG.open_folder(out, log=log)
|
|
758
|
+
rules = RU.resolve(rules, out)
|
|
759
|
+
sources = list(sources) if sources is not None else list(rules["sources"]["order"])
|
|
760
|
+
http = http or CachedHTTP(cache, min_interval=rate, offline=offline, timeout=60)
|
|
761
|
+
bad = set(sources) - set(SOURCES)
|
|
762
|
+
if bad:
|
|
763
|
+
raise ValueError(f"unknown sources {sorted(bad)}; use {','.join(SOURCES)}")
|
|
764
|
+
if "standard" in sources and "kicad" in sources and sources.index("standard") < sources.index("kicad"):
|
|
765
|
+
sources.remove("standard") # never ahead of the KiCad library
|
|
766
|
+
sources.insert(sources.index("kicad") + 1, "standard")
|
|
767
|
+
if "standard" in sources and "datasheet" in sources and sources.index("datasheet") < sources.index("standard"):
|
|
768
|
+
from .llm import run as LR # rail prefer_standard: a guided (smaller) model reads datasheets
|
|
769
|
+
if LR.wants(rules, "prefer_standard"): # only for what the offline standard parts can't build
|
|
770
|
+
sources.remove("standard")
|
|
771
|
+
sources.insert(sources.index("datasheet"), "standard")
|
|
772
|
+
lines, mapping = T.read_bom(bom, RU.bom_columns(rules))
|
|
773
|
+
if not lines:
|
|
774
|
+
raise ValueError(f"{bom}: the BOM has a header but no part rows")
|
|
775
|
+
log(f" BOM: {len(lines)} lines; looking them up in your libraries and the part sources (triage) ...")
|
|
776
|
+
index = KiCadIndex(libs)
|
|
777
|
+
from . import plugins as PL # providers: None = [providers] order, else jlcpcb_search,easyeda
|
|
778
|
+
provs = [c(http) for c in PL.select(providers, rules, default="jlcpcb_search,easyeda")]
|
|
779
|
+
rows = T.triage(lines, index, provs, http)
|
|
780
|
+
T.write_reports(rows, mapping, provs, http, out / "triage", bom)
|
|
781
|
+
items = A365.plan(lines, pd.DataFrame(rows, dtype=str).fillna("") if rows else None, rules=rules)
|
|
782
|
+
state_f, plan_f = out / "librarian_state.json", out / "plan.json"
|
|
783
|
+
state = json.loads(state_f.read_text(encoding="utf-8")) if state_f.exists() else {}
|
|
784
|
+
old = {i["name"]: i for i in json.loads(plan_f.read_text(encoding="utf-8"))} if plan_f.exists() else {}
|
|
785
|
+
items, in_ws = A365S.reuse_existing(items, list(old.values()), rules) # parts `ecadlib a365 pull` found in the workspace
|
|
786
|
+
if reader is None and "datasheet" in sources:
|
|
787
|
+
reader = D.Reader(out, cache, api_key, rules, http, batch=datasheet_batch, log=log)
|
|
788
|
+
r = Run(out, http, index, libs, list(sources), api_key, Path(cache) / "kicad_library_index.json", refresh_index, gen_step, rules,
|
|
789
|
+
reader)
|
|
790
|
+
todo = []
|
|
791
|
+
for it in items:
|
|
792
|
+
if it["name"] in in_ws:
|
|
793
|
+
continue
|
|
794
|
+
ln, row = _line_for(it, lines, rows)
|
|
795
|
+
if not it["footprint"]["ref"]:
|
|
796
|
+
it["footprint"]["ref"] = AL.lib_name(it["name"])
|
|
797
|
+
fpr, st = _fingerprint(it, ln, row), state.get(it["name"])
|
|
798
|
+
redo = refresh is not None and (not refresh or it["name"] in refresh)
|
|
799
|
+
stale = bool(st) and (old.get(it["name"]) or {}).get("status") == "held" and not _built_now(st, rules)
|
|
800
|
+
if stale and st.get("key") == fpr and _done(st, out) and not redo:
|
|
801
|
+
bw = st.get("built_with") or {}
|
|
802
|
+
why = (f"built by ecadlib {bw['ecadlib']}" if bw.get("ecadlib") and bw["ecadlib"] != __version__ else
|
|
803
|
+
"built with other rules" if st.get("build_rev") == BUILD_REV else "built by an older ecadlib")
|
|
804
|
+
log(f" {it['name']}: held, {why}: built again, so its models, fields and release check are this version's")
|
|
805
|
+
if st and st.get("key") == fpr and _done(st, out) and not redo and not stale and not FS.unchecked_share(st.get("footprint")):
|
|
806
|
+
st["skipped"] = True
|
|
807
|
+
for k in ("symbol", "footprint"):
|
|
808
|
+
r.written.setdefault(str(out / st[k]["file"]), st[k])
|
|
809
|
+
else:
|
|
810
|
+
todo.append((it, ln, row, fpr))
|
|
811
|
+
keeps = {}
|
|
812
|
+
for it, ln, row, fpr in todo:
|
|
813
|
+
old_st = state.get(it["name"]) or {}
|
|
814
|
+
keeps[it["name"]] = keep = {k: old_st[k] for k in ("symbol", "footprint")
|
|
815
|
+
if (old_st.get(k) or {}).get("chosen") and (out / old_st[k]["file"]).exists()}
|
|
816
|
+
for rec in keep.values(): # a chosen file another part shares is reused, never overwritten by its fetch
|
|
817
|
+
r.written.setdefault(str(out / rec["file"]), rec)
|
|
818
|
+
r.log = log
|
|
819
|
+
rec = LK.Recorder(http) # what each part fetched, for library.lock.json
|
|
820
|
+
if todo:
|
|
821
|
+
log(f" finding symbols and footprints for {len(todo)} part(s) (sources: {', '.join(sources)}) ...")
|
|
822
|
+
if progress is not None:
|
|
823
|
+
progress.total = len(todo)
|
|
824
|
+
for n, (it, ln, row, fpr) in enumerate(todo, 1):
|
|
825
|
+
if progress is not None:
|
|
826
|
+
progress.start(it["name"])
|
|
827
|
+
elif len(todo) > 3:
|
|
828
|
+
log(f" [{n}/{len(todo)}] {it['name']}")
|
|
829
|
+
with rec.part(it["name"]):
|
|
830
|
+
st = r.resolve(it, ln, row, keeps[it["name"]])
|
|
831
|
+
if progress is not None:
|
|
832
|
+
gone = [k for k in ("symbol", "footprint") if not st.get(k)]
|
|
833
|
+
progress.done(it["name"], "held" if gone else "ready",
|
|
834
|
+
f"no {' or '.join(gone)} yet" if gone else "symbol and footprint found")
|
|
835
|
+
st["key"], st["skipped"] = fpr, False
|
|
836
|
+
LK.stamp_state(st, rec.take(it["name"]), rules)
|
|
837
|
+
st["build_rev"] = BUILD_REV
|
|
838
|
+
state[it["name"]] = st
|
|
839
|
+
r.flush_datasheets(log) # --datasheet-batch: one batch for every part that got this far
|
|
840
|
+
for it in items:
|
|
841
|
+
if it["name"] in in_ws:
|
|
842
|
+
log(f" a365 {it['name']:<28} in the Altium 365 workspace: reused, not fetched")
|
|
843
|
+
continue
|
|
844
|
+
apply_state(it, state[it["name"]], _line_for(it, lines, rows)[0])
|
|
845
|
+
for k in ("allow", "approvals", "duplicate_of"): # decisions people made survive re-runs
|
|
846
|
+
if old.get(it["name"], {}).get(k):
|
|
847
|
+
it[k] = old[it["name"]][k]
|
|
848
|
+
prev = old.get(it["name"]) or {}
|
|
849
|
+
for k, was in (prev.get("merged_from") or {}).items(): # `ecadlib dedupe --merge` pointed this half at a keeper
|
|
850
|
+
if k in ("symbol", "footprint") and it[k].get("ref") == was.get("ref") and (prev.get(k) or {}).get("file") \
|
|
851
|
+
and RC._path(out, prev[k]["file"]).exists():
|
|
852
|
+
it[k] = dict(prev[k])
|
|
853
|
+
it.setdefault("merged_from", {})[k] = was
|
|
854
|
+
log(f" {'skip ' if state[it['name']].get('skipped') else 'done ' if _done(state[it['name']], out) else 'open '}"
|
|
855
|
+
f"{it['name']:<28} sym={it['source']['symbol'] or '-'} fp={it['source']['footprint'] or '-'}")
|
|
856
|
+
state = {k: v for k, v in state.items() if k in {i["name"] for i in items}}
|
|
857
|
+
for n in set(allow) - set(state):
|
|
858
|
+
log(f" --allow {n}: not a part in this plan (names are the plan's Part Numbers)")
|
|
859
|
+
write_text_atomic(state_f, json.dumps(state, indent=2))
|
|
860
|
+
from . import house_style as HS # new parts in your house style (house_style.json, [style] apply)
|
|
861
|
+
prof = HS.active(rules, out)
|
|
862
|
+
if prof:
|
|
863
|
+
styled = HS.adapt_items([it for it in items if it["name"] not in in_ws], prof, rules)
|
|
864
|
+
if styled:
|
|
865
|
+
log(f" style {len(styled)} part(s) written in your house style (descriptions, value formats)")
|
|
866
|
+
checks = RC.apply(items, out, allow, rules=rules)
|
|
867
|
+
if allow: # record who gave the --allow, so approvals can refuse self-approval
|
|
868
|
+
from .approvals import identity, post_check, stamp_allow
|
|
869
|
+
stamp_allow(items, allow, identity(user, cwd=out))
|
|
870
|
+
post_check(items, out, checks)
|
|
871
|
+
write_text_atomic(plan_f, json.dumps(items, indent=2))
|
|
872
|
+
rec.close()
|
|
873
|
+
MG.write_stamp(out, ["plan", "librarian_state"])
|
|
874
|
+
LK.write_lock(out, items, state, bom, libs, sources, cache, providers, gen_step, rules)
|
|
875
|
+
RC.write_report(checks, out / "release_check", plan_f)
|
|
876
|
+
write_review(review_rows(items), out, bom, items)
|
|
877
|
+
return items
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def main(argv=None):
|
|
881
|
+
ap = argparse.ArgumentParser(description="BOM -> library: triage, fill missing symbols/footprints, Altium models, plan, release check")
|
|
882
|
+
ap.add_argument("bom")
|
|
883
|
+
ap.add_argument("--lib", action="append", default=[], help="local KiCad library dir (repeatable)")
|
|
884
|
+
ap.add_argument("-o", "--out", default="out/librarian")
|
|
885
|
+
ap.add_argument("--sources", help="order of sources to try (default: the rules' [sources] order, else " + ",".join(SOURCES) + ")")
|
|
886
|
+
ap.add_argument("--refresh", nargs="*", metavar="PART", help="redo finished parts: all, or the named ones")
|
|
887
|
+
ap.add_argument("--refresh-index", action="store_true", help="rebuild the KiCad library name index")
|
|
888
|
+
ap.add_argument("--allow", action="append", default=[], metavar="PART", help="release PART despite its release-check failures")
|
|
889
|
+
ap.add_argument("--providers", help="triage providers in order (default: [providers] order, else jlcpcb_search,easyeda)")
|
|
890
|
+
ap.add_argument("--offline", action="store_true", help="use the HTTP cache only")
|
|
891
|
+
ap.add_argument("--cache", default="out/http_cache")
|
|
892
|
+
ap.add_argument("--rate", type=float, default=0.5, help="min seconds between requests per host")
|
|
893
|
+
ap.add_argument("--box", action="store_true", help="extruded 3D box instead of a generated STEP when there is no KiCad model")
|
|
894
|
+
ap.add_argument("--user", help="who gives the --allow (default: $ECADLIB_USER, then git config user.name/email)")
|
|
895
|
+
ap.add_argument("--rules", help="library rules file (default: ecadlib.toml found from -o or here; see `ecadlib init`)")
|
|
896
|
+
ap.add_argument("--datasheet-batch", action="store_true",
|
|
897
|
+
help="read every datasheet still needed as one Message Batch (half price; waits for it, see --datasheet-no-wait)")
|
|
898
|
+
ap.add_argument("--datasheet-no-wait", action="store_true", help="submit the batch and finish; re-run to collect it")
|
|
899
|
+
ap.add_argument("--json", action="store_true", help='print {"plan", "review", "parts", "ready", "held", "blockers"} instead of the log')
|
|
900
|
+
a = ap.parse_args(argv)
|
|
901
|
+
rules = RU.load(a.rules, start=a.out)
|
|
902
|
+
reader = None
|
|
903
|
+
if a.datasheet_batch:
|
|
904
|
+
reader = D.Reader(a.out, a.cache, None, rules, CachedHTTP(a.cache, min_interval=a.rate, offline=a.offline, timeout=60),
|
|
905
|
+
batch=True, wait=not a.datasheet_no_wait)
|
|
906
|
+
from . import messages as M, term as TM
|
|
907
|
+
t = TM.Term()
|
|
908
|
+
loud = (lambda *x, **k: None) if a.json or t.quiet else print
|
|
909
|
+
# on a terminal the per-step log is -v detail (the live progress line and the table below say it); piped, as before
|
|
910
|
+
log = loud if (t.verbose or not t.styled) else (lambda m="", *x, **k: print(m) if str(m).startswith((" BOM:", " finding")) else None)
|
|
911
|
+
prog = TM.Progress(t.err, 0, "parts") if t.styled and not a.json else None # piped: the plain log lines
|
|
912
|
+
try:
|
|
913
|
+
items = run(a.bom, a.lib, a.out, [s for s in a.sources.split(",") if s] if a.sources else None, a.refresh,
|
|
914
|
+
a.refresh_index, a.allow, a.offline, a.cache, a.rate, a.providers, gen_step=not a.box, rules=rules,
|
|
915
|
+
user=a.user, http=reader.http if reader else None, reader=reader,
|
|
916
|
+
log=log, progress=prog)
|
|
917
|
+
except ValueError as e: # unreadable / headerless BOM, unknown --sources
|
|
918
|
+
from .firstrun import bom_hint
|
|
919
|
+
print(f"ecadlib librarian: {e}" + bom_hint(e), file=sys.stderr)
|
|
920
|
+
return 2
|
|
921
|
+
from .firstrun import blockers
|
|
922
|
+
held = sum(i["status"] != "ready" for i in items)
|
|
923
|
+
why = blockers(review_rows(items), rules, a.offline)
|
|
924
|
+
if a.json:
|
|
925
|
+
print(json.dumps({"plan": str(Path(a.out) / "plan.json"), "review": str(Path(a.out) / "review.md"), "parts": len(items),
|
|
926
|
+
"ready": len(items) - held, "held": held, "blockers": [{"message": m, "fix": f} for m, f in why]}))
|
|
927
|
+
return 2 if held else 0
|
|
928
|
+
if prog is not None:
|
|
929
|
+
prog.t.clear_live()
|
|
930
|
+
print_result(items, Path(a.out), why, t)
|
|
931
|
+
return 2 if held else 0
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def print_result(items, out, why=(), t=None):
|
|
935
|
+
"""The terminal ending of a librarian run: a row per part on a terminal (status, name, the reason in plain
|
|
936
|
+
words), then notes, the counts, what was written and the next commands."""
|
|
937
|
+
from . import messages as M, term as TM
|
|
938
|
+
t = t or TM.Term()
|
|
939
|
+
if t.styled or t.verbose:
|
|
940
|
+
rows = []
|
|
941
|
+
for it in items:
|
|
942
|
+
why_ = M.explain_all(it.get("hold_reasons") or []) if it.get("status") == "held" else []
|
|
943
|
+
src = it.get("source") or {}
|
|
944
|
+
rows.append([t.status(it.get("status") or "", M.status_word(it.get("status"))), it["name"],
|
|
945
|
+
why_[0]["text"] if why_ else t.dim(f"{src.get('symbol') or '-'} / {src.get('footprint') or '-'}")])
|
|
946
|
+
t.table(rows)
|
|
947
|
+
held = [i["name"] for i in items if i.get("status") == "held"]
|
|
948
|
+
plan = out / "plan.json"
|
|
949
|
+
nxt = []
|
|
950
|
+
if held:
|
|
951
|
+
nxt.append((f"ecadlib review {plan}", "go through the held parts one by one"))
|
|
952
|
+
nxt += [(f"ecadlib kicad-export {plan} -o {out.parent.parent / 'kicad' if out.parent.name == 'librarian' else 'kicad'} --name mylib",
|
|
953
|
+
"write the KiCad library"),
|
|
954
|
+
(f"ecadlib altium-pkg {plan} --out altium_pkg", "the Altium hand-off folder (.LibPkg, DbLib)")]
|
|
955
|
+
t.summary(f"{M.plural(len(items), 'part')}: {M.counts(i.get('status') for i in items)}"
|
|
956
|
+
+ (f" (held: {', '.join(held[:5])}{' ...' if len(held) > 5 else ''})" if held else ""),
|
|
957
|
+
written=[(str(plan), "library plan"), (str(out / "review.md"), "review: what to check, part by part"),
|
|
958
|
+
(str(out / "models") + "/", "Altium symbol and footprint files")],
|
|
959
|
+
next=nxt, notes=[(m, f) for m, f in why])
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
if __name__ == "__main__":
|
|
963
|
+
sys.exit(main())
|