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/app.py
ADDED
|
@@ -0,0 +1,1458 @@
|
|
|
1
|
+
"""Shared actions behind every front end (web UI, MCP server, `ecadlib` CLI).
|
|
2
|
+
|
|
3
|
+
A Workspace is one folder on the user's machine:
|
|
4
|
+
|
|
5
|
+
<root>/uploads/<stamp>-<rand>/<name> BOMs dropped into the UI, one folder per upload (never overwritten)
|
|
6
|
+
<root>/footprints/ecadlib.pretty/ footprints the user approved (indexed like any other library)
|
|
7
|
+
<root>/altium_models/PcbLib|SchLib/ Altium binaries: approved footprints, converted library footprints,
|
|
8
|
+
generic R/C symbols (what an Altium 365 release uploads)
|
|
9
|
+
<root>/reports/ triage .md/.csv, plus .json (the full result, for triage_history)
|
|
10
|
+
<root>/altium/ DbLib export
|
|
11
|
+
<root>/altium365/ release plan + push reports
|
|
12
|
+
<root>/librarian/<bom>/ librarian output: models, plan.json, review and release-check reports
|
|
13
|
+
<root>/http_cache/ provider responses
|
|
14
|
+
|
|
15
|
+
Each action returns plain JSON-able dicts so the UI and the MCP server can share it."""
|
|
16
|
+
import contextlib, datetime, hashlib, html, itertools, json, math, os, re, secrets
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
from . import bom_triage as T, footprint_gen as F, providers as P, rules as RU, sexpr
|
|
22
|
+
from .fsutil import write_text_atomic
|
|
23
|
+
from .httpcache import CachedHTTP
|
|
24
|
+
from .kicad_index import KiCadIndex
|
|
25
|
+
|
|
26
|
+
STATUS_ORDER = ["needs_footprint_generation", "unverified (source unreachable)", "easyeda_footprint_only",
|
|
27
|
+
"available_easyeda", "in_library (generated footprint)", "in_library"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Workspace:
|
|
31
|
+
def __init__(self, root="ecadlib_workspace", libs=(), providers=None, offline=False):
|
|
32
|
+
self.root = Path(root).resolve()
|
|
33
|
+
for d in ("uploads", "reports", "altium", "altium365", "footprints/ecadlib.pretty",
|
|
34
|
+
"altium_models/PcbLib", "altium_models/SchLib"):
|
|
35
|
+
(self.root / d).mkdir(parents=True, exist_ok=True)
|
|
36
|
+
from . import migrate as MG # files from a newer ecadlib: one clear error, nothing misread
|
|
37
|
+
MG.guard_tree(self.root)
|
|
38
|
+
self.gen_dir = self.root / "footprints" / "ecadlib.pretty"
|
|
39
|
+
self.models = self.root / "altium_models"
|
|
40
|
+
self.libs = [str(Path(l).resolve()) for l in libs]
|
|
41
|
+
self.provider_names = providers or [c.name for c in P.ALL]
|
|
42
|
+
self._providers_given = bool(providers) # else [providers] order in the rules decides (ecadlib.plugins)
|
|
43
|
+
self.offline = offline
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------ paths
|
|
46
|
+
def safe_name(self, name, strict=False):
|
|
47
|
+
"""A plain file name. Uploads keep only their base name; footprint names (strict) with a path
|
|
48
|
+
separator are refused, so "SOIC-8/TI_D" can't quietly become "TI_D" and collide with another part."""
|
|
49
|
+
name = str(name)
|
|
50
|
+
if strict and ("/" in name or "\\" in name):
|
|
51
|
+
plain = re.sub(r"[/\\]+", "_", name)
|
|
52
|
+
raise ValueError(f"footprint name {name!r} contains a path separator; use a plain name such as {plain!r}")
|
|
53
|
+
name = re.sub(r"[^A-Za-z0-9._-]+", "_", re.split(r"[/\\]", name)[-1]).strip("._") or "bom.csv"
|
|
54
|
+
return name[:120]
|
|
55
|
+
|
|
56
|
+
def inside(self, path):
|
|
57
|
+
"""Resolve a workspace-relative path, refusing anything outside the workspace."""
|
|
58
|
+
p = (self.root / path).resolve()
|
|
59
|
+
if p != self.root and self.root not in p.parents:
|
|
60
|
+
raise ValueError("path outside workspace")
|
|
61
|
+
return p
|
|
62
|
+
|
|
63
|
+
def rel(self, p):
|
|
64
|
+
return Path(p).resolve().relative_to(self.root).as_posix()
|
|
65
|
+
|
|
66
|
+
def _bom_path(self, bom):
|
|
67
|
+
return Path(bom) if Path(bom).is_absolute() else self.inside(bom)
|
|
68
|
+
|
|
69
|
+
def _index(self):
|
|
70
|
+
return KiCadIndex(self.libs + [str(self.root / "footprints")])
|
|
71
|
+
|
|
72
|
+
def _read_bom(self, bom_path):
|
|
73
|
+
try:
|
|
74
|
+
found = bom_path.is_file()
|
|
75
|
+
except OSError: # name too long, ...
|
|
76
|
+
found = False
|
|
77
|
+
if not found:
|
|
78
|
+
raise ValueError(f"BOM not found: {bom_path.name[:80]} is not a file in the workspace")
|
|
79
|
+
head = bom_path.read_bytes()[:4096] # UTF-16 with a byte-order mark: Excel "Unicode Text", fine
|
|
80
|
+
if (bom_path.suffix.lower() not in (".xlsx", ".xlsm", ".xls", ".schdoc", ".prjpcb", ".bomdoc")
|
|
81
|
+
and b"\0" in head and head[:2] not in (b"\xff\xfe", b"\xfe\xff") and not T.utf16_without_bom(head)):
|
|
82
|
+
raise ValueError(f"{bom_path.name} looks like a binary file, not a CSV; export the BOM as CSV or XLSX")
|
|
83
|
+
try:
|
|
84
|
+
lines, mapping = T.read_bom(bom_path, RU.bom_columns(self.rules()))
|
|
85
|
+
except Exception as e: # pandas/csv/zip errors -> one message a person can act on
|
|
86
|
+
if type(e) is ValueError: # the reader's own words: no exception name, no server path
|
|
87
|
+
raise ValueError(f"couldn't read {bom_path.name} as a BOM: "
|
|
88
|
+
+ str(e).replace(str(bom_path), bom_path.name).replace(f"{bom_path.name}: ", "", 1)) from e
|
|
89
|
+
raise ValueError(f"couldn't read {bom_path.name} as a BOM table ({type(e).__name__}: {e}). "
|
|
90
|
+
"Save it as CSV or XLSX with a header row.") from e
|
|
91
|
+
if not lines or not {"designator", "mpn", "value"} & set(mapping):
|
|
92
|
+
raise ValueError(f"no BOM lines found in {bom_path.name}: it needs a header row with columns such as "
|
|
93
|
+
"Designator, Quantity and Manufacturer Part Number, followed by at least one part")
|
|
94
|
+
return lines, mapping
|
|
95
|
+
|
|
96
|
+
def _key(self, bom_path):
|
|
97
|
+
"""Report name for a BOM: its stem for BOMs in uploads/ (plus the upload's folder name for
|
|
98
|
+
uploads/<stamp>-<rand>/<name>), plus a short hash of the full path for any other location, so
|
|
99
|
+
/projA/BOM.csv and /projB/BOM.csv (or two uploads called BOM.csv) don't share (and overwrite) one triage."""
|
|
100
|
+
bom_path = Path(bom_path).resolve()
|
|
101
|
+
if bom_path.parent == self.root / "uploads":
|
|
102
|
+
return bom_path.stem
|
|
103
|
+
if bom_path.parent.parent == self.root / "uploads":
|
|
104
|
+
return f"{bom_path.stem}_{bom_path.parent.name}"
|
|
105
|
+
return f"{bom_path.stem}_{hashlib.sha1(str(bom_path).encode()).hexdigest()[:8]}"
|
|
106
|
+
|
|
107
|
+
def _triage_csv(self, bom_path):
|
|
108
|
+
return self.root / "reports" / f"triage_{self._key(bom_path)}.csv"
|
|
109
|
+
|
|
110
|
+
def _triage_frame(self, bom_path):
|
|
111
|
+
tri = self._triage_csv(bom_path)
|
|
112
|
+
return pd.read_csv(tri, dtype=str).fillna("") if tri.exists() else None
|
|
113
|
+
|
|
114
|
+
# ------------------------------------------------------------------ actions
|
|
115
|
+
def info(self):
|
|
116
|
+
http = CachedHTTP(self.root / "http_cache", offline=True)
|
|
117
|
+
index = self._index()
|
|
118
|
+
return {"workspace": str(self.root), "libs": self.libs, "offline": self.offline,
|
|
119
|
+
"symbols": len(index.symbols), "footprints": len(index.footprints),
|
|
120
|
+
"providers": [{"name": c.name, "auth": c.auth, "enabled": c.name in self.provider_names,
|
|
121
|
+
"configured": c(http).configured()} for c in P.ALL],
|
|
122
|
+
"altium365": {"token": bool(os.environ.get("NEXAR_TOKEN"))},
|
|
123
|
+
"rules": (lambda f: self._label(f) if f else None)(self.rules_in_use()),
|
|
124
|
+
"altium_models": {k: sorted(p.stem for p in (self.models / k).glob(f"*.{k}")) for k in ("PcbLib", "SchLib")},
|
|
125
|
+
"boms": self._uploaded_boms()}
|
|
126
|
+
|
|
127
|
+
def _uploaded_boms(self):
|
|
128
|
+
"""uploads/<name> (older workspaces) and uploads/<stamp>-<rand>/<name>, newest upload folder first.
|
|
129
|
+
Links are skipped, so nothing outside the workspace is listed."""
|
|
130
|
+
up = self.root / "uploads"
|
|
131
|
+
flat = sorted(p for p in up.iterdir() if p.is_file() and not p.is_symlink())
|
|
132
|
+
nested = [f for d in sorted((d for d in up.iterdir() if d.is_dir() and not d.is_symlink()), reverse=True)
|
|
133
|
+
for f in sorted(d.iterdir()) if f.is_file() and not f.is_symlink()]
|
|
134
|
+
return [self.rel(p) for p in nested + flat if self._is_inside(p)]
|
|
135
|
+
|
|
136
|
+
def _is_inside(self, p):
|
|
137
|
+
try:
|
|
138
|
+
self.inside(p)
|
|
139
|
+
return True
|
|
140
|
+
except ValueError:
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
def save_upload(self, filename, data: bytes):
|
|
144
|
+
"""Store an uploaded BOM as uploads/<UTC stamp>-<random>/<plain file name> and return that path. Every
|
|
145
|
+
upload gets its own new folder, so two people uploading BOM.csv never overwrite each other (nor each
|
|
146
|
+
other's triage reports, which are keyed by the folder too). The file keeps its (sanitised) name."""
|
|
147
|
+
if not data:
|
|
148
|
+
raise ValueError(f"{filename} is empty")
|
|
149
|
+
name = self.safe_name(filename)
|
|
150
|
+
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
151
|
+
for _ in range(20):
|
|
152
|
+
d = self.root / "uploads" / f"{stamp}-{secrets.token_hex(3)}"
|
|
153
|
+
try:
|
|
154
|
+
d.mkdir() # atomic: a folder another upload made is never reused
|
|
155
|
+
break
|
|
156
|
+
except FileExistsError:
|
|
157
|
+
continue
|
|
158
|
+
else:
|
|
159
|
+
raise FileExistsError("couldn't make a new upload folder; try again")
|
|
160
|
+
p = d / name
|
|
161
|
+
fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o644)
|
|
162
|
+
with os.fdopen(fd, "wb") as f:
|
|
163
|
+
f.write(data)
|
|
164
|
+
return self.rel(p)
|
|
165
|
+
|
|
166
|
+
def triage(self, bom, progress=None, lock=None, user=None):
|
|
167
|
+
"""Triage a BOM (workspace-relative or absolute path). Writes reports/triage_<stem>.{md,csv,json}; the
|
|
168
|
+
.json is the whole result plus when and by whom (user), which triage_history lists and opens again.
|
|
169
|
+
progress(done, total, label) is called before each line, for UIs that show a progress bar.
|
|
170
|
+
lock, if given, is held only while the reports are written: the lookups are network-bound and
|
|
171
|
+
must not block other workspace actions."""
|
|
172
|
+
bom_path = self._bom_path(bom)
|
|
173
|
+
http = CachedHTTP(self.root / "http_cache", min_interval=1.0, offline=self.offline)
|
|
174
|
+
provs = [c(http) for c in self._provider_classes()]
|
|
175
|
+
lines, mapping = self._read_bom(bom_path)
|
|
176
|
+
index = self._index()
|
|
177
|
+
rows = []
|
|
178
|
+
for i, ln in enumerate(lines, 1): # one line at a time so long lookups can report progress
|
|
179
|
+
if progress:
|
|
180
|
+
progress(i - 1, len(lines), ln.get("mpn") or ln.get("value") or ln.get("designator", ""))
|
|
181
|
+
r = T.triage([ln], index, provs, http)[0]
|
|
182
|
+
r["line"] = i
|
|
183
|
+
rows.append(r)
|
|
184
|
+
if progress:
|
|
185
|
+
progress(len(lines), len(lines), "writing report")
|
|
186
|
+
stem = self.root / "reports" / f"triage_{self._key(bom_path)}"
|
|
187
|
+
with lock or contextlib.nullcontext():
|
|
188
|
+
counts = T.write_reports(rows, mapping, provs, http, stem, bom_path.name)
|
|
189
|
+
out = {"bom": self.rel(bom_path) if self.root in bom_path.resolve().parents else str(bom_path),
|
|
190
|
+
"mapping": mapping, "rows": rows,
|
|
191
|
+
"counts": {k: int(v) for k, v in counts.items()},
|
|
192
|
+
"reports": [self.rel(stem.with_suffix(".md")), self.rel(stem.with_suffix(".csv"))]}
|
|
193
|
+
meta = {"id": self._key(bom_path), "name": bom_path.name, "finished": _now(), "user": user or None}
|
|
194
|
+
tmp = stem.with_name(stem.name + f".{os.getpid()}.{secrets.token_hex(3)}.tmp")
|
|
195
|
+
tmp.write_text(json.dumps({**meta, **out}, default=str), encoding="utf-8")
|
|
196
|
+
os.replace(tmp, stem.with_suffix(".json"))
|
|
197
|
+
return {**out, **meta}
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------ past triages (read-only)
|
|
200
|
+
def _triage_files(self):
|
|
201
|
+
"""{id: (json or None, csv or None)} of every triage report in reports/ (id = the BOM's report key)."""
|
|
202
|
+
out = {}
|
|
203
|
+
for f in (self.root / "reports").glob("triage_*"):
|
|
204
|
+
if f.suffix in (".json", ".csv") and f.is_file() and not f.is_symlink() and self._is_inside(f):
|
|
205
|
+
j, c = out.get(f.stem[len("triage_"):], (None, None))
|
|
206
|
+
out[f.stem[len("triage_"):]] = (f, c) if f.suffix == ".json" else (j, f)
|
|
207
|
+
return out
|
|
208
|
+
|
|
209
|
+
def triage_history(self, limit=200):
|
|
210
|
+
"""Finished triages in this workspace, newest first: id, BOM, file name, when, who ran it (team mode),
|
|
211
|
+
status counts and report links. Older triages that only left a .csv are listed too (no BOM / user)."""
|
|
212
|
+
items = []
|
|
213
|
+
for tid, (j, c) in self._triage_files().items():
|
|
214
|
+
try:
|
|
215
|
+
if j:
|
|
216
|
+
d = json.loads(j.read_text(encoding="utf-8"))
|
|
217
|
+
it = {k: d.get(k) for k in ("bom", "name", "finished", "user", "counts")}
|
|
218
|
+
it["lines"] = len(d.get("rows") or [])
|
|
219
|
+
it["reports"] = d.get("reports") or []
|
|
220
|
+
elif c:
|
|
221
|
+
rows = pd.read_csv(c, dtype=str).fillna("")
|
|
222
|
+
st = rows["status"].value_counts() if "status" in rows else {}
|
|
223
|
+
it = {"bom": None, "name": tid, "user": None, "lines": len(rows),
|
|
224
|
+
"counts": {k: int(v) for k, v in dict(st).items()},
|
|
225
|
+
"finished": _now(c.stat().st_mtime),
|
|
226
|
+
"reports": [self.rel(x) for x in (c.with_suffix(".md"), c) if x.exists()]}
|
|
227
|
+
else:
|
|
228
|
+
continue
|
|
229
|
+
except (ValueError, OSError, KeyError, pd.errors.ParserError, pd.errors.EmptyDataError):
|
|
230
|
+
continue # a damaged report must not hide the others
|
|
231
|
+
it["id"], mtime = tid, max(x.stat().st_mtime_ns for x in (j, c) if x)
|
|
232
|
+
it["bom_exists"] = bool(it["bom"]) and self._is_inside(it["bom"]) and self.inside(it["bom"]).is_file()
|
|
233
|
+
items.append((it.get("finished") or "", mtime, it))
|
|
234
|
+
items.sort(key=lambda x: x[:2], reverse=True) # same second: the file written last first
|
|
235
|
+
return {"triages": [x[2] for x in items[:limit]], "total": len(items)}
|
|
236
|
+
|
|
237
|
+
def triage_result(self, tid):
|
|
238
|
+
"""One finished triage by its id (from triage_history), in the shape triage() returns. Read-only: the id
|
|
239
|
+
is only matched against the reports that exist, never turned into a path."""
|
|
240
|
+
f = self._triage_files().get(str(tid or ""))
|
|
241
|
+
if not f:
|
|
242
|
+
raise KeyError(f"no triage {tid!r} in this workspace")
|
|
243
|
+
j, c = f
|
|
244
|
+
if j:
|
|
245
|
+
d = json.loads(j.read_text(encoding="utf-8"))
|
|
246
|
+
d["id"] = tid
|
|
247
|
+
return d
|
|
248
|
+
rows = pd.read_csv(c, dtype=str).fillna("").to_dict("records")
|
|
249
|
+
for r in rows:
|
|
250
|
+
with contextlib.suppress(ValueError, KeyError):
|
|
251
|
+
r["line"] = int(r["line"])
|
|
252
|
+
st = pd.Series([r.get("status", "") for r in rows]).value_counts() if rows else {}
|
|
253
|
+
return {"id": tid, "bom": None, "name": tid, "user": None, "finished": _now(c.stat().st_mtime), "mapping": {},
|
|
254
|
+
"rows": rows, "counts": {k: int(v) for k, v in dict(st).items()},
|
|
255
|
+
"reports": [self.rel(x) for x in (c.with_suffix(".md"), c) if x.exists()]}
|
|
256
|
+
|
|
257
|
+
def preview_footprint(self, spec, density=None):
|
|
258
|
+
"""Run the IPC-7351B generator without saving. Returns numbers, pads and an SVG drawing."""
|
|
259
|
+
if isinstance(spec, str):
|
|
260
|
+
spec = json.loads(spec)
|
|
261
|
+
if not isinstance(spec, dict) or not spec.get("name"):
|
|
262
|
+
raise ValueError("the package spec needs at least a \"name\"")
|
|
263
|
+
try:
|
|
264
|
+
F.validate(spec)
|
|
265
|
+
text, rep = F.build(spec, RU.density_for(spec, self.rules(), density))
|
|
266
|
+
except KeyError as e: # no jsonschema installed: the generator finds the gap itself
|
|
267
|
+
raise ValueError(f"the package spec is missing {e.args[0]!r}") from e
|
|
268
|
+
except Exception as e:
|
|
269
|
+
if type(e).__name__ != "ValidationError":
|
|
270
|
+
raise
|
|
271
|
+
where = "/".join(map(str, e.absolute_path))
|
|
272
|
+
raise ValueError(f"the package spec isn't valid{' at ' + where if where else ''}: {e.message}") from e
|
|
273
|
+
sexpr.parse(text)
|
|
274
|
+
name = self.safe_name(spec["name"], strict=True)
|
|
275
|
+
return {"name": spec["name"], "report": rep, "kicad_mod": text, "svg": footprint_svg(rep, kicad_mod=text),
|
|
276
|
+
"exists": (self.gen_dir / f"{name}.kicad_mod").exists()}
|
|
277
|
+
|
|
278
|
+
def preview_symbol(self, pins, name="PART", split=None, stack=None, save=False, overwrite=False):
|
|
279
|
+
"""IC symbol with pins grouped by function (ecadlib.symbol_layout) under the workspace rules' [symbols].
|
|
280
|
+
pins: [{num, name, type?, group?}] or a CSV/JSON path. Returns groups, parts, problems and an SVG; save=True
|
|
281
|
+
also writes <workspace>/altium_models/SchLib/<name>.SchLib (refused if it exists unless overwrite)."""
|
|
282
|
+
from . import altium_lib, symbol_layout as SL
|
|
283
|
+
if isinstance(pins, str):
|
|
284
|
+
p = self._bom_path(pins)
|
|
285
|
+
name_, pins, _ = SL.load_pins(p)
|
|
286
|
+
name = name if name != "PART" else name_
|
|
287
|
+
sec = dict(self.rules()["symbols"])
|
|
288
|
+
sec.update({k: v for k, v in (("split_pins", split), ("stack_power", stack)) if v is not None})
|
|
289
|
+
sym = SL.build(name, pins, rules={"symbols": sec})
|
|
290
|
+
groups = {}
|
|
291
|
+
for q in SL.arrange_pins(pins, {"symbols": sec}):
|
|
292
|
+
groups.setdefault(f"{chr(64 + q['unit'])}:{q['side']}:{q['group']}", []).append(q["num"])
|
|
293
|
+
out = {"name": name, "pins": len(pins), "parts": sym.get("parts", 1), "groups": groups,
|
|
294
|
+
"problems": SL.check(sym, sec["grid"]), "svg": SL.svg(sym)}
|
|
295
|
+
if save:
|
|
296
|
+
f = self.models / "SchLib" / f"{altium_lib.file_stem(self.safe_name(name, strict=True))}.SchLib"
|
|
297
|
+
if f.exists() and not overwrite:
|
|
298
|
+
raise FileExistsError(f"{f.name} is already in the workspace; save again with overwrite to replace it")
|
|
299
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
300
|
+
out["file"] = self.rel(altium_lib.write_schlib(f, [sym]))
|
|
301
|
+
return out
|
|
302
|
+
|
|
303
|
+
def approve_footprint(self, spec, density=None, overwrite=False):
|
|
304
|
+
"""Save the generated footprint into the workspace library (KiCad .kicad_mod + Altium .PcbLib),
|
|
305
|
+
where triage and the Altium 365 plan will find it. Refuses to replace an existing one unless overwrite."""
|
|
306
|
+
from . import altium_lib
|
|
307
|
+
spec_obj = json.loads(spec) if isinstance(spec, str) else spec
|
|
308
|
+
res = self.preview_footprint(spec_obj, density)
|
|
309
|
+
name = self.safe_name(res["name"], strict=True)
|
|
310
|
+
p = self.gen_dir / f"{name}.kicad_mod"
|
|
311
|
+
if p.exists() and not overwrite:
|
|
312
|
+
raise FileExistsError(f"{name} is already in the workspace library; approve again with overwrite to replace it")
|
|
313
|
+
p.write_text(res["kicad_mod"], encoding="utf-8", newline="\n")
|
|
314
|
+
(self.gen_dir / f"{name}.json").write_text(json.dumps(spec_obj, indent=2), encoding="utf-8") # keep the source dimensions
|
|
315
|
+
files = [self.rel(p)]
|
|
316
|
+
warnings = [f"{c['rule']}: {c['message']}" for c in res["report"].get("checks", []) if c["level"] == "error"]
|
|
317
|
+
try:
|
|
318
|
+
rules = self.rules()
|
|
319
|
+
fp, _ = altium_lib.footprint_from_spec(spec_obj, RU.density_for(spec_obj, rules, density))
|
|
320
|
+
files.append(self.rel(altium_lib.write_pcblib(self.models / "PcbLib" / f"{name}.PcbLib", [fp], RU.layers(rules))))
|
|
321
|
+
except Exception as e:
|
|
322
|
+
warnings.append(f"Altium .PcbLib not written: {type(e).__name__}: {e}")
|
|
323
|
+
return {"name": name, "path": files[0], "files": files, "warnings": warnings, "report": res["report"]}
|
|
324
|
+
|
|
325
|
+
def build_altium_models(self, bom):
|
|
326
|
+
"""Write the Altium files an Altium 365 release needs, for every BOM line that has a footprint:
|
|
327
|
+
footprints already in the KiCad libraries are converted to .PcbLib, and generic Resistor/Capacitor
|
|
328
|
+
symbols are written as .SchLib. IC symbols are not generated here."""
|
|
329
|
+
from . import altium_lib
|
|
330
|
+
bom_path = self._bom_path(bom)
|
|
331
|
+
tri = self._triage_frame(bom_path)
|
|
332
|
+
if tri is None:
|
|
333
|
+
self.triage(bom)
|
|
334
|
+
tri = self._triage_frame(bom_path)
|
|
335
|
+
index, done, skipped, layers = self._index(), [], [], RU.layers(self.rules())
|
|
336
|
+
for key in dict.fromkeys(k.split(" ")[0] for k in tri["local_footprint"] if k):
|
|
337
|
+
ref = key.split(":")[-1]
|
|
338
|
+
out = self.models / "PcbLib" / f"{ref}.PcbLib"
|
|
339
|
+
if out.exists():
|
|
340
|
+
done.append(ref)
|
|
341
|
+
continue
|
|
342
|
+
hit = index.footprints.get(key) or index.footprint(key)[1]
|
|
343
|
+
if not hit:
|
|
344
|
+
skipped.append(f"{ref}: not found in indexed libraries")
|
|
345
|
+
continue
|
|
346
|
+
try:
|
|
347
|
+
altium_lib.write_pcblib(out, [altium_lib.footprint_from_kicad(Path(hit["path"]).read_text(encoding="utf-8"))], layers)
|
|
348
|
+
done.append(ref)
|
|
349
|
+
except Exception as e:
|
|
350
|
+
skipped.append(f"{ref}: {e}")
|
|
351
|
+
for name, make in (("Resistor", altium_lib.resistor_symbol), ("Capacitor", altium_lib.capacitor_symbol)):
|
|
352
|
+
out = self.models / "SchLib" / f"{name}.SchLib"
|
|
353
|
+
if not out.exists():
|
|
354
|
+
altium_lib.write_schlib(out, [make()])
|
|
355
|
+
return {"footprints": done, "skipped": skipped,
|
|
356
|
+
"symbols": sorted(p.stem for p in (self.models / "SchLib").glob("*.SchLib"))}
|
|
357
|
+
|
|
358
|
+
def export_dblib(self, bom):
|
|
359
|
+
"""Write the Altium DbLib (SQLite + XLSX + .DbLib) for a BOM, using the latest triage."""
|
|
360
|
+
from . import altium_export
|
|
361
|
+
bom_path = self._bom_path(bom)
|
|
362
|
+
self._read_bom(bom_path) # same friendly error as triage for unreadable files
|
|
363
|
+
tri = self._triage_csv(bom_path)
|
|
364
|
+
out = self.root / "altium"
|
|
365
|
+
argv = [str(bom_path), "-o", str(out)] + (["--triage", str(tri)] if tri.exists() else [])
|
|
366
|
+
rf = self.rules_in_use()
|
|
367
|
+
argv += ["--rules", str(rf)] if rf else []
|
|
368
|
+
if altium_export.main(argv):
|
|
369
|
+
raise ValueError(f"couldn't write the DbLib for {bom_path.name}: no part lines in it")
|
|
370
|
+
return {"files": [self.rel(p) for p in sorted(out.iterdir()) if p.is_file()]}
|
|
371
|
+
|
|
372
|
+
def altium365_plan(self, bom):
|
|
373
|
+
"""One workspace component per part. When the librarian has run for this BOM its plan is used, with the
|
|
374
|
+
release check's ready / held verdicts; otherwise a part is ready when its .SchLib and .PcbLib are in
|
|
375
|
+
altium_models/."""
|
|
376
|
+
from . import altium365 as A
|
|
377
|
+
bom_path = self._bom_path(bom)
|
|
378
|
+
lines, _ = self._read_bom(bom_path)
|
|
379
|
+
lib_plan = self._librarian_dir(bom_path) / "plan.json"
|
|
380
|
+
if lib_plan.exists():
|
|
381
|
+
items = json.loads(lib_plan.read_text(encoding="utf-8"))
|
|
382
|
+
for it in items: # librarian paths are relative to its output folder
|
|
383
|
+
for k in ("symbol", "footprint"):
|
|
384
|
+
f = it[k].get("file")
|
|
385
|
+
if f and not Path(f).is_absolute():
|
|
386
|
+
it[k]["file"] = str(lib_plan.parent / f)
|
|
387
|
+
src, out = "librarian", lib_plan
|
|
388
|
+
else:
|
|
389
|
+
items = A.plan(lines, self._triage_frame(bom_path), self.models, rules=self.rules())
|
|
390
|
+
out = self.root / "altium365" / f"plan_{self._key(bom_path)}.json"
|
|
391
|
+
out.write_text(json.dumps(items, indent=2), encoding="utf-8")
|
|
392
|
+
src = "triage"
|
|
393
|
+
ready = sum(i["status"] == "ready" for i in items)
|
|
394
|
+
return {"items": items, "ready": ready, "total": len(items), "plan": self.rel(out), "source": src}
|
|
395
|
+
|
|
396
|
+
# ------------------------------------------------------------------ librarian
|
|
397
|
+
def _librarian_dir(self, bom_path):
|
|
398
|
+
return self.root / "librarian" / self._key(bom_path)
|
|
399
|
+
|
|
400
|
+
def librarian_run(self, bom, sources=None, allow=(), refresh=None, progress=None):
|
|
401
|
+
"""BOM -> library in one go (ecadlib.librarian): triage, fill every missing symbol and footprint from
|
|
402
|
+
the KiCad libraries, EasyEDA or the datasheet, write Altium models and the plan, run the release check.
|
|
403
|
+
refresh: None resumes (finished parts are skipped), [] redoes everything, [names] redoes those."""
|
|
404
|
+
from . import librarian as L
|
|
405
|
+
bom_path = self._bom_path(bom)
|
|
406
|
+
lines, _ = self._read_bom(bom_path) # same friendly error as triage for unreadable files
|
|
407
|
+
out = self._librarian_dir(bom_path)
|
|
408
|
+
log, n = [], [0]
|
|
409
|
+
|
|
410
|
+
def note(msg):
|
|
411
|
+
log.append(str(msg).strip())
|
|
412
|
+
n[0] += 1
|
|
413
|
+
if progress:
|
|
414
|
+
progress(min(n[0], len(lines)), len(lines), str(msg).strip()[:80])
|
|
415
|
+
if progress:
|
|
416
|
+
progress(0, len(lines), "looking up parts")
|
|
417
|
+
provs = self._librarian_providers()
|
|
418
|
+
items = L.run(str(bom_path), self.libs + [str(self.root / "footprints")], out,
|
|
419
|
+
list(sources) if sources else None, refresh, False, list(allow), self.offline,
|
|
420
|
+
self.root / "http_cache", 0.5, provs, log=note, rules=self.rules())
|
|
421
|
+
return self._librarian_summary(bom_path, items, log)
|
|
422
|
+
|
|
423
|
+
def release_check(self, bom, allow=(), unallow=(), user=None):
|
|
424
|
+
"""Re-run the release check on the librarian's plan, optionally releasing named parts despite their
|
|
425
|
+
failures (allow) or taking that back (unallow). user, when given, is recorded as the person who gave the
|
|
426
|
+
allow (approvals' no_self_approval uses it). Never touches the network."""
|
|
427
|
+
with self._plan_lock():
|
|
428
|
+
return self._release_check(bom, allow, unallow, user)
|
|
429
|
+
|
|
430
|
+
def _release_check(self, bom, allow=(), unallow=(), user=None):
|
|
431
|
+
from . import release_check as RC
|
|
432
|
+
bom_path = self._bom_path(bom)
|
|
433
|
+
out = self._librarian_dir(bom_path)
|
|
434
|
+
plan_f = out / "plan.json"
|
|
435
|
+
if not plan_f.exists():
|
|
436
|
+
raise ValueError("no librarian plan for this BOM yet: run the librarian (Fill missing parts) first")
|
|
437
|
+
items = json.loads(plan_f.read_text(encoding="utf-8"))
|
|
438
|
+
unknown = set(allow) | set(unallow)
|
|
439
|
+
unknown -= {i["name"] for i in items}
|
|
440
|
+
if unknown:
|
|
441
|
+
raise ValueError(f"not parts of this plan: {', '.join(sorted(unknown))}")
|
|
442
|
+
rows = RC.apply(items, out, list(allow), list(unallow), who="ecadlib ui/mcp", rules=self.rules())
|
|
443
|
+
user = user.strip() if isinstance(user, str) else ""
|
|
444
|
+
if allow and user:
|
|
445
|
+
from .approvals import post_check, stamp_allow
|
|
446
|
+
stamp_allow(items, list(allow), user)
|
|
447
|
+
post_check(items, out, rows)
|
|
448
|
+
write_text_atomic(plan_f, json.dumps(items, indent=2))
|
|
449
|
+
RC.write_report(rows, out / "release_check", plan_f)
|
|
450
|
+
from . import librarian as L
|
|
451
|
+
L.write_review(L.review_rows(items), out, str(bom_path))
|
|
452
|
+
return self._librarian_summary(bom_path, items, [])
|
|
453
|
+
|
|
454
|
+
@contextlib.contextmanager
|
|
455
|
+
def _plan_lock(self):
|
|
456
|
+
"""<workspace>/agent/plan.lock around a plan rewrite (not re-entrant: locked methods call the unlocked _bodies): while the library agent's
|
|
457
|
+
librarian rewrites a plan, a change written now would be overwritten by its copy, so it is refused instead."""
|
|
458
|
+
from .agent_queue import plan_lock
|
|
459
|
+
with plan_lock(self.root, wait=False) as free:
|
|
460
|
+
if not free:
|
|
461
|
+
raise ValueError("the library agent is rebuilding the library right now; try again in a minute")
|
|
462
|
+
yield
|
|
463
|
+
|
|
464
|
+
def _librarian_summary(self, bom_path, items, log):
|
|
465
|
+
from . import librarian as L, reports as RP
|
|
466
|
+
out = self._librarian_dir(bom_path)
|
|
467
|
+
rows = L.review_rows(items)
|
|
468
|
+
for r, it in zip(rows, items):
|
|
469
|
+
r["allowed"] = bool(it.get("allow"))
|
|
470
|
+
a = it.get("approval")
|
|
471
|
+
r["approval"] = {k: a.get(k) for k in ("required", "met", "approved_by", "missing", "stale")} if a else None
|
|
472
|
+
if it.get("duplicate_of"):
|
|
473
|
+
r["duplicate_of"] = it["duplicate_of"]
|
|
474
|
+
v = RP.part_view(it) # the shared voice: status word, reasons and checks with a next step
|
|
475
|
+
r["word"] = v["word"]
|
|
476
|
+
r["says"] = [{k: e[k] for k in ("code", "text", "fix", "note")} for e in v["reasons"] + v["checks"]]
|
|
477
|
+
files = [out / f for f in ("review.md", "review.html", "release_check.md", "plan.json", "dedupe.md")]
|
|
478
|
+
return {"bom": self.rel(bom_path) if self._is_inside(bom_path) else str(bom_path),
|
|
479
|
+
"parts": len(items), "ready": sum(i["status"] == "ready" for i in items),
|
|
480
|
+
"held": sum(i["status"] == "held" for i in items),
|
|
481
|
+
"awaiting_approval": sum(i["status"] == "awaiting_approval" for i in items),
|
|
482
|
+
"duplicates": sum(i["status"] == "duplicate" for i in items),
|
|
483
|
+
"needs_person": sum(bool(r["needs_human"]) for r in rows), "review": rows,
|
|
484
|
+
"reports": [self.rel(f) for f in files if f.exists()], "log": log[-60:]}
|
|
485
|
+
|
|
486
|
+
# ------------------------------------------------------------------ held parts (ecadlib.resolve)
|
|
487
|
+
def _plan(self, bom):
|
|
488
|
+
"""(bom_path, plan.json path, items) of the librarian plan for a BOM; the plan is always inside the workspace."""
|
|
489
|
+
bom_path = self._bom_path(bom)
|
|
490
|
+
plan_f = self.inside(self.rel(self._librarian_dir(bom_path) / "plan.json"))
|
|
491
|
+
if not plan_f.exists():
|
|
492
|
+
raise ValueError("no librarian plan for this BOM yet: run the librarian (Fill missing parts) first")
|
|
493
|
+
return bom_path, plan_f, json.loads(plan_f.read_text(encoding="utf-8"))
|
|
494
|
+
|
|
495
|
+
@staticmethod
|
|
496
|
+
def _part(items, part):
|
|
497
|
+
if not isinstance(part, str) or not part.strip():
|
|
498
|
+
raise ValueError("no part given")
|
|
499
|
+
it = next((i for i in items if i["name"] == part), None)
|
|
500
|
+
if it is None:
|
|
501
|
+
raise ValueError(f"{part!r} is not a part of this plan")
|
|
502
|
+
return it
|
|
503
|
+
|
|
504
|
+
def _resolve_sources(self):
|
|
505
|
+
from . import resolve as R
|
|
506
|
+
return R.Sources(self.root / "http_cache", self.libs + [str(self.root / "footprints")], self.offline)
|
|
507
|
+
|
|
508
|
+
@staticmethod
|
|
509
|
+
def _kind(kind):
|
|
510
|
+
from . import resolve as R
|
|
511
|
+
if kind not in R.KINDS:
|
|
512
|
+
raise ValueError(f"kind must be one of {', '.join(R.KINDS)}")
|
|
513
|
+
return kind
|
|
514
|
+
|
|
515
|
+
def _bom_label(self, bom_path):
|
|
516
|
+
return self.rel(bom_path) if self._is_inside(bom_path) else str(bom_path)
|
|
517
|
+
|
|
518
|
+
def held_parts(self, bom):
|
|
519
|
+
"""Held plan items: name, folder, missing halves, hold reasons and where each half came from. No network."""
|
|
520
|
+
from . import resolve as R
|
|
521
|
+
bom_path, plan_f, _ = self._plan(bom)
|
|
522
|
+
from . import messages as M # each reason also as a plain sentence + next step (code kept)
|
|
523
|
+
held = [dict(h, reasons=[{k: e[k] for k in ("code", "text", "fix", "note")} for e in M.explain_all(h["hold_reasons"])])
|
|
524
|
+
for h in R.held(plan_f)]
|
|
525
|
+
return {"bom": self._bom_label(bom_path), "plan": self.rel(plan_f), "held": held}
|
|
526
|
+
|
|
527
|
+
def part_candidates(self, bom, part, kind=None, limit=10, progress=None):
|
|
528
|
+
"""Ranked symbol / footprint candidates for one plan item (default: its missing halves, else both), with the
|
|
529
|
+
item's current state. May fetch KiCad library files (through the HTTP cache; offline uses the cache only)."""
|
|
530
|
+
from . import resolve as R
|
|
531
|
+
_, plan_f, items = self._plan(bom)
|
|
532
|
+
it = self._part(items, part)
|
|
533
|
+
kinds = [self._kind(kind)] if kind else (R.missing(it) or list(R.KINDS))
|
|
534
|
+
try:
|
|
535
|
+
limit = max(1, min(50, int(limit or 10)))
|
|
536
|
+
except (TypeError, ValueError):
|
|
537
|
+
raise ValueError("limit must be a number") from None
|
|
538
|
+
src, out = self._resolve_sources(), {}
|
|
539
|
+
for n, k in enumerate(kinds):
|
|
540
|
+
if progress:
|
|
541
|
+
progress(n, len(kinds), f"{k} candidates for {part}")
|
|
542
|
+
out[k] = R.candidates(it, k, plan_f.parent, src, limit)
|
|
543
|
+
if progress:
|
|
544
|
+
progress(len(kinds), len(kinds), "done")
|
|
545
|
+
return {"part": part, "item": R.show(plan_f, part), "candidates": out}
|
|
546
|
+
|
|
547
|
+
_CID = re.compile(r"(kicad|local):[^:/\\\0]+:[^:/\\\0]+|spec:[^:/\\\0]+")
|
|
548
|
+
|
|
549
|
+
def choose_part(self, bom, part, kind, candidate_id, pin_map=None):
|
|
550
|
+
"""Use a candidate (kicad:Lib:Name, local:Lib:Name or spec:Name) for one half of a plan item, convert it into
|
|
551
|
+
the librarian's models folder, then re-run the release check and rewrite the review / release-check reports.
|
|
552
|
+
pin_map {symbol pin: pad} remaps pin numbers. Returns the librarian summary plus `chosen`."""
|
|
553
|
+
with self._plan_lock():
|
|
554
|
+
return self._choose_part(bom, part, kind, candidate_id, pin_map)
|
|
555
|
+
|
|
556
|
+
def _choose_part(self, bom, part, kind, candidate_id, pin_map=None):
|
|
557
|
+
from . import resolve as R
|
|
558
|
+
_, plan_f, items = self._plan(bom)
|
|
559
|
+
self._part(items, part)
|
|
560
|
+
self._kind(kind)
|
|
561
|
+
if not isinstance(candidate_id, str) or not self._CID.fullmatch(candidate_id) or \
|
|
562
|
+
any(s.strip() in ("", ".", "..") for s in candidate_id.split(":")):
|
|
563
|
+
raise ValueError(f"candidate id {candidate_id!r} must look like kicad:Lib:Name, local:Lib:Name or spec:Name")
|
|
564
|
+
if pin_map is not None and not (isinstance(pin_map, dict) and all(
|
|
565
|
+
isinstance(a, str) and isinstance(b, str) and a and b and len(a) < 40 and len(b) < 40 for a, b in pin_map.items())):
|
|
566
|
+
raise ValueError("pin_map must map symbol pin numbers to pad numbers, e.g. {\"S1\": \"SH\"}")
|
|
567
|
+
try:
|
|
568
|
+
chosen = R.choose(plan_f, part, kind, candidate_id, src=self._resolve_sources(), pin_map=pin_map or None)
|
|
569
|
+
except FileNotFoundError as e:
|
|
570
|
+
raise ValueError(str(e)) from e
|
|
571
|
+
out = self._release_check(bom) # the whole plan again (lock already held): reports, statuses, approvals
|
|
572
|
+
out["chosen"] = chosen
|
|
573
|
+
return out
|
|
574
|
+
|
|
575
|
+
# ------------------------------------------------------------------ sign-off (ecadlib.approvals)
|
|
576
|
+
def approval_status(self, bom):
|
|
577
|
+
"""The sign-off policy next to the plan (none = approvals are recorded, not enforced) and one row per part."""
|
|
578
|
+
from . import approvals as AP
|
|
579
|
+
bom_path, plan_f, items = self._plan(bom)
|
|
580
|
+
base = plan_f.parent
|
|
581
|
+
policy = AP.load_policy(base)
|
|
582
|
+
pf, audit = AP.policy_file(base), base / AP.AUDIT
|
|
583
|
+
return {"bom": self._bom_label(bom_path), "policy": policy, "policy_file": self.rel(pf) if pf else None,
|
|
584
|
+
"rows": AP.status_rows(items, policy, base),
|
|
585
|
+
"awaiting_approval": sum(i.get("status") == "awaiting_approval" for i in items),
|
|
586
|
+
"audit": self.rel(audit) if audit.exists() else None}
|
|
587
|
+
|
|
588
|
+
def approve_part(self, bom, part, decision, user, role=None, comment="", via="ecadlib ui/mcp"):
|
|
589
|
+
"""Record an approve / reject (see _approve_part). Holds <workspace>/agent/plan.lock while it rewrites the plan:
|
|
590
|
+
while the library agent's librarian rewrites a plan the decision is refused ("decide again in a minute")
|
|
591
|
+
instead of being overwritten when the librarian saves its copy."""
|
|
592
|
+
from .agent_queue import plan_lock
|
|
593
|
+
with plan_lock(self.root, wait=False) as free:
|
|
594
|
+
if not free:
|
|
595
|
+
raise ValueError("the library agent is rebuilding the library right now; decide again in a minute")
|
|
596
|
+
return self._approve_part(bom, part, decision, user, role, comment, via)
|
|
597
|
+
|
|
598
|
+
def _approve_part(self, bom, part, decision, user, role=None, comment="", via="ecadlib ui/mcp"):
|
|
599
|
+
"""Record an approve / reject for one part as `user`. user is required: locally there is no login, so the
|
|
600
|
+
caller says who it is (never taken from this machine's git identity); the team server passes the signed-in
|
|
601
|
+
user and via="ecadlib ui (team, signed in)", which the audit entry records. Every decision, refused ones included, is
|
|
602
|
+
appended to approvals_audit.jsonl; the part then flips between ready and awaiting_approval."""
|
|
603
|
+
from . import approvals as AP, librarian as L
|
|
604
|
+
if decision not in ("approve", "reject"):
|
|
605
|
+
raise ValueError("decision must be approve or reject")
|
|
606
|
+
user = user.strip() if isinstance(user, str) else ""
|
|
607
|
+
if not user:
|
|
608
|
+
raise ValueError("say who you are: user is required (e.g. \"Ann Lee <ann@example.com>\")")
|
|
609
|
+
if len(user) > 200 or any(c in user for c in "\r\n\0"):
|
|
610
|
+
raise ValueError("user must be one line of at most 200 characters")
|
|
611
|
+
role = (role.strip() if isinstance(role, str) else "") or None
|
|
612
|
+
comment = comment if isinstance(comment, str) else ""
|
|
613
|
+
bom_path, plan_f, items = self._plan(bom)
|
|
614
|
+
it, base = self._part(items, part), plan_f.parent
|
|
615
|
+
policy = AP.load_policy(base)
|
|
616
|
+
entry = {"ts": AP.now(), "plan": str(plan_f), "part": part, "who": user, "role": role or "", "decision": decision,
|
|
617
|
+
"comment": comment, "via": via}
|
|
618
|
+
try:
|
|
619
|
+
rec = AP.decide(it, decision, user, role, comment, base, policy)
|
|
620
|
+
except AP.Refused as e:
|
|
621
|
+
entry["result"] = f"refused: {e}"
|
|
622
|
+
AP.audit(base, entry)
|
|
623
|
+
raise ValueError(str(e)) from e
|
|
624
|
+
status = AP.refresh_status(it, policy, base)
|
|
625
|
+
entry.update(role=rec["role"], hash=rec["hash"], result="recorded", status=status)
|
|
626
|
+
AP.audit(base, entry)
|
|
627
|
+
write_text_atomic(plan_f, json.dumps(items, indent=2))
|
|
628
|
+
L.write_review(L.review_rows(items), base, str(bom_path))
|
|
629
|
+
out = self._librarian_summary(bom_path, items, [])
|
|
630
|
+
out["decision"] = {"part": part, "decision": decision, "who": user, "role": rec["role"], "status": status,
|
|
631
|
+
"enforced": bool(policy)}
|
|
632
|
+
return out
|
|
633
|
+
|
|
634
|
+
# ------------------------------------------------------------------ library agent review queue (ecadlib.agent_queue)
|
|
635
|
+
def agent_queue_list(self, status=None, batch=None):
|
|
636
|
+
"""Queue items (summaries, newest first), counts per state and batches; batch=ID adds that batch."""
|
|
637
|
+
from . import agent_queue as AQ
|
|
638
|
+
return AQ.ws_list(self.root, status, batch)
|
|
639
|
+
|
|
640
|
+
def agent_queue_get(self, item_id):
|
|
641
|
+
"""One queue item in full, its showable pictures (paths inside agent/), allowed next states and its batch."""
|
|
642
|
+
from . import agent_queue as AQ
|
|
643
|
+
return AQ.ws_get(self.root, item_id)
|
|
644
|
+
|
|
645
|
+
def agent_queue_decide(self, item_id, action, user, role=None, note="", via="ecadlib ui/mcp", aliases=(), expect=None):
|
|
646
|
+
"""approve / reject / rebuild / retry a queue item as `user`; approve and reject also go through approvals.py.
|
|
647
|
+
expect: the build_token agent_queue_get gave; the decision is refused if the item was rebuilt since."""
|
|
648
|
+
from . import agent_queue as AQ
|
|
649
|
+
return AQ.decide(self, item_id, action, user, role, note, via, aliases, expect=expect)
|
|
650
|
+
|
|
651
|
+
def agent_picture(self, path):
|
|
652
|
+
"""Path of a picture under <workspace>/agent/ (anything else: ValueError)."""
|
|
653
|
+
from . import agent_queue as AQ
|
|
654
|
+
return AQ.picture_path(self.root, path)
|
|
655
|
+
|
|
656
|
+
# ------------------------------------------------------------------ duplicates (ecadlib.dedupe)
|
|
657
|
+
def find_duplicates(self, bom, merge=False):
|
|
658
|
+
"""Duplicate parts (same MPN), footprints (same geometry) and symbols (same pinout) across the librarian plan
|
|
659
|
+
and the workspace's altium_models/ and footprints/. merge=True points duplicates at their keeper in the plan
|
|
660
|
+
(library files are never changed; plan.json.pre-dedupe keeps the old plan) and re-runs the release check."""
|
|
661
|
+
with self._plan_lock() if merge else contextlib.nullcontext():
|
|
662
|
+
return self._find_duplicates(bom, merge)
|
|
663
|
+
|
|
664
|
+
def _find_duplicates(self, bom, merge=False):
|
|
665
|
+
from . import dedupe as D
|
|
666
|
+
_, plan_f, _ = self._plan(bom)
|
|
667
|
+
libs = [p for p in (self.models, self.root / "footprints") if p.exists()]
|
|
668
|
+
r = D.run(plan_f, libs, do_merge=bool(merge), who="ecadlib ui/mcp")
|
|
669
|
+
keep = ("name", "origin", "mpn", "used_by", "status", "where", "files", "file")
|
|
670
|
+
short = lambda f: self.rel(f) if self._is_inside(f) else str(f)
|
|
671
|
+
groups = []
|
|
672
|
+
for g in r["groups"]:
|
|
673
|
+
members = []
|
|
674
|
+
for m in g["members"]:
|
|
675
|
+
m = {k: m[k] for k in keep if m.get(k) not in (None, "", [])}
|
|
676
|
+
if "files" in m:
|
|
677
|
+
m["files"] = [short(f) for f in m["files"]]
|
|
678
|
+
m.pop("file", None)
|
|
679
|
+
elif "file" in m:
|
|
680
|
+
m["file"] = short(m["file"])
|
|
681
|
+
members.append(m)
|
|
682
|
+
groups.append({"kind": g["kind"], "key": g["key"], "keeper": g["keeper"], "members": members})
|
|
683
|
+
out = {"groups": groups, "mapping": r["mapping"], "errors": r["errors"], "merged": bool(merge),
|
|
684
|
+
"reports": [self.rel(r["report"]), self.rel(r["map"])]}
|
|
685
|
+
if merge:
|
|
686
|
+
out["summary"] = self._release_check(bom) # the plan lock is already held
|
|
687
|
+
return out
|
|
688
|
+
|
|
689
|
+
# ------------------------------------------------------------------ upkeep (ecadlib.upkeep)
|
|
690
|
+
def upkeep_run(self, bom, checks=None, apply=False, workspace=None):
|
|
691
|
+
"""The weekly upkeep checks for a BOM (its librarian plan when there is one): lifecycle, KiCad upstream, price
|
|
692
|
+
and stock, Altium 365 drift. Reports only what changed since the last upkeep run of this BOM; checks without
|
|
693
|
+
credentials are skipped. apply=True reconverts KiCad parts whose sources changed. Never touches GitHub."""
|
|
694
|
+
from . import upkeep as UK
|
|
695
|
+
bom_path = self._bom_path(bom)
|
|
696
|
+
base = self.root / "upkeep" / self._key(bom_path)
|
|
697
|
+
plan_f = self._librarian_dir(bom_path) / "plan.json"
|
|
698
|
+
src = plan_f if plan_f.exists() else bom_path
|
|
699
|
+
manifest = next((m for m in (plan_f.parent / "models" / "manifest.json", self.models / "manifest.json")
|
|
700
|
+
if m.is_file()), None)
|
|
701
|
+
rules = self.rules()
|
|
702
|
+
rep = UK.run(str(src), checks or None, base / "state", base / "upkeep", manifest and str(manifest),
|
|
703
|
+
str(bom_path) if src != bom_path else None, workspace or None, bool(apply), rules,
|
|
704
|
+
UK.Ctx(cache=self.root / "http_cache", offline=self.offline))
|
|
705
|
+
rel = lambda p: self.rel(p) if self._is_inside(p) else str(p)
|
|
706
|
+
return {"bom": self._bom_label(bom_path), "input": rel(src), "new_changes": rep["new_changes"],
|
|
707
|
+
"exit_code": rep["exit_code"],
|
|
708
|
+
"checks": [{k: s[k] for k in ("check", "status", "reason", "changes", "resolved", "notes")} for s in rep["checks"]],
|
|
709
|
+
"applied": [rel(p) for p in rep["applied_paths"]],
|
|
710
|
+
"reports": [self.rel(base / "upkeep.md"), self.rel(base / "upkeep.json")]}
|
|
711
|
+
# ------------------------------------------------------------------ library agent (ecadlib.agent)
|
|
712
|
+
def agent_status(self):
|
|
713
|
+
"""What the library agent did last (status.json), its queue counts per state and its batches/PRs."""
|
|
714
|
+
from . import agent as AG
|
|
715
|
+
return AG.status(self.root)
|
|
716
|
+
|
|
717
|
+
def agent_run(self, dry_run=True):
|
|
718
|
+
"""One agent pass now: scan watched sources, build and prove new parts, batch PR, push (if [agent] auto_push).
|
|
719
|
+
dry_run (the default) reports what would happen and changes nothing."""
|
|
720
|
+
from . import agent as AG
|
|
721
|
+
rep = AG.run_once(self.root, self.rules(), dry_run=bool(dry_run), libs=self.libs)
|
|
722
|
+
return {k: v for k, v in rep.items() if k != "push"} | {"push": rep.get("push")}
|
|
723
|
+
|
|
724
|
+
# ------------------------------------------------------------------ Altium projects (ecadlib.altium_project)
|
|
725
|
+
def project_bom(self, project, variant=None):
|
|
726
|
+
"""BOM straight from an Altium .PrjPcb / .SchDoc (with variants and ActiveBOM choices); writes
|
|
727
|
+
reports/project_<name>.csv, which triage/librarian also accept, as does the project file itself."""
|
|
728
|
+
from . import altium_project as AP
|
|
729
|
+
p = self._bom_path(project)
|
|
730
|
+
prj = AP.load_project(p)
|
|
731
|
+
groups = AP.bom_groups(prj, variant)
|
|
732
|
+
variants = [v["name"] for v in prj["variants"]]
|
|
733
|
+
out = AP.write_bom_csv(groups, self.root / "reports" / f"project_{self._key(p)}{'_' + self.safe_name(variant) if variant else ''}.csv", variants)
|
|
734
|
+
keep = ("designator", "qty", "mpn", "manufacturer", "value", "footprint", "description", "libref", "design_item_id",
|
|
735
|
+
"dnp", "alternates", "variant_notes", "variants", "lifecycle", "item_guid", "revision_guid")
|
|
736
|
+
return {"project": prj["name"], "variant": variant, "variants": variants, "sheets": [Path(s).name for s in prj["sheets"]],
|
|
737
|
+
"components": len(prj["components"]), "lines": [{k: g[k] for k in keep} for g in groups],
|
|
738
|
+
"warnings": prj["warnings"], "csv": self.rel(out)}
|
|
739
|
+
|
|
740
|
+
def where_used(self, projects, part=None, plan=None):
|
|
741
|
+
"""Which projects use which part. projects: folders or project files. The library compared against is the
|
|
742
|
+
given librarian plan (or every librarian plan in the workspace) plus altium_models/."""
|
|
743
|
+
from . import altium_project as AP
|
|
744
|
+
dirs = [self._bom_path(d) for d in ([projects] if isinstance(projects, str) else projects)]
|
|
745
|
+
plans = [self._bom_path(plan)] if plan else sorted((self.root / "librarian").glob("*/plan.json"))
|
|
746
|
+
if plan and not plans[0].is_file():
|
|
747
|
+
raise ValueError(f"plan not found: {plan} is not a plan.json file")
|
|
748
|
+
libs = [str(self.models)] if self.models.exists() else []
|
|
749
|
+
res = AP.where_used(dirs, part, plans[0] if plans else None, libs)
|
|
750
|
+
files = AP.write_where_used(res, self.root / "reports" / "where_used")
|
|
751
|
+
rows = AP.where_used_rows(res)
|
|
752
|
+
return {"projects": res["projects"], "flags": res["flags"], "rows": rows, "warnings": res["warnings"],
|
|
753
|
+
"library": [self.rel(p) if self._is_inside(p) else str(p) for p in plans[:1]] + [self.rel(l) for l in libs],
|
|
754
|
+
"reports": [self.rel(f) for f in files.values()]}
|
|
755
|
+
def sim_models(self, bom, attach=False, models=None):
|
|
756
|
+
"""Find and check SPICE / IBIS models for the librarian plan's parts (built-in passives, the workspace's
|
|
757
|
+
sim_models/ folder plus `models` folders inside the workspace, Nexar documents, vendor URL patterns).
|
|
758
|
+
attach=True writes SIM links into the .SchLib files and Sim.* fields for kicad-export, and re-runs the
|
|
759
|
+
release check (a model whose pin map is unverified holds its part as `verify`)."""
|
|
760
|
+
from . import sim_models as SM
|
|
761
|
+
_, plan_f, _ = self._plan(bom)
|
|
762
|
+
dirs = [self.inside(m) for m in ([models] if isinstance(models, str) else models or [])]
|
|
763
|
+
own = self.root / "sim_models"
|
|
764
|
+
if own.is_dir():
|
|
765
|
+
dirs.insert(0, own)
|
|
766
|
+
rep = SM.run(plan_f, [d for d in dirs if d.is_dir()], attach=bool(attach),
|
|
767
|
+
http=CachedHTTP(self.root / "http_cache", offline=self.offline, timeout=60), rules=self.rules(), log=lambda *a: None)
|
|
768
|
+
return {"attach": rep["attach"], "parts": rep["parts"], "hosts": rep["hosts"], "altium365": rep["altium365"],
|
|
769
|
+
"errors": rep.get("errors", []), "reports": [self.rel(plan_f.parent / "sim_models.md"), self.rel(plan_f.parent / "sim_models.json")]}
|
|
770
|
+
|
|
771
|
+
def _datasheet_reader(self, out, batch=True, wait=False, estimate=False, yes=False):
|
|
772
|
+
from . import datasheet as D
|
|
773
|
+
return D.Reader(out, self.root / "http_cache", rules=self.rules(), batch=batch, wait=wait, estimate=estimate, yes=yes,
|
|
774
|
+
http=CachedHTTP(self.root / "http_cache", offline=self.offline, timeout=60), log=lambda *a: None)
|
|
775
|
+
|
|
776
|
+
def datasheet_batch(self, bom, sync=False, wait=False, parts=None, yes=False):
|
|
777
|
+
"""Read the datasheet of every part of the librarian plan that still has no symbol or footprint, as one
|
|
778
|
+
Message Batch (sync=True: one Messages call per part), build them, mark them `verify` and re-run the release
|
|
779
|
+
check. wait=False submits and returns; call again to collect (the batch id is kept, nothing is paid twice).
|
|
780
|
+
With no plan yet, the librarian runs first with the batch as its datasheet step."""
|
|
781
|
+
from . import datasheet as D, librarian as L
|
|
782
|
+
bom_path = self._bom_path(bom)
|
|
783
|
+
out = self._librarian_dir(bom_path)
|
|
784
|
+
rd = self._datasheet_reader(out, batch=not sync, wait=wait, yes=yes)
|
|
785
|
+
if not rd.key:
|
|
786
|
+
raise ValueError(f"{D.settings(self.rules())['api_key_env']} is not set: the datasheet reader needs a Claude API key "
|
|
787
|
+
"(datasheet_estimate works without one)")
|
|
788
|
+
log = []
|
|
789
|
+
rd.say = lambda m: log.append(str(m).strip())
|
|
790
|
+
if (out / "plan.json").exists():
|
|
791
|
+
items = D.fill_plan(out / "plan.json", rd, self.rules(), log=log.append, parts=set(parts or []) or None)
|
|
792
|
+
else:
|
|
793
|
+
self._read_bom(bom_path)
|
|
794
|
+
provs = self._librarian_providers()
|
|
795
|
+
items = L.run(str(bom_path), self.libs + [str(self.root / "footprints")], out, None, None, False, [], self.offline,
|
|
796
|
+
self.root / "http_cache", 0.5, provs, http=rd.http, log=log.append, rules=self.rules(), reader=rd)
|
|
797
|
+
state = rd._state()
|
|
798
|
+
r = self._librarian_summary(bom_path, items, log)
|
|
799
|
+
r["batches"] = [{k: b.get(k) for k in ("id", "status", "counts", "collected", "errors")} for b in state["batches"]][-5:]
|
|
800
|
+
r["pending"] = sum(1 for b in state["batches"] if not b.get("collected"))
|
|
801
|
+
return r
|
|
802
|
+
|
|
803
|
+
def datasheet_estimate(self, bom):
|
|
804
|
+
"""Tokens and cost of reading the datasheets the librarian plan still needs, before anything is sent (exact
|
|
805
|
+
input counts with an API key, rough without). Downloads the datasheets (cached) to count their pages."""
|
|
806
|
+
from . import datasheet as D
|
|
807
|
+
bom_path = self._bom_path(bom)
|
|
808
|
+
out = self._librarian_dir(bom_path)
|
|
809
|
+
plan_f = out / "plan.json"
|
|
810
|
+
if not plan_f.exists():
|
|
811
|
+
raise ValueError("no librarian plan for this BOM yet: run the librarian (Fill missing parts) first")
|
|
812
|
+
est = D.estimate_plan(plan_f, self._datasheet_reader(out, estimate=True))
|
|
813
|
+
return dict(est, key_set=est["exact_input"])
|
|
814
|
+
|
|
815
|
+
def cross_checks(self, bom):
|
|
816
|
+
"""Which datasheet parts of the librarian plan were cross-checked against a second source (ecadlib.crosscheck)
|
|
817
|
+
and how: agreed, disagreed (held, both values in the review), unchecked or skipped. Sends nothing."""
|
|
818
|
+
import json
|
|
819
|
+
from . import crosscheck as CC
|
|
820
|
+
out = self._librarian_dir(self._bom_path(bom))
|
|
821
|
+
plan_f = out / "plan.json"
|
|
822
|
+
if not plan_f.exists():
|
|
823
|
+
raise ValueError("no librarian plan for this BOM yet: run the librarian (Fill missing parts) first")
|
|
824
|
+
parts = [{"part": it["name"], "status": it.get("status"), "cross_check": it["cross_check"]["status"],
|
|
825
|
+
"summary": it["cross_check"].get("summary", "")}
|
|
826
|
+
for it in json.loads(plan_f.read_text(encoding="utf-8")) if it.get("cross_check")]
|
|
827
|
+
counts = {s: sum(p["cross_check"] == s for p in parts) for s in ("agreed", "disagreed", "unchecked", "skipped")}
|
|
828
|
+
return {"parts": parts, "counts": counts, "modes": list(CC.MODES)}
|
|
829
|
+
|
|
830
|
+
def library_parts(self, bom, pictures=True):
|
|
831
|
+
"""The librarian plan of a BOM as cards (ecadlib.partview): status in plain words, held reasons with a next
|
|
832
|
+
step, cross-check and KiCad/Altium results with both values, datasheet link, cost and model, a footprint
|
|
833
|
+
picture. Read-only, no network."""
|
|
834
|
+
from . import partview as PV
|
|
835
|
+
bom_path, plan_f, items = self._plan(bom)
|
|
836
|
+
reports = [self.rel(f) for f in (plan_f.parent / n for n in ("review.md", "release_check.md", "plan.json")) if f.exists()]
|
|
837
|
+
return dict(PV.cards(plan_f.parent, items, pictures), bom=self._bom_label(bom_path), plan=self.rel(plan_f), reports=reports)
|
|
838
|
+
|
|
839
|
+
# ------------------------------------------------------------------ library hub (ecadlib.hub)
|
|
840
|
+
def hub(self, q="", have=(), lack=(), states=(), origin="", flags=(), sort="name", page=1, size=100, force=False,
|
|
841
|
+
tool="", library="", project=""):
|
|
842
|
+
"""Every part once across your libraries and ecadlib's own, with a cell per tool (missing / same / different /
|
|
843
|
+
behind), last updated and by whom. Filters as in hub.listing. Reads only; notes go to <workspace>/hub/."""
|
|
844
|
+
from . import hub as H
|
|
845
|
+
bad = [t for t in [*have, *lack, *([tool] if tool else [])] if t not in H.LABEL] + [s for s in states if s not in H.STATES]
|
|
846
|
+
if bad:
|
|
847
|
+
raise ValueError(f"unknown tool or state {bad[0]!r}: tools are {', '.join(H.LABEL)}; states {', '.join(H.STATES)}")
|
|
848
|
+
return H.listing(self, q, have, lack, states, origin, flags, sort, page, size, force, tool, library, project)
|
|
849
|
+
|
|
850
|
+
def hub_part(self, key):
|
|
851
|
+
"""One hub part: each tool's copy (where, updated, by, what differs), footprint and symbol pictures, sources,
|
|
852
|
+
release check, sign-off, where-used, lifecycle, history and the fixes on offer."""
|
|
853
|
+
from . import hub as H
|
|
854
|
+
return H.part(self, key)
|
|
855
|
+
|
|
856
|
+
def hub_fix(self, key, action, who=None):
|
|
857
|
+
"""Run one of a hub part's fixes: kicad (write the KiCad library of its BOM), a365_preview (Altium 365 dry run),
|
|
858
|
+
dedupe (find duplicates in its plan), tool:<id> (another tool's exporter). "build" is hub_build_bom + the
|
|
859
|
+
librarian; "open_plan" happens in the browser."""
|
|
860
|
+
from . import hub as H
|
|
861
|
+
row, copies = H.copies_of(self, key)
|
|
862
|
+
offered = {f["action"] for f in H.fixes(row, copies)}
|
|
863
|
+
if action not in offered:
|
|
864
|
+
raise ValueError(f"{action!r} is not on offer for {row['name']}: {', '.join(sorted(offered)) or 'nothing to fix'}")
|
|
865
|
+
if action.startswith("copy:"):
|
|
866
|
+
return {"action": action, "part": row["name"], "result": H.copy_parts(self, [key], action[5:], "", who)}
|
|
867
|
+
pc = next(c for c in copies if c["plan"] and c["origin"] == "ecadlib" and c["bom"])
|
|
868
|
+
if action == "kicad":
|
|
869
|
+
out = self.kicad_dblib(pc["bom"])
|
|
870
|
+
what = "made the KiCad version"
|
|
871
|
+
elif action == "a365_preview":
|
|
872
|
+
r = self.altium365_push(pc["bom"], None, dry_run=True)
|
|
873
|
+
out = dict(r, results=[x for x in r["results"] if x.get("name") == pc["name"]])
|
|
874
|
+
what = None
|
|
875
|
+
elif action == "dedupe":
|
|
876
|
+
out, what = self.find_duplicates(pc["bom"]), None
|
|
877
|
+
elif action.startswith("tool:"):
|
|
878
|
+
reg = H._READERS[action[5:]]
|
|
879
|
+
items = json.loads(self.inside(pc["plan"]).read_text(encoding="utf-8"))
|
|
880
|
+
out = reg["fix"](self, self.inside(pc["plan"]), items[pc["item"]],
|
|
881
|
+
**({"bom": pc["bom"]} if getattr(reg["fix"], "wants_bom", False) else {}))
|
|
882
|
+
what = f"made the {reg['label']} version"
|
|
883
|
+
else:
|
|
884
|
+
raise ValueError(f"{action!r} runs from the web page, not here")
|
|
885
|
+
if what:
|
|
886
|
+
H.record(self.root, [{"when": H.now(), "who": who, "what": what, "kind": "action", "key": key, "name": row["name"],
|
|
887
|
+
"bom": pc["bom"], "tool": action[5:] if action.startswith("tool:") else action}])
|
|
888
|
+
return {"action": action, "part": row["name"], "result": out}
|
|
889
|
+
|
|
890
|
+
def hub_copy(self, keys, tool, project="", who=None):
|
|
891
|
+
"""Copy parts to another tool through the hub (Altium <-> KiCad), into <workspace>/hub/library; with a project
|
|
892
|
+
listed under [hub] projects, also import that library into the project. Your own library files are not edited."""
|
|
893
|
+
from . import hub as H
|
|
894
|
+
return H.copy_parts(self, keys, tool, project, who)
|
|
895
|
+
|
|
896
|
+
def hub_build_bom(self, key):
|
|
897
|
+
"""A one-line BOM for a part that is only in your libraries (for "Build it with ecadlib") -> its workspace path."""
|
|
898
|
+
from . import hub as H
|
|
899
|
+
row, copies = H.copies_of(self, key)
|
|
900
|
+
if not row["mpn"]:
|
|
901
|
+
raise ValueError(f"{row['name']} has no manufacturer part number, so ecadlib can't look it up")
|
|
902
|
+
return self.save_upload(f"hub_{self.safe_name(row['mpn'])}.csv", H.one_line_bom(row, copies))
|
|
903
|
+
|
|
904
|
+
def demo_bom(self):
|
|
905
|
+
"""Copy the bundled sample BOM (the one `ecadlib demo` uses) into uploads/demo/; -> its workspace path."""
|
|
906
|
+
import shutil
|
|
907
|
+
from .firstrun import DEMO_BOM
|
|
908
|
+
if self.root.resolve() == Path(self.root.resolve().anchor):
|
|
909
|
+
raise ValueError("the workspace is the filesystem root; start ecadlib ui with --workspace FOLDER")
|
|
910
|
+
dest = self.root / "uploads" / "demo" / DEMO_BOM.name
|
|
911
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
912
|
+
if not dest.exists() or dest.read_bytes() != DEMO_BOM.read_bytes():
|
|
913
|
+
shutil.copyfile(DEMO_BOM, dest)
|
|
914
|
+
return self.rel(dest)
|
|
915
|
+
|
|
916
|
+
def altium365_workspaces(self):
|
|
917
|
+
from . import altium365 as A
|
|
918
|
+
return {"workspaces": A.Nexar(_nexar_token()).workspaces()}
|
|
919
|
+
|
|
920
|
+
def altium365_push(self, bom, workspace_url, dry_run=True):
|
|
921
|
+
"""Release ready parts and update parameters of existing ones; parts the release check held are left
|
|
922
|
+
alone. dry_run (the default) sends nothing and returns the requests that would be made."""
|
|
923
|
+
from . import altium365 as A
|
|
924
|
+
if not workspace_url and not dry_run:
|
|
925
|
+
raise ValueError("choose an Altium 365 workspace first")
|
|
926
|
+
plan = self.altium365_plan(bom)
|
|
927
|
+
client = None if dry_run else A.Nexar(_nexar_token())
|
|
928
|
+
policy = None
|
|
929
|
+
if plan["source"] == "librarian": # the sign-off policy as it is now, not as at the last check
|
|
930
|
+
from .approvals import load_policy
|
|
931
|
+
policy = load_policy(self.inside(plan["plan"]).parent)
|
|
932
|
+
results = A.push(plan["items"], client, workspace_url or "https://<workspace>", dry_run=dry_run, policy=policy,
|
|
933
|
+
rules=self.rules())
|
|
934
|
+
if any(r.get("a365_updated") for r in results):
|
|
935
|
+
from .a365_sync import save_meta # new revisions of pulled parts, so the next push is not a conflict
|
|
936
|
+
save_meta(self.inside(plan["plan"]), plan["items"])
|
|
937
|
+
out = self.root / "altium365" / f"push_{'dry_run_' if dry_run else ''}{self._key(self._bom_path(bom))}.json"
|
|
938
|
+
out.write_text(json.dumps(results, indent=2), encoding="utf-8")
|
|
939
|
+
return {"dry_run": dry_run, "results": results, "report": self.rel(out)}
|
|
940
|
+
|
|
941
|
+
# ------------------------------------------------------------------ two-way sync (ecadlib.a365_sync)
|
|
942
|
+
def _a365_plan(self, bom=None):
|
|
943
|
+
"""The plan a pull writes into: the librarian plan of a BOM (so the librarian reuses workspace parts), or
|
|
944
|
+
altium365/workspace_plan.json for the whole workspace library."""
|
|
945
|
+
if bom:
|
|
946
|
+
f = self._librarian_dir(self._bom_path(bom)) / "plan.json"
|
|
947
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
948
|
+
return self.inside(self.rel(f))
|
|
949
|
+
return self.root / "altium365" / "workspace_plan.json"
|
|
950
|
+
|
|
951
|
+
def _a365_run(self, mode, workspace_url, bom=None, folders=None, client=None):
|
|
952
|
+
from . import a365_sync as S, altium365 as A
|
|
953
|
+
if not workspace_url:
|
|
954
|
+
raise ValueError("choose an Altium 365 workspace first")
|
|
955
|
+
r = S.run(mode, self._a365_plan(bom), client or A.Nexar(_nexar_token()), workspace_url,
|
|
956
|
+
folders=folders or None, rules=self.rules(), log=lambda *_: None)
|
|
957
|
+
return {"mode": mode, "summary": r["summary"], "conflicts": r["conflicts"], "unresolved": r["unresolved"],
|
|
958
|
+
"rows": [x for x in r["rows"] if x["status"] != "in_sync"], "components": len(r["components"]),
|
|
959
|
+
"plan": self.rel(r["plan"]), "state": self.rel(r["state"]) if mode != "status" else None,
|
|
960
|
+
"report": self.rel(r["report"])}
|
|
961
|
+
|
|
962
|
+
def a365_pull(self, workspace_url, bom=None, folders=None, client=None):
|
|
963
|
+
"""Import the workspace's components into a plan (status `existing`), refresh ones pulled before, download
|
|
964
|
+
their .SchLib/.PcbLib. Changes local files only; conflicts are reported, not settled."""
|
|
965
|
+
return self._a365_run("pull", workspace_url, bom, folders, client)
|
|
966
|
+
|
|
967
|
+
def a365_status(self, workspace_url, bom=None, client=None):
|
|
968
|
+
"""Read-only comparison of the plan with the workspace (new, changed on either side, conflicts, deleted)."""
|
|
969
|
+
return self._a365_run("status", workspace_url, bom, None, client)
|
|
970
|
+
|
|
971
|
+
# ------------------------------------------------------------------ library agent: watch (ecadlib.agent_watch)
|
|
972
|
+
def agent_watch_scan(self, sources=None, dry_run=False, client=None):
|
|
973
|
+
"""New / changed / removed parts in the watched projects and BOMs ([agent.watch] sources, or `sources`), compared
|
|
974
|
+
with the last scan (agent/watch_state.json; dry_run leaves it alone). Relative refs are workspace-relative."""
|
|
975
|
+
from . import agent_watch as AW
|
|
976
|
+
rep = AW.scan_report(self, sources=sources, client=client, dry_run=dry_run)
|
|
977
|
+
rep["state"] = self.rel(rep["state"])
|
|
978
|
+
return rep
|
|
979
|
+
|
|
980
|
+
# ------------------------------------------------------------------ library rules (ecadlib.rules / rules_infer)
|
|
981
|
+
def rules_file(self):
|
|
982
|
+
"""<workspace>/ecadlib.toml (or ecadlib_rules.json when only that exists)."""
|
|
983
|
+
return next((self.root / n for n in RU.FILES if (self.root / n).is_file()), self.root / RU.FILES[0])
|
|
984
|
+
|
|
985
|
+
def rules_in_use(self):
|
|
986
|
+
"""The rules file the workspace follows: its own, else the one every ecadlib command run here finds (the
|
|
987
|
+
workspace's parents, then the current folder and its parents: `ecadlib init` writes ./ecadlib.toml). The
|
|
988
|
+
tools the workspace runs by path (DbLib export, approvals, upkeep) search the same way, so all agree. None: no file."""
|
|
989
|
+
f = self.rules_file()
|
|
990
|
+
return f if f.is_file() else RU.find(self.root)
|
|
991
|
+
|
|
992
|
+
def _label(self, p):
|
|
993
|
+
return self.rel(p) if self._is_inside(p) else str(p)
|
|
994
|
+
|
|
995
|
+
def rules(self):
|
|
996
|
+
"""The rules in use (rules_in_use) merged over the defaults (no file: ecadlib's own behaviour)."""
|
|
997
|
+
f = self.rules_in_use()
|
|
998
|
+
return RU.load(f) if f else RU.load({})
|
|
999
|
+
|
|
1000
|
+
def rules_info(self):
|
|
1001
|
+
f = self.rules_in_use()
|
|
1002
|
+
return {"file": self._label(f or self.rules_file()), "exists": bool(f), "text": f.read_text(encoding="utf-8") if f else "",
|
|
1003
|
+
"rules": RU.effective(self.rules()), "defaults": RU.DEFAULTS}
|
|
1004
|
+
|
|
1005
|
+
def _rules_sources(self):
|
|
1006
|
+
"""What a scan looks at when the person names nothing: the --lib folders, uploaded BOMs and Altium 365 state."""
|
|
1007
|
+
out = list(self.libs) + [str(self.root / "uploads")]
|
|
1008
|
+
out += [str(p) for p in sorted(self.root.glob("**/a365_state.json")) if self._is_inside(p)]
|
|
1009
|
+
return out
|
|
1010
|
+
|
|
1011
|
+
def propose_rules(self, paths=None, a365=None, choices=None, progress=None):
|
|
1012
|
+
"""Scan existing libraries (Altium .SchLib/.PcbLib/.DbLib, parts tables, KiCad libraries, BOMs, Altium 365
|
|
1013
|
+
component lists) and propose rules with evidence and a confidence per setting. Nothing is written; the
|
|
1014
|
+
proposal's `toml` goes to save_rules. paths default to the workspace's --lib folders and uploaded BOMs.
|
|
1015
|
+
choices {setting key: value} answer the proposal's questions. A diff is included when a file exists."""
|
|
1016
|
+
from . import rules_infer as I
|
|
1017
|
+
paths, a365 = ([x] if isinstance(x, (str, Path)) else x for x in (paths, a365)) # one path, not its letters
|
|
1018
|
+
paths = [str(p).strip() for p in (paths or []) if str(p).strip()] or self._rules_sources()
|
|
1019
|
+
if progress:
|
|
1020
|
+
progress(0, 1, "scanning " + ", ".join(Path(p).name for p in paths)[:80])
|
|
1021
|
+
out = I.propose(paths, [str(x) for x in (a365 or [])], choices, existing=self.rules_file())
|
|
1022
|
+
out["paths"], out["file"] = paths, self.rel(self.rules_file())
|
|
1023
|
+
if progress:
|
|
1024
|
+
progress(1, 1, "done")
|
|
1025
|
+
return out
|
|
1026
|
+
|
|
1027
|
+
def save_rules(self, text, overwrite=False):
|
|
1028
|
+
"""Validate and write <workspace>/ecadlib.toml. An existing different file is only replaced with overwrite=True
|
|
1029
|
+
(kept as ecadlib.toml.bak); otherwise the diff comes back with needs_confirm, for the person to look at."""
|
|
1030
|
+
from . import rules_infer as I
|
|
1031
|
+
if not isinstance(text, str) or not text.strip():
|
|
1032
|
+
raise ValueError("no rules text given")
|
|
1033
|
+
res = I.save(text, self.rules_file(), bool(overwrite))
|
|
1034
|
+
res["path"] = self.rel(self.rules_file())
|
|
1035
|
+
if res.get("backup"):
|
|
1036
|
+
res["backup"] = self.rel(res["backup"])
|
|
1037
|
+
return res
|
|
1038
|
+
|
|
1039
|
+
# ------------------------------------------------------------------ house style (ecadlib.house_style)
|
|
1040
|
+
def _style_file(self):
|
|
1041
|
+
from . import house_style as HS
|
|
1042
|
+
return HS.profile_path(self.rules(), self.root)
|
|
1043
|
+
|
|
1044
|
+
def house_style(self):
|
|
1045
|
+
"""The learned house style: settings, description patterns, value formats, pin notation, the person's
|
|
1046
|
+
direction and the model's guide. {exists: False} before `learn_house_style`."""
|
|
1047
|
+
from . import house_style as HS
|
|
1048
|
+
f = self._style_file()
|
|
1049
|
+
prof = HS.load(self.rules(), path=f) if f.is_file() else None
|
|
1050
|
+
if not prof:
|
|
1051
|
+
return {"exists": False, "file": self._label(f)}
|
|
1052
|
+
return {"exists": True, "file": self._label(f), "summary": HS.summary(prof),
|
|
1053
|
+
**{k: prof.get(k) for k in ("learned_at", "learned_from", "components", "categories", "settings", "descriptions",
|
|
1054
|
+
"param_formats", "param_presence", "pins", "direction", "guide")}}
|
|
1055
|
+
|
|
1056
|
+
def learn_house_style(self, paths=None, direction=None, ai=False, model=None, progress=None):
|
|
1057
|
+
"""Learn the house style from the person's library (paths default to the --lib folders), keep their direction
|
|
1058
|
+
(a line or a list of lines, added to what is there), and with ai=True have the configured model write the
|
|
1059
|
+
guide and the settings the direction implies. Writes <workspace>/house_style.json; ecadlib.toml is untouched."""
|
|
1060
|
+
from . import house_style as HS
|
|
1061
|
+
paths = [paths] if isinstance(paths, (str, Path)) else paths
|
|
1062
|
+
paths = [str(x).strip() for x in (paths or []) if str(x).strip()] or self._rules_sources()
|
|
1063
|
+
direction = [direction] if isinstance(direction, str) else list(direction or [])
|
|
1064
|
+
f = self._style_file()
|
|
1065
|
+
old = HS.load(self.rules(), path=f) if f.is_file() else None
|
|
1066
|
+
if progress:
|
|
1067
|
+
progress(0, 2 if ai else 1, "scanning " + ", ".join(Path(x).name for x in paths)[:80])
|
|
1068
|
+
prof = HS.learn(paths, None, direction, old)
|
|
1069
|
+
prof["_file"] = str(f)
|
|
1070
|
+
if ai:
|
|
1071
|
+
if progress:
|
|
1072
|
+
progress(1, 2, "the model is writing the guide")
|
|
1073
|
+
HS.write_guide(prof, self.rules(), model)
|
|
1074
|
+
HS.save(prof, f)
|
|
1075
|
+
return self.house_style()
|
|
1076
|
+
|
|
1077
|
+
def style_direction(self, text=None, clear=False):
|
|
1078
|
+
"""Add a line of direction (what the person wants new parts to look like); clear=True removes the old ones."""
|
|
1079
|
+
from . import house_style as HS
|
|
1080
|
+
f = self._style_file()
|
|
1081
|
+
prof = (HS.load(self.rules(), path=f) if f.is_file() else None) or {"schema_version": HS.SCHEMA_VERSION,
|
|
1082
|
+
"direction": [], "learned_from": []}
|
|
1083
|
+
if clear:
|
|
1084
|
+
prof["direction"] = []
|
|
1085
|
+
if text:
|
|
1086
|
+
HS.add_direction(prof, text)
|
|
1087
|
+
HS.save(prof, f)
|
|
1088
|
+
return {"file": self._label(f), "direction": [d["text"] for d in prof.get("direction") or []]}
|
|
1089
|
+
|
|
1090
|
+
def check_house_style(self, bom, part=None):
|
|
1091
|
+
"""How far each part of a BOM's plan is from the house style: per part, the findings code can fix and the
|
|
1092
|
+
ones a person has to (a missing house parameter, a name pattern)."""
|
|
1093
|
+
from . import house_style as HS
|
|
1094
|
+
_, _, items = self._plan(bom)
|
|
1095
|
+
prof = self._style_or_raise()
|
|
1096
|
+
return {it["name"]: HS.check_item(it, prof, self.rules()) for it in items if not part or it["name"] == part}
|
|
1097
|
+
|
|
1098
|
+
def apply_house_style(self, bom, ai=False, model=None, dry_run=True, part=None):
|
|
1099
|
+
"""Adapt a BOM's plan to the house style: exact fixes by code, and with ai=True rewording by the model
|
|
1100
|
+
(checked: no new numbers, no identity fields). dry_run (default) shows the changes and writes nothing."""
|
|
1101
|
+
from . import house_style as HS
|
|
1102
|
+
prof = self._style_or_raise()
|
|
1103
|
+
with self._plan_lock():
|
|
1104
|
+
_, plan_f, items = self._plan(bom)
|
|
1105
|
+
only = {part} if part else None
|
|
1106
|
+
done = HS.adapt_items(items, prof, self.rules(), only)
|
|
1107
|
+
rejected = []
|
|
1108
|
+
if ai:
|
|
1109
|
+
more, rejected = HS.adapt_with_model(items, prof, self.rules(), model, only=only)
|
|
1110
|
+
for k, v in more.items():
|
|
1111
|
+
done.setdefault(k, []).extend(v)
|
|
1112
|
+
if done and not dry_run:
|
|
1113
|
+
bak = plan_f.with_name(plan_f.name + ".style.bak")
|
|
1114
|
+
if not bak.exists():
|
|
1115
|
+
bak.write_text(plan_f.read_text(encoding="utf-8"), encoding="utf-8")
|
|
1116
|
+
from .fsutil import write_text_atomic
|
|
1117
|
+
write_text_atomic(plan_f, json.dumps(items, indent=2))
|
|
1118
|
+
return {"changes": done, "rejected": rejected, "written": bool(done) and not dry_run, "plan": self.rel(plan_f)}
|
|
1119
|
+
|
|
1120
|
+
def _style_or_raise(self):
|
|
1121
|
+
from . import house_style as HS
|
|
1122
|
+
f = self._style_file()
|
|
1123
|
+
prof = HS.load(self.rules(), path=f) if f.is_file() else None
|
|
1124
|
+
if not prof:
|
|
1125
|
+
raise ValueError("no house style yet: learn it from the library first (learn_house_style)")
|
|
1126
|
+
return prof
|
|
1127
|
+
|
|
1128
|
+
def file_formats(self, migrate=False):
|
|
1129
|
+
"""Versioned files in this workspace (ecadlib/migrate.py): kind, version, ok / old / new. migrate=True brings
|
|
1130
|
+
old ones to this version (backups in .ecadlib_backup/ next to them); files from a newer ecadlib are refused."""
|
|
1131
|
+
from . import migrate as MG
|
|
1132
|
+
rows = MG.migrate_tree(self.root, dry_run=not migrate, log=lambda *a: None)
|
|
1133
|
+
for r in rows:
|
|
1134
|
+
r["path"] = self.rel(Path(r["path"]))
|
|
1135
|
+
return {"rows": rows, "old": sum(r["state"] == "old" for r in rows),
|
|
1136
|
+
"newer": sum(r["state"] in ("new", "error") for r in rows), "migrated": bool(migrate)}
|
|
1137
|
+
|
|
1138
|
+
def rebuild_check(self, bom):
|
|
1139
|
+
"""Rebuild the BOM's library from its library.lock.json offline in a temporary copy (ecadlib/rebuild.py) and
|
|
1140
|
+
report per part and file: identical, identical after normalising dates, or drift; and inputs that changed."""
|
|
1141
|
+
from . import rebuild as RB
|
|
1142
|
+
d = self._librarian_dir(self._bom_path(bom))
|
|
1143
|
+
if not (d / RB.LOCK).is_file():
|
|
1144
|
+
raise ValueError("no library.lock.json for this BOM yet: run the librarian (Fill missing parts) first")
|
|
1145
|
+
rep = RB.check(d, log=lambda *a: None)
|
|
1146
|
+
rep["lock"] = self.rel(Path(rep["lock"]))
|
|
1147
|
+
return rep
|
|
1148
|
+
|
|
1149
|
+
def visual_check(self, target, datasheet=None, pages=None, symbol=None, render_only=False, client=None):
|
|
1150
|
+
"""Claude visual proof (ecadlib.visual_check): render our footprint / symbol (TOP VIEW, pin 1, pitch, grid)
|
|
1151
|
+
next to the datasheet pages and ask Claude whether they match. target: a part name in this workspace
|
|
1152
|
+
(approved footprint, librarian plan) or a workspace path to a .kicad_mod / package spec .json. Writes the
|
|
1153
|
+
pictures and proof.json under agent/proofs/<part>/. No API key -> verdict "skipped" (pictures still written)."""
|
|
1154
|
+
from . import visual_check as VC
|
|
1155
|
+
item = {"datasheet": datasheet, "pages": pages, "symbol": None}
|
|
1156
|
+
try:
|
|
1157
|
+
p = self.inside(target)
|
|
1158
|
+
except ValueError:
|
|
1159
|
+
p = None
|
|
1160
|
+
if p is not None and p.is_file():
|
|
1161
|
+
item.update(footprint=str(p), part=p.stem)
|
|
1162
|
+
else:
|
|
1163
|
+
found = VC._find_part(str(target), self.root)
|
|
1164
|
+
if not found:
|
|
1165
|
+
raise ValueError(f"{target}: not a footprint file or a part in this workspace")
|
|
1166
|
+
item.update({k: v for k, v in found.items() if not item.get(k)}, part=str(target))
|
|
1167
|
+
if symbol:
|
|
1168
|
+
item["symbol"] = str(self.inside(symbol))
|
|
1169
|
+
if datasheet and not str(datasheet).startswith("http"):
|
|
1170
|
+
item["datasheet"] = str(self.inside(datasheet))
|
|
1171
|
+
out = self.root / "agent" / "proofs" / self.safe_name(item["part"])
|
|
1172
|
+
pr = VC.prove(item, self.root, self.rules(), client=client, out_dir=out, render_only=render_only)
|
|
1173
|
+
pr["proof_file"] = self.rel(VC.write_proof(pr, out / "proof.json"))
|
|
1174
|
+
pr["images"] = {k: [self.rel(f) for f in v] for k, v in pr["images"].items()}
|
|
1175
|
+
return pr
|
|
1176
|
+
|
|
1177
|
+
def export_parts(self, bom, fmt="csv", include_held=False, name="ecadlib"):
|
|
1178
|
+
"""The library's parts table (stable columns, ecadlib.parts_export) for a BOM's plan, as reports/parts_<bom>.<fmt>."""
|
|
1179
|
+
from . import parts_export as PE
|
|
1180
|
+
if fmt not in ("csv", "json", "xlsx"):
|
|
1181
|
+
raise ValueError(f"format must be csv, json or xlsx, not {str(fmt)[:20]!r}")
|
|
1182
|
+
bom_path, plan_f, _ = self._plan(bom)
|
|
1183
|
+
rows = PE.rows(plan_f, name, include_held)
|
|
1184
|
+
out = PE.write(rows, self.root / "reports" / f"parts_{self._key(bom_path)}.{fmt}", fmt)
|
|
1185
|
+
return {"file": self.rel(out), "parts": len(rows), "columns": PE.COLUMNS}
|
|
1186
|
+
|
|
1187
|
+
def kicad_dblib(self, bom, name="ecadlib", driver=None, include_held=False):
|
|
1188
|
+
"""KiCad files (kicad-export) plus a KiCad database library (.kicad_dbl + SQLite) for a BOM's plan, in kicad/<bom>/."""
|
|
1189
|
+
from . import kicad_dblib as KD
|
|
1190
|
+
from .kicad_export import kname
|
|
1191
|
+
bom_path, plan_f, _ = self._plan(bom)
|
|
1192
|
+
out = self.root / "kicad" / self._key(bom_path)
|
|
1193
|
+
rep = KD.build(plan_f, out, kname(str(name))[:60], include_held, self.rules_in_use(), driver or KD.DEFAULT_DRIVER,
|
|
1194
|
+
export=True, http=CachedHTTP(self.root / "http_cache", offline=self.offline), log=lambda *a: None)
|
|
1195
|
+
return {"folder": self.rel(out), "files": [self.rel(p) for p in sorted(out.iterdir()) if p.is_file()],
|
|
1196
|
+
"tables": rep["tables"], "skipped": rep["skipped"], "warnings": rep["warnings"],
|
|
1197
|
+
"kicad_export": rep.get("kicad_export")}
|
|
1198
|
+
|
|
1199
|
+
def tool_export(self, bom, tool, name="ecadlib", include_held=False):
|
|
1200
|
+
"""The BOM's library for another tool, written natively and read back pad by pad: tool "eagle" (Autodesk Fusion
|
|
1201
|
+
Electronics / EAGLE .lbr, in fusion/<bom>/) or "easyeda" (EasyEDA Pro .elibz, in easyeda/<bom>/). "bundle" is
|
|
1202
|
+
one zip to hand over (library, STEP models, INSTALL.md with the import steps and the model of each footprint)."""
|
|
1203
|
+
from .kicad_export import kname
|
|
1204
|
+
mods = {"eagle": ("eagle_export", "fusion"), "easyeda": ("easyeda_pro_export", "easyeda")}
|
|
1205
|
+
if tool not in mods:
|
|
1206
|
+
raise ValueError(f"tool must be eagle or easyeda, not {str(tool)[:20]!r}")
|
|
1207
|
+
import importlib
|
|
1208
|
+
mod = importlib.import_module(f"ecadlib.{mods[tool][0]}")
|
|
1209
|
+
bom_path, plan_f, _ = self._plan(bom)
|
|
1210
|
+
out = self.root / mods[tool][1] / self._key(bom_path)
|
|
1211
|
+
rep = mod.export(plan_f, out, kname(str(name))[:60], include_held, http=CachedHTTP(self.root / "http_cache", offline=self.offline),
|
|
1212
|
+
log=lambda *a: None, rules=self.rules_in_use())
|
|
1213
|
+
return {"folder": self.rel(out), "file": self.rel(out / rep["file"]), "bundle": self.rel(out / rep["bundle"]),
|
|
1214
|
+
"install": self.rel(out / rep["install"]), "counts": rep["counts"],
|
|
1215
|
+
"ok": not rep["errors"], "errors": rep["errors"][:30], "approximations": rep["warnings"][:30],
|
|
1216
|
+
"models": len(rep["models"]), "kicad_reread": rep.get("kicad_reread"),
|
|
1217
|
+
"report": self.rel(out / ("eagle_export.json" if tool == "eagle" else "easyeda_export.json"))}
|
|
1218
|
+
|
|
1219
|
+
def parity(self, bom, kicad=False, name="ecadlib"):
|
|
1220
|
+
"""KiCad vs Altium parity of a BOM's library (ecadlib.parity): the plan's .kicad_mod against its .PcbLib / .SchLib,
|
|
1221
|
+
or with kicad=True the KiCad export in kicad/<bom>/ (run kicad_dblib first) against the Altium files, its
|
|
1222
|
+
.kicad_dbl rows included. Report in reports/parity_<bom>.md / .json. Never touches the network."""
|
|
1223
|
+
from . import parity as PY
|
|
1224
|
+
from .kicad_export import kname
|
|
1225
|
+
bom_path, plan_f, _ = self._plan(bom)
|
|
1226
|
+
kdir = None
|
|
1227
|
+
if kicad:
|
|
1228
|
+
kdir = self.root / "kicad" / self._key(bom_path)
|
|
1229
|
+
if not (kdir / f"{kname(str(name))[:60]}.kicad_sym").is_file():
|
|
1230
|
+
raise ValueError(f"no KiCad export in {self.rel(kdir)} yet: run kicad_dblib (or kicad-export) first")
|
|
1231
|
+
rep = PY.check_plan(plan_f, kdir, kname(str(name))[:60] if kdir else None, rules=self.rules_in_use())
|
|
1232
|
+
stem = PY.write_report(rep, self.root / "reports" / f"parity_{self._key(bom_path)}")
|
|
1233
|
+
bad = [p for p in rep["parts"] if p["result"] == "mismatch"]
|
|
1234
|
+
return {"mode": rep["mode"], "ok": rep["ok"], "counts": rep["counts"], "report": self.rel(stem.with_suffix(".md")),
|
|
1235
|
+
"mismatches": {p["part"]: [d["msg"] for d in p["diffs"] if d["level"] == "error"][:20] for p in bad},
|
|
1236
|
+
"database": [d["msg"] for d in rep["database"]][:50]}
|
|
1237
|
+
|
|
1238
|
+
def tool_packages(self, bom, name="ecadlib", kicad=8, include_held=False):
|
|
1239
|
+
"""Packages that plug into the tools themselves, for a BOM's plan, in packages/<bom>/: a KiCad Plugin and
|
|
1240
|
+
Content Manager zip (kicad_pcm) and the Altium hand-off folder (altium_pkg: .LibPkg, install script, DbLib)."""
|
|
1241
|
+
from . import altium_pkg as AP, kicad_pcm as KP
|
|
1242
|
+
from .kicad_export import kname
|
|
1243
|
+
bom_path, plan_f, _ = self._plan(bom)
|
|
1244
|
+
lib = kname(str(name))[:60]
|
|
1245
|
+
out = self.root / "packages" / self._key(bom_path)
|
|
1246
|
+
pcm = KP.build(plan_f, out / "kicad", lib, kicad=int(kicad), include_held=include_held,
|
|
1247
|
+
http=CachedHTTP(self.root / "http_cache", offline=self.offline), log=lambda *a: None)
|
|
1248
|
+
alt = AP.build(plan_f, out / "altium", lib, rules=self.rules_in_use(), log=lambda *a: None)
|
|
1249
|
+
return {"folder": self.rel(out),
|
|
1250
|
+
"kicad": {"zip": self.rel(Path(pcm["zip"])), "version": pcm["version"], "nickname": pcm["nickname"],
|
|
1251
|
+
"symbols": pcm["symbols"], "footprints": pcm["footprints"], "problems": pcm["problems"]},
|
|
1252
|
+
"altium": {"folder": self.rel(out / "altium"), "libpkg": alt["libpkg"], "script": alt["script"],
|
|
1253
|
+
"dblibs": alt["dblibs"], "documents": len(alt["documents"]), "problems": alt["problems"]}}
|
|
1254
|
+
|
|
1255
|
+
def altium_menu(self, win_dir=None, name="ecadlib", command="", port=8765):
|
|
1256
|
+
"""The "ecadlib" menu for Altium (ecadlib.altium_menu) in altium_menu/: a DelphiScript project whose entries
|
|
1257
|
+
add a part from its MPN, check the focused project's BOM and open the review queue, all in this workspace
|
|
1258
|
+
(settings use relative paths: workspace=.., library=..\\altium_library). win_dir: the Windows folder the
|
|
1259
|
+
altium_menu folder is in on the Altium PC, for the ready-made menu file (.DXPPrf)."""
|
|
1260
|
+
from . import altium_menu as AM
|
|
1261
|
+
out = self.root / "altium_menu"
|
|
1262
|
+
rep = AM.build(out, "..", "..\\altium_library", name, command, win_dir or None, int(port))
|
|
1263
|
+
return {"folder": self.rel(out), "files": [self.rel(p) for p in sorted(out.iterdir()) if p.is_file()],
|
|
1264
|
+
"written": rep["written"], "kept": rep["kept"], "menu_file": rep["menu_file"], "problems": rep["problems"],
|
|
1265
|
+
"procedures": rep["procedures"]}
|
|
1266
|
+
|
|
1267
|
+
def example_specs(self):
|
|
1268
|
+
d = Path(__file__).resolve().parent / "package_specs" # shipped as package data
|
|
1269
|
+
return {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted(d.glob("*.json"))} if d.exists() else {}
|
|
1270
|
+
|
|
1271
|
+
# ------------------------------------------------------------------ setup check and summary (ecadlib.doctor / ws_status)
|
|
1272
|
+
def status(self):
|
|
1273
|
+
"""One-screen summary (read-only): parts per librarian plan, held, waiting for sign-off, last triage, last
|
|
1274
|
+
upkeep, the library agent's state and queue counts. Paths are relative to the workspace."""
|
|
1275
|
+
from . import ws_status
|
|
1276
|
+
return ws_status.status(self.root)
|
|
1277
|
+
|
|
1278
|
+
def doctor(self):
|
|
1279
|
+
"""The `ecadlib doctor` checks for this workspace and its --lib folders: each {check, level OK/WARN/FAIL,
|
|
1280
|
+
detail, hint}. API keys are reported by name only."""
|
|
1281
|
+
from . import doctor
|
|
1282
|
+
rf = self.rules_in_use()
|
|
1283
|
+
return doctor.run(self.root, self.libs, str(rf) if rf else None)
|
|
1284
|
+
|
|
1285
|
+
# ------------------------------------------------------------------ plugins (ecadlib.plugins), notifications
|
|
1286
|
+
def _provider_classes(self):
|
|
1287
|
+
"""Triage sources: --providers when given, else [providers] order, else every built-in (today's default)."""
|
|
1288
|
+
from . import plugins as PL
|
|
1289
|
+
return PL.select(self.provider_names if self._providers_given else None, self.rules(),
|
|
1290
|
+
default=[c.name for c in P.ALL])
|
|
1291
|
+
|
|
1292
|
+
def _librarian_providers(self):
|
|
1293
|
+
"""The librarian's sources: [providers] order when set (and no --providers), else the free built-in two."""
|
|
1294
|
+
from . import plugins as PL
|
|
1295
|
+
if not self._providers_given and PL.order_set(self.rules()):
|
|
1296
|
+
return None
|
|
1297
|
+
return ",".join(p for p in ("jlcpcb_search", "easyeda") if p in self.provider_names)
|
|
1298
|
+
|
|
1299
|
+
def plugins(self):
|
|
1300
|
+
"""Installed provider / exporter plugins, the [providers] order in use and the [notify] targets (no secrets)."""
|
|
1301
|
+
from . import notify as N, plugins as PL
|
|
1302
|
+
d = PL.describe()
|
|
1303
|
+
r = self.rules()
|
|
1304
|
+
d["order"] = [c.name for c in self._provider_classes()]
|
|
1305
|
+
d["notify"] = [{"name": t["name"], "type": t["type"], "events": sorted(t["events"]), "url_env": t["url_env"],
|
|
1306
|
+
"url_set": bool(os.environ.get(t["url_env"]))} for t in N.settings(r)["targets"]]
|
|
1307
|
+
return d
|
|
1308
|
+
|
|
1309
|
+
def plugin_export(self, name, bom):
|
|
1310
|
+
"""Run exporter plugin `name` on the librarian plan of a BOM; files go to <ws>/exports/<name>/<bom stem>/."""
|
|
1311
|
+
from . import plugins as PL
|
|
1312
|
+
bom_path = self._bom_path(bom)
|
|
1313
|
+
plan = self._librarian_dir(bom_path) / "plan.json"
|
|
1314
|
+
if not plan.exists():
|
|
1315
|
+
raise ValueError("run the librarian on this BOM first (no plan.json)")
|
|
1316
|
+
out = self.root / "exports" / self.safe_name(name) / self.safe_name(bom_path.stem)
|
|
1317
|
+
files = PL.run_exporter(name, plan, out, self.rules())
|
|
1318
|
+
return {"exporter": name, "out": self.rel(out),
|
|
1319
|
+
"files": [self.rel(f) if self.root in Path(f).resolve().parents else str(f) for f in files]}
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def _now(ts=None):
|
|
1323
|
+
t = datetime.datetime.fromtimestamp(ts, datetime.timezone.utc) if ts else datetime.datetime.now(datetime.timezone.utc)
|
|
1324
|
+
return t.replace(microsecond=0).isoformat()
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def _nexar_token():
|
|
1328
|
+
tok = os.environ.get("NEXAR_TOKEN")
|
|
1329
|
+
if not tok:
|
|
1330
|
+
raise ValueError("set NEXAR_TOKEN before starting ecadlib (a Nexar portal token with the "
|
|
1331
|
+
"design.domain, user.access and openid scopes)")
|
|
1332
|
+
return tok
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
def _svg_outline(g):
|
|
1336
|
+
"""fp_line / fp_rect / fp_poly -> list of (x, y) points (KiCad frame) and whether it is closed."""
|
|
1337
|
+
if g[0] == "fp_poly":
|
|
1338
|
+
return [(float(c[1]), float(c[2])) for c in sexpr.find(sexpr.first(g, "pts") or [], "xy")], True
|
|
1339
|
+
(x1, y1), (x2, y2) = [(float(c[1]), float(c[2])) for c in (sexpr.first(g, "start"), sexpr.first(g, "end"))]
|
|
1340
|
+
return ([(x1, y1), (x2, y2)], False) if g[0] == "fp_line" else ([(x1, y1), (x2, y1), (x2, y2), (x1, y2)], True)
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def footprint_geometry(kicad_mod):
|
|
1344
|
+
"""What the preview draws, from the .kicad_mod itself (KiCad frame, y down): courtyard and fab outlines, copper
|
|
1345
|
+
pads (rotation, rounded corners, polygon pads) and drills. Each item carries its points for the bounds."""
|
|
1346
|
+
t = sexpr.parse(kicad_mod)
|
|
1347
|
+
out = {"crtyd": [], "fab": [], "pads": [], "holes": []}
|
|
1348
|
+
for kind in ("fp_line", "fp_rect", "fp_poly"):
|
|
1349
|
+
for g in sexpr.find(t, kind):
|
|
1350
|
+
layer = (sexpr.first(g, "layer") or [None, None])[1]
|
|
1351
|
+
if layer in ("F.CrtYd", "F.Fab"):
|
|
1352
|
+
out["crtyd" if layer == "F.CrtYd" else "fab"].append(_svg_outline(g))
|
|
1353
|
+
for p in sexpr.find(t, "pad"):
|
|
1354
|
+
at, size = sexpr.first(p, "at"), sexpr.first(p, "size")
|
|
1355
|
+
x, y, rot = float(at[1]), float(at[2]), float(at[3]) if len(at) > 3 else 0.0
|
|
1356
|
+
w, h = float(size[1]), float(size[2])
|
|
1357
|
+
layers = (sexpr.first(p, "layers") or ["layers"])[1:]
|
|
1358
|
+
a = math.radians(-rot) # KiCad: + is counter-clockwise on screen
|
|
1359
|
+
tf = lambda u, v: (x + u * math.cos(a) - v * math.sin(a), y + u * math.sin(a) + v * math.cos(a))
|
|
1360
|
+
d = sexpr.first(p, "drill")
|
|
1361
|
+
if d:
|
|
1362
|
+
vals = [float(v) for v in d[1:] if isinstance(v, str) and v != "oval"]
|
|
1363
|
+
dw, dh = vals[0], vals[1] if len(vals) > 1 else vals[0]
|
|
1364
|
+
out["holes"].append({"x": x, "y": y, "w": dw, "h": dh, "rot": rot,
|
|
1365
|
+
"pts": [tf(sx * dw / 2, sy * dh / 2) for sx in (-1, 1) for sy in (-1, 1)]})
|
|
1366
|
+
if p[2] == "np_thru_hole" or not any(l.endswith(".Cu") for l in layers):
|
|
1367
|
+
continue # holes only; paste windows aren't copper
|
|
1368
|
+
shape = p[3]
|
|
1369
|
+
pad = {"num": str(p[1]), "x": x, "y": y, "w": w, "h": h, "rot": rot}
|
|
1370
|
+
if shape == "custom":
|
|
1371
|
+
polys = [[tf(float(c[1]), float(c[2])) for c in sexpr.find(sexpr.first(g, "pts") or [], "xy")]
|
|
1372
|
+
for g in (sexpr.first(p, "primitives") or [])[1:] if g[0] == "gr_poly"]
|
|
1373
|
+
pad["polys"] = [q for q in polys if len(q) > 2]
|
|
1374
|
+
r = {"circle": 0.5, "oval": 0.5}.get(shape, 0.0)
|
|
1375
|
+
rr = sexpr.first(p, "roundrect_rratio")
|
|
1376
|
+
if shape == "roundrect" and rr:
|
|
1377
|
+
r = float(rr[1])
|
|
1378
|
+
pad["r"] = r * min(w, h)
|
|
1379
|
+
pad["pts"] = [tf(sx * w / 2, sy * h / 2) for sx in (-1, 1) for sy in (-1, 1)] + [q for poly in pad.get("polys", []) for q in poly]
|
|
1380
|
+
out["pads"].append(pad)
|
|
1381
|
+
return out
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
_SVG_IDS = itertools.count(1)
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
def footprint_svg(rep, scale=40, kicad_mod=None):
|
|
1388
|
+
"""Top view: copper pads (pin 1 highlighted), drills, exposed pad, fab body and courtyard. KiCad Y points down,
|
|
1389
|
+
as SVG's does. The view box fits what is drawn (through-hole connectors put pin 1, not the centre, at 0,0).
|
|
1390
|
+
kicad_mod: draw from the footprint itself (rotation, polygon pads, holes); else from the report's numbers.
|
|
1391
|
+
The UI colours it like Altium's PCB editor: a black board under a 0.5 / 2.5 mm grid, pad numbers on the copper,
|
|
1392
|
+
and the footprint's origin as a cross. Through-hole pads (a drill at the pad) get the extra class "tht"."""
|
|
1393
|
+
f = lambda v: f"{float(v):.4f}".rstrip("0").rstrip(".")
|
|
1394
|
+
body, pts = [], []
|
|
1395
|
+
if kicad_mod:
|
|
1396
|
+
geo = footprint_geometry(kicad_mod)
|
|
1397
|
+
for cls in ("fab", "crtyd"):
|
|
1398
|
+
for line, closed in geo[cls]:
|
|
1399
|
+
pts += line
|
|
1400
|
+
d = " ".join(f"{f(x)},{f(y)}" for x, y in line)
|
|
1401
|
+
body.append(f'<{"polygon" if closed else "polyline"} class="{cls}" points="{d}"/>')
|
|
1402
|
+
pads = geo["pads"]
|
|
1403
|
+
else:
|
|
1404
|
+
bb = rep.get("courtyard_bbox")
|
|
1405
|
+
if not bb:
|
|
1406
|
+
cx, cy = float(rep["courtyard"][0]) / 2, float(rep["courtyard"][1]) / 2
|
|
1407
|
+
bb = (-cx, -cy, cx, cy)
|
|
1408
|
+
bb = [float(v) for v in bb]
|
|
1409
|
+
pts += [(bb[0], bb[1]), (bb[2], bb[3])]
|
|
1410
|
+
body.append(f'<rect class="crtyd" x="{f(bb[0])}" y="{f(bb[1])}" width="{f(bb[2] - bb[0])}" height="{f(bb[3] - bb[1])}"/>')
|
|
1411
|
+
if "EP" in rep:
|
|
1412
|
+
ew, eh = float(rep["EP"][0]), float(rep["EP"][1])
|
|
1413
|
+
body.append(f'<rect class="pad ep" x="{f(-ew / 2)}" y="{f(-eh / 2)}" width="{f(ew)}" height="{f(eh)}"/>')
|
|
1414
|
+
pads = []
|
|
1415
|
+
for p in rep["pads"]:
|
|
1416
|
+
x, y, w, h = (float(p[k]) for k in ("x", "y", "w", "h"))
|
|
1417
|
+
pads.append({"num": str(p["num"]), "x": x, "y": y, "w": w, "h": h, "rot": 0.0, "r": min(0.25, 0.25 * min(w, h)),
|
|
1418
|
+
"pts": [(x - w / 2, y - h / 2), (x + w / 2, y + h / 2)]})
|
|
1419
|
+
geo = {"holes": []}
|
|
1420
|
+
p1 = next((p["num"] for p in pads if p["num"] in ("1", "A1")), None)
|
|
1421
|
+
drilled = {(round(q["x"], 3), round(q["y"], 3)) for q in geo["holes"]}
|
|
1422
|
+
labels = []
|
|
1423
|
+
for p in pads:
|
|
1424
|
+
pts += p["pts"]
|
|
1425
|
+
cls = "pad p1" if p["num"] == p1 else "pad"
|
|
1426
|
+
if (round(p["x"], 3), round(p["y"], 3)) in drilled:
|
|
1427
|
+
cls += " tht"
|
|
1428
|
+
size = min(p["w"], p["h"]) * 0.55
|
|
1429
|
+
if size > 0:
|
|
1430
|
+
labels.append(f'<text class="padnum" x="{f(p["x"])}" y="{f(p["y"])}" font-size="{f(min(size, 0.6))}">'
|
|
1431
|
+
f'{html.escape(p["num"])}</text>')
|
|
1432
|
+
num = html.escape(p["num"]) # the UI inserts this SVG as HTML
|
|
1433
|
+
tip = f'<title>pad {num}: {p["w"]:.3f} x {p["h"]:.3f} mm @ ({p["x"]:.3f}, {p["y"]:.3f})</title>'
|
|
1434
|
+
if p.get("polys"):
|
|
1435
|
+
body.append(f'<g class="{cls}">' + "".join(
|
|
1436
|
+
'<polygon points="' + " ".join(f"{f(x)},{f(y)}" for x, y in q) + '"/>' for q in p["polys"]) + tip + "</g>")
|
|
1437
|
+
continue
|
|
1438
|
+
rot = f' transform="rotate({f(-p["rot"])} {f(p["x"])} {f(p["y"])})"' if p["rot"] else ""
|
|
1439
|
+
body.append(f'<rect class="{cls}" x="{f(p["x"] - p["w"] / 2)}" y="{f(p["y"] - p["h"] / 2)}" width="{f(p["w"])}" '
|
|
1440
|
+
f'height="{f(p["h"])}" rx="{f(p["r"])}"{rot}>{tip}</rect>')
|
|
1441
|
+
for q in geo["holes"]:
|
|
1442
|
+
pts += q["pts"]
|
|
1443
|
+
rot = f' transform="rotate({f(-q["rot"])} {f(q["x"])} {f(q["y"])})"' if q["rot"] else ""
|
|
1444
|
+
body.append(f'<rect class="hole" x="{f(q["x"] - q["w"] / 2)}" y="{f(q["y"] - q["h"] / 2)}" width="{f(q["w"])}" '
|
|
1445
|
+
f'height="{f(q["h"])}" rx="{f(min(q["w"], q["h"]) / 2)}"{rot}/>')
|
|
1446
|
+
m = 0.6
|
|
1447
|
+
x0, y0 = min(x for x, _ in pts) - m, min(y for _, y in pts) - m
|
|
1448
|
+
vw, vh = max(x for x, _ in pts) + m - x0, max(y for _, y in pts) + m - y0
|
|
1449
|
+
gid = f"fpgrid{next(_SVG_IDS)}"
|
|
1450
|
+
board = (f'<defs><pattern id="{gid}f" width="0.5" height="0.5" patternUnits="userSpaceOnUse">'
|
|
1451
|
+
f'<path class="grid" d="M0.5 0V0.5H0"/></pattern>'
|
|
1452
|
+
f'<pattern id="{gid}" width="2.5" height="2.5" patternUnits="userSpaceOnUse">'
|
|
1453
|
+
f'<rect width="2.5" height="2.5" fill="url(#{gid}f)"/><path class="grid major" d="M2.5 0V2.5H0"/></pattern></defs>'
|
|
1454
|
+
f'<rect class="board" x="{f(x0)}" y="{f(y0)}" width="{f(vw)}" height="{f(vh)}"/>'
|
|
1455
|
+
f'<rect class="board-grid" x="{f(x0)}" y="{f(y0)}" width="{f(vw)}" height="{f(vh)}" fill="url(#{gid})"/>')
|
|
1456
|
+
origin = '<path class="origin" d="M-0.4 0H0.4M0 -0.4V0.4"/>'
|
|
1457
|
+
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{f(x0)} {f(y0)} {f(vw)} {f(vh)}" width="{vw * scale:.0f}" '
|
|
1458
|
+
f'height="{vh * scale:.0f}" class="fp">' + board + "".join(body) + "".join(labels) + origin + "</svg>")
|