278 lines
9.4 KiB
Python
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()) |