Files
pd-hub/fetch_lcsc.py
2026-02-18 18:01:40 +01:00

278 lines
9.4 KiB
Python

#!/usr/bin/env python3
"""
LCSC importer using easyeda2kicad.py (tested against v0.8.0).
What it does:
- Reads an LCSC cart CSV and extracts unique IDs from column header `LCSC#`.
- For each ID:
- tries: easyeda2kicad --full --lcsc_id <ID> --output <libs/easyeda_full> --project-relative --overwrite
- if it fails due to the known "no 3D model available" crash, retries without 3D:
easyeda2kicad --symbol --footprint ...
- continues on errors (does not abort the batch)
- Writes project-local sym-lib-table and fp-lib-table pointing at generated libs
- Fixes broken 3D model paths in ALL generated footprints:
"${KIPRJMOD}C:/.../libs/easyeda_full.3dshapes/foo.wrl" -> "${KIPRJMOD}/libs/easyeda_full.3dshapes/foo.wrl"
and normalizes to portable ${KIPRJMOD}/... paths with forward slashes.
"""
from __future__ import annotations
import argparse
import csv
import re
import subprocess
from pathlib import Path
from typing import List, Tuple
CSV_LCSC_COLUMN = "LCSC#"
EASYEDA_BASE = "easyeda_full"
# Matches the first line of a KiCad footprint model reference.
# Example:
# (model "C:/path/file.step"
MODEL_LINE_RE = re.compile(r'^(\s*\(model\s+")([^"]+)(".*)$', re.MULTILINE)
def read_lcsc_parts_from_csv(csv_path: Path) -> List[str]:
parts: List[str] = []
with csv_path.open("r", encoding="utf-8", newline="") as f:
reader = csv.DictReader(f)
if CSV_LCSC_COLUMN not in (reader.fieldnames or []):
raise SystemExit(
f"CSV missing column '{CSV_LCSC_COLUMN}'. Found: {reader.fieldnames}"
)
for row in reader:
raw = (row.get(CSV_LCSC_COLUMN) or "").strip().upper()
if not raw:
continue
parts.append(raw)
# unique, stable order
seen = set()
out: List[str] = []
for p in parts:
if p not in seen:
seen.add(p)
out.append(p)
return out
def write_sym_lib_table(root: Path, base: str) -> None:
(root / "sym-lib-table").write_text(
"\n".join(
[
"(sym_lib_table",
" (version 7)",
f' (lib (name {base})(type KiCad)(uri ${{KIPRJMOD}}/libs/{base}.kicad_sym)(options "")(descr "easyeda2kicad generated"))',
")",
"",
]
),
encoding="utf-8",
)
def write_fp_lib_table(root: Path, base: str) -> None:
(root / "fp-lib-table").write_text(
"\n".join(
[
"(fp_lib_table",
" (version 7)",
f' (lib (name {base})(type KiCad)(uri ${{KIPRJMOD}}/libs/{base}.pretty)(options "")(descr "easyeda2kicad generated"))',
")",
"",
]
),
encoding="utf-8",
)
def dump_log_to_stdout(name: str, stdout: str | None, stderr: str | None, rc: int | None) -> None:
print(f"COMMAND: {name}")
print(f"RETURN CODE: {rc}")
if stdout:
print("STDOUT:\n" + stdout)
if stderr:
print("STDERR:\n" + stderr)
def run_cmd(cmd: list[str], timeout_s: int) -> subprocess.CompletedProcess[str] | None:
try:
return subprocess.run(cmd, text=True, capture_output=True, timeout=timeout_s)
except FileNotFoundError:
print("FAILED: easyeda2kicad not found (install it or fix PATH).")
dump_log_to_stdout(" ".join(cmd), None, None, None)
return None
except subprocess.TimeoutExpired as e:
print("TIMEOUT")
dump_log_to_stdout(" ".join(cmd), e.stdout, e.stderr, None)
return None
def is_no_3d_crash(stdout: str | None, stderr: str | None) -> bool:
# easyeda2kicad.py v0.8.0 prints warning then crashes with NoneType.step
combined = (stdout or "") + "\n" + (stderr or "")
return ("No 3D model available" in combined) and ("NoneType" in combined) and ("step" in combined)
def fix_3d_model_paths(root: Path, base: str) -> Tuple[int, int]:
"""
Fixes broken model paths in all footprints in libs/<base>.pretty.
Rewrites any (model "...") line to:
(model "${KIPRJMOD}/libs/<base>.3dshapes/<filename.ext>"
Prefers STEP if present, otherwise WRL if present, otherwise keeps filename ext.
Returns: (files_changed, model_lines_rewritten)
"""
pretty_dir = root / "libs" / f"{base}.pretty"
shapes_dir = root / "libs" / f"{base}.3dshapes"
if not pretty_dir.is_dir() or not shapes_dir.is_dir():
return (0, 0)
files_changed = 0
rewritten = 0
for mod in sorted(pretty_dir.glob("*.kicad_mod")):
text = mod.read_text(encoding="utf-8", errors="replace")
def repl(m: re.Match) -> str:
nonlocal rewritten
prefix, old_path, suffix = m.group(1), m.group(2), m.group(3)
# Normalize old path and keep only the filename
# This also fixes the specific broken pattern: ${KIPRJMOD}C:/.../file.wrl
normalized = old_path.replace("\\", "/")
fname = Path(normalized).name
if not fname:
return m.group(0)
stem = Path(fname).stem
# Prefer step/stp/wrl based on actual files on disk
step = shapes_dir / f"{stem}.step"
stp = shapes_dir / f"{stem}.stp"
wrl = shapes_dir / f"{stem}.wrl"
if step.exists():
fname2 = step.name
elif stp.exists():
fname2 = stp.name
elif wrl.exists():
fname2 = wrl.name
else:
fname2 = fname # last resort
new_path = f"${{KIPRJMOD}}/libs/{base}.3dshapes/{fname2}"
rewritten += 1
return f'{prefix}{new_path}{suffix}'
new_text = MODEL_LINE_RE.sub(repl, text)
if new_text != text:
mod.write_text(new_text, encoding="utf-8")
files_changed += 1
return (files_changed, rewritten)
def main() -> int:
ap = argparse.ArgumentParser(description="LCSC importer for easyeda2kicad.py v0.8.x")
ap.add_argument("csv", type=Path, help="LCSC cart export CSV file")
ap.add_argument("--root", type=Path, default=Path.cwd(), help="project root for libs/tables (default: cwd)")
args = ap.parse_args()
root = args.root.resolve()
libs = root / "libs"
libs.mkdir(parents=True, exist_ok=True)
parts = read_lcsc_parts_from_csv(args.csv.resolve())
if not parts:
print("No LCSC parts found in CSV.")
return 0
base_out = libs / EASYEDA_BASE
print(f"Found {len(parts)} unique parts; importing {len(parts)}")
for lcsc in parts:
print(f"\nImporting {lcsc} ...")
full_cmd = [
"easyeda2kicad",
"--full",
"--lcsc_id",
lcsc,
"--output",
str(base_out),
"--project-relative",
"--overwrite",
]
proc = run_cmd(full_cmd, timeout_s=240)
if proc is not None and proc.returncode == 0:
print("OK (full)")
dump_log_to_stdout(" ".join(full_cmd), proc.stdout, proc.stderr, proc.returncode)
# Fix paths after each import so you can open KiCad immediately and see models
changed_files, rewritten = fix_3d_model_paths(root, EASYEDA_BASE)
if rewritten:
print(f"3D path fix: changed {changed_files} files, rewrote {rewritten} model paths")
continue
# Full failed
if proc is not None:
dump_log_to_stdout(" ".join(full_cmd), proc.stdout, proc.stderr, proc.returncode)
# If it's the known no-3D crash, retry without 3D and continue
if proc is not None and is_no_3d_crash(proc.stdout, proc.stderr):
print(f"FAILED (full due to no-3D crash), retrying without 3D for {lcsc}...")
else:
print(f"FAILED (full), retrying without 3D for {lcsc}...")
retry_cmd = [
"easyeda2kicad",
"--symbol",
"--footprint",
"--lcsc_id",
lcsc,
"--output",
str(base_out),
"--project-relative",
"--overwrite",
]
proc2 = run_cmd(retry_cmd, timeout_s=180)
if proc2 is None:
continue
dump_log_to_stdout(" ".join(retry_cmd), proc2.stdout, proc2.stderr, proc2.returncode)
if proc2.returncode == 0:
print("OK (no 3D)")
else:
print(f"FAILED (no-3D exit {proc2.returncode})")
# Even in no-3D mode, running the fixer is harmless
changed_files, rewritten = fix_3d_model_paths(root, EASYEDA_BASE)
if rewritten:
print(f"3D path fix: changed {changed_files} files, rewrote {rewritten} model paths")
# write minimal lib tables so KiCad can see the generated libs
write_sym_lib_table(root, EASYEDA_BASE)
write_fp_lib_table(root, EASYEDA_BASE)
# Final sweep in case anything was generated late
changed_files, rewritten = fix_3d_model_paths(root, EASYEDA_BASE)
print(f"\nFinal 3D path fix: changed {changed_files} files, rewrote {rewritten} model paths")
print("\nDone. Wrote sym-lib-table and fp-lib-table (project-local).")
return 0
if __name__ == "__main__":
#parts = read_lcsc_parts_from_csv(Path("export_cart_20260218_090012.csv"))
#parts = []
#print(parts)
#for lcsc in parts:
# print(f"\nImporting {lcsc} ...")
# subprocess.run(["easyeda2kicad", "--symbol", "--footprint", "--lcsc_id", lcsc, "--output", "./hw/libs/"+str(EASYEDA_BASE)])
exit(main())