Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
"""`akcli jlc` — JLCPCB/LCSC part commands (the ONLY networked subcommands).
``jlc search`` / ``show`` / ``bom`` / ``datasheet`` / ``add`` — keyword search,
part detail (+ optional EasyEDA metadata), BOM-vs-catalog stock/price check
(with ``--fix`` writing LCSC part numbers), datasheet resolution/fetch, and the
in-process LCSC->KiCad converter. Every network dependency is imported LAZILY
so offline paths never touch it.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from ..contract import PROTOCOL_VERSIONS
from ..errors import EXIT
from ._shared import (
_draw_exit,
_dumps,
_emit,
_ExitWith,
_load_cfg,
_load_schematic,
_require_path,
_stamp,
)
def _bom_cfg(args, path) -> tuple[tuple[str, ...], int, float]:
"""(no_part_prefixes, default_min_stock, extended_fee) from [bom]."""
from ..bom_policy import DEFAULT_NO_PART_PREFIXES
try:
cfg = _load_cfg(args, path)
except Exception: # noqa: BLE001 — config is optional
return DEFAULT_NO_PART_PREFIXES, 1, 3.0
tbl = getattr(cfg, "bom", None) or {}
classes = tbl.get("classes") or {}
np = classes.get("no_part")
prefixes = (tuple(str(x) for x in np) if isinstance(np, list) and np
else DEFAULT_NO_PART_PREFIXES)
return (prefixes, int(tbl.get("min_stock", 1)),
float(tbl.get("extended_fee", 3.0)))
def _mpn_resolve_limit() -> int:
"""Catalog depth for an *exact*-MPN resolution (the ONE policy).
Owned by :mod:`akcli.parts.bom_report`; read through this lazy accessor so
importing the command module never drags the networked ``parts`` package
into an offline run. ``jlc search --category`` over-fetches with the same
idiom: the jlcsearch mirror ranks longer/marketing variants ahead of a
short generic MPN, so a shallow search would push the true exact casefold
match past the cut and mis-report the part as not-found.
"""
from ..parts.bom_report import MPN_RESOLVE_LIMIT
return MPN_RESOLVE_LIMIT
def _jlc_price(p) -> str:
return f"${p:.4f}" if isinstance(p, (int, float)) else "-"
def _jlc_table(parts: list) -> str:
"""Render search results as a fixed-width table (one part per row)."""
header = ("LCSC", "MPN", "PACKAGE", "STOCK", "PRICE", "B", "DESCRIPTION")
rows = [header]
for p in parts:
desc = p.description or p.category or ""
rows.append((
p.lcsc or "-",
(p.mpn or "-")[:28],
p.package or "-",
str(p.stock),
_jlc_price(p.price),
"B" if p.basic else ("P" if p.preferred else "-"),
desc[:40],
))
widths = [max(len(r[i]) for r in rows) for i in range(len(header))]
out = []
for r in rows:
out.append(" ".join(c.ljust(widths[i]) for i, c in enumerate(r)).rstrip())
return "\n".join(out)
def _jlc_detail(p) -> str:
"""Render a single part as a key/value block."""
lines = [
f"LCSC: {p.lcsc or '-'}",
f"MPN: {p.mpn or '-'}",
f"description: {p.description or '-'}",
f"package: {p.package or '-'}",
f"category: {p.category or '-'}",
f"stock: {p.stock}",
f"unit price: {_jlc_price(p.price)}",
f"basic: {'yes' if p.basic else 'no'}",
f"preferred: {'yes' if p.preferred else 'no'}",
f"datasheet: {p.datasheet or '-'}",
]
sub = p.attributes.get("subcategory")
if sub:
lines.append(f"subcategory: {sub}")
return "\n".join(lines)
def _cmd_jlc(args: argparse.Namespace) -> int:
"""No subcommand given: print usage."""
raise _ExitWith(
EXIT["USAGE"],
"ERROR: use `akcli jlc search <query>`, `akcli jlc show <C-number>`, "
"`akcli jlc bom <sch>`, `akcli jlc datasheet <C-number|MPN|sch>`, "
"or `akcli jlc add <C-number>`",
)
def _cmd_jlc_search(args: argparse.Namespace) -> int:
query = getattr(args, "query", None)
if not query:
raise _ExitWith(EXIT["USAGE"], "ERROR: missing search query")
category = getattr(args, "category", None)
user_limit = getattr(args, "limit", None) or 20
# jlcsearch has no server-side category parameter, so --category is a
# client-side post-filter: over-fetch first (the MPN_RESOLVE_LIMIT idiom)
# so the filtered result is not starved by the API's own truncation.
fetch_limit = max(user_limit, _mpn_resolve_limit()) if category else user_limit
from ..parts import search as parts_search # lazy: keeps network out of offline paths
try:
results = parts_search.search(query, limit=fetch_limit,
cache_dir=parts_search.default_cache_dir())
except parts_search.JlcNetworkError as exc:
raise _ExitWith(EXIT["TOOL_MISSING"], f"ERROR: NETWORK: {exc.message}")
if category:
want = category.strip().lower()
results = [p for p in results
if (p.category or "").strip().lower() == want][:user_limit]
if not results:
note = f" in category {category!r}" if category else ""
sys.stderr.write(f"no parts found for {query!r}{note}\n")
if args.json:
_emit(_dumps([]))
return EXIT["OK"]
if args.json:
_emit(_dumps([p.to_dict() for p in results]))
else:
_emit(_jlc_table(results))
return EXIT["OK"]
def _easyeda_enrich(lcsc: str):
"""Best-effort EasyEDA metadata/3D-availability lookup; never raises.
Returns an ``EasyEdaInfo`` or ``None`` — a failed/absent EasyEDA lookup must never
break ``jlc show``; it just omits the EasyEDA-derived fields.
"""
try:
from ..parts import easyeda # lazy: keeps network out of offline paths
return easyeda.lookup(lcsc)
except Exception: # EasyEdaError or anything unexpected -> degrade gracefully
return None
def _enrich(lcsc: str):
"""Resolve EasyEDA enrichment through THIS module's patch seam.
``_easyeda_enrich`` is late-bound on purpose: tests patch
``akcli.commands.jlc._easyeda_enrich`` (the offline seam) and the handler
picks the patch up at call time. The seam used to live on ``cli`` — a
command module reaching back into the dispatcher was the layering
inversion this indirection removes.
"""
return _easyeda_enrich(lcsc)
def _easyeda_lines(info) -> list[str]:
return [
"-- EasyEDA --",
f"3D model: {'available' if info.has_3d else 'none'}",
f"model uuid: {info.model_uuid or '-'}",
f"manufacturer: {info.manufacturer or '-'}",
f"EasyEDA MPN: {info.mpn or '-'}",
f"EasyEDA pkg: {info.package or '-'}",
f"source: {info.source}",
]
def _cmd_jlc_bom(args: argparse.Namespace) -> int:
"""`jlc bom <sch>...` — BOM lines vs the JLCPCB catalog (stock/price/cost)."""
raw_paths = getattr(args, "path", None) or []
if isinstance(raw_paths, str):
raw_paths = [raw_paths]
if not raw_paths:
raise _ExitWith(EXIT["USAGE"], "ERROR: missing input schematic")
paths = [_require_path(p) for p in raw_paths]
path = paths[0]
multi = len(paths) > 1
sch = _load_schematic(path)
from ..parts import bom_jlc, bom_report, search as parts_search # lazy: networked
qty = max(1, getattr(args, "qty", 1) or 1)
prefixes, cfg_min_stock, extended_fee = _bom_cfg(args, path)
min_stock = getattr(args, "min_stock", None) or cfg_min_stock
offline = bool(getattr(args, "offline", False))
fix_all = getattr(args, "fix_all", False)
do_fix = getattr(args, "fix", False) or fix_all
do_suggest = do_fix or getattr(args, "suggest", False)
if offline and do_suggest:
raise _ExitWith(EXIT["USAGE"],
"ERROR: --suggest/--fix need the live catalog; "
"drop --offline")
if offline:
sys.stderr.write("OFFLINE (degraded): catalog answers come from the "
"HTTP cache only; misses are 'unverified'\n")
if do_fix and not str(path).lower().endswith(".kicad_sch"):
raise _ExitWith(EXIT["USAGE"],
"ERROR: --fix writes the schematic; it needs a .kicad_sch")
if multi and do_fix:
raise _ExitWith(EXIT["USAGE"],
"ERROR: --fix works on one schematic at a time")
cache = parts_search.default_cache_dir()
want_alts = getattr(args, "alternates", False)
boards = ([(p.stem, _load_schematic(p)) for p in paths] if multi else None)
try:
lines = bom_report.collect_lines(
sch, boards, min_stock=min_stock, qty=qty, cache=cache,
no_part_prefixes=prefixes, offline=offline,
suggest=do_suggest, alternates=want_alts)
except parts_search.JlcNetworkError as exc:
raise _ExitWith(EXIT["TOOL_MISSING"], f"ERROR: NETWORK: {exc.message}")
if do_fix:
# plain --fix writes high-confidence suggestions only; --fix-all also
# writes low-confidence ones (package matched, value unverified)
ops = bom_jlc.fix_ops(
lines, min_confidence=("low" if fix_all else "high"))
if not fix_all:
low = sum(1 for ln in lines
if ln.suggestion
and (ln.suggestion_confidence or "low") == "low")
if low:
sys.stderr.write(f"{low} low-confidence suggestion(s) not "
"written (use --fix-all)\n")
if not ops:
sys.stderr.write("--fix: nothing to fix (no suggestions)\n")
else:
from ..writers import kicad as kwriter
oplist = {"protocol_version": PROTOCOL_VERSIONS["oplist"],
"target_format": "kicad",
"target_file": path.name, "ops": ops}
findings: list = []
from .. import journal as _journal
results = kwriter.apply(oplist, str(path), apply=True,
sources=[], verify_out=findings,
backup_dir=_journal.backups_dir(path))
code = _draw_exit(results, findings)
# Every write goes through the journal — `akcli log` must never be
# blind to a BOM auto-fix (same provenance as plan/draw/arrange).
_bak = f"{_journal.DIR_NAME}/{_journal.BACKUP_DIR_NAME}/{path.name}.bak"
_journal.record(
path, "jlc-bom-fix",
"applied" if code == EXIT["OK"] else "refused",
op_count=len(ops),
backup=(_bak if code == EXIT["OK"] else None))
if code != EXIT["OK"]:
return code
fixed = [ln for ln in lines if ln.suggestion]
for ln in fixed:
sys.stderr.write(
f"fixed {','.join(ln.refs)}: "
f"{ln.lcsc_key or 'LCSC'} = {ln.suggestion.lcsc} "
f"({ln.suggestion.mpn}) — verify against the datasheet\n")
# re-check so the report reflects the written ids
sch = _load_schematic(path)
try:
lines = bom_jlc.check(
sch, min_stock=min_stock,
qty=qty, cache_dir=cache, no_part_prefixes=prefixes)
except parts_search.JlcNetworkError as exc:
raise _ExitWith(EXIT["TOOL_MISSING"],
f"ERROR: NETWORK: {exc.message}")
drift: list[dict] | None = None
lock_out = getattr(args, "lock", None)
if lock_out:
import json as _json
lock_doc = bom_jlc.make_lock(lines, qty=qty)
Path(lock_out).write_text(_json.dumps(lock_doc, indent=1) + "\n",
encoding="utf-8", newline="\n")
sys.stderr.write(f"wrote BOM lockfile: {lock_out}\n")
against = getattr(args, "against_lock", None)
if against:
import json as _json
try:
lock_doc = _json.loads(Path(against).read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise _ExitWith(EXIT["USAGE"], f"ERROR: cannot read lockfile: {exc}")
drift = bom_jlc.diff_lock(lines, lock_doc)
csv_out = getattr(args, "csv", None)
if csv_out:
text = bom_jlc.to_jlc_csv(lines)
if csv_out == "-":
_emit(text) # CSV replaces the table: stdout = data
else:
Path(csv_out).write_text(text, encoding="utf-8", newline="\n")
sys.stderr.write(f"wrote JLCPCB BOM CSV: {csv_out}\n")
agg = bom_jlc.totals(lines, extended_fee=extended_fee)
md_out = getattr(args, "md", None)
if md_out:
text = bom_jlc.to_markdown(lines, agg, qty=qty)
if md_out == "-":
_emit(text)
else:
Path(md_out).write_text(text, encoding="utf-8", newline="\n")
sys.stderr.write(f"wrote BOM markdown report: {md_out}\n")
if csv_out == "-" or md_out == "-":
pass
elif args.json:
payload = {"qty": qty, "lines": [ln.to_dict() for ln in lines],
"totals": agg}
if drift is not None:
payload["lock_drift"] = drift
_emit(_dumps(_stamp(payload)))
else:
rows = bom_report.table_rows(lines)
widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]) - 1)]
_emit("\n".join(
" ".join(c.ljust(w) for c, w in zip(r[:-1], widths)) + " " + r[-1]
for r in rows).rstrip())
summary = bom_report.summary_text(agg, qty=qty, offline=offline)
if drift is not None:
for d in drift:
_emit(f" DRIFT {d['kind']}: {d['refs']} — {d['detail']}")
_emit(f"lock drift: {len(drift)} change(s) vs {against}"
if drift else f"lock drift: none vs {against}")
sys.stdout.flush() # keep the table above the stderr summary
sys.stderr.write(summary + "\n")
if (agg["problems"] or drift) and not getattr(args, "exit_zero", False):
return EXIT["FINDINGS"]
return EXIT["OK"]
def _cmd_jlc_datasheet(args: argparse.Namespace) -> int:
"""`jlc datasheet <target>` — resolve (and fetch) datasheet PDFs.
``target`` is a C-number, an MPN (exact catalog match, same policy as
``jlc bom``), or a schematic whose BOM lines carry LCSC parameters.
Resolution goes through the EasyEDA component record (the jlcsearch
mirror never carries datasheet links; lcsc.com bot-gates direct fetches).
"""
target = getattr(args, "target", None)
if not target:
raise _ExitWith(EXIT["USAGE"],
"ERROR: give a C-number (C2984661), an MPN, or a "
"schematic path")
from ..parts import bom_report # lazy: networked
from ..parts import datasheet as ds
from ..parts import easyeda
from ..parts import search as parts_search
cache = parts_search.default_cache_dir()
if ds._CNUM_RE.match(target.strip()):
rows = [ds.DatasheetRow(lcsc=target.strip())]
elif Path(target).exists():
sch = _load_schematic(_require_path(target))
rows = ds.rows_for_schematic(sch)
if getattr(args, "resolve_mpn", False):
try:
bom_report.resolve_mpn_only_rows(rows, cache)
except parts_search.JlcNetworkError as exc:
raise _ExitWith(EXIT["TOOL_MISSING"],
f"ERROR: NETWORK: {exc.message}")
else:
# MPN: exact match against the catalog, prefer in-stock/Basic depth
try:
results = parts_search.search(
target, limit=bom_report.MPN_RESOLVE_LIMIT, cache_dir=cache)
except parts_search.JlcNetworkError as exc:
sys.stderr.write(f"ERROR: NETWORK: {exc.message}\n")
return EXIT["TOOL_MISSING"]
exact = [p for p in results if p.mpn.casefold() == target.casefold()]
exact.sort(key=lambda p: (p.stock > 0, p.basic, p.stock), reverse=True)
if exact:
rows = [ds.DatasheetRow(lcsc=exact[0].lcsc, mpn=exact[0].mpn)]
else:
near = f" (nearest: {results[0].mpn})" if results else ""
rows = [ds.DatasheetRow(mpn=target, status="not-found",
note="no exact MPN match" + near)]
try:
for i, row in enumerate(rows):
if row.status or not row.lcsc:
continue
r = ds.resolve(row.lcsc, cache_dir=cache)
r.refs = row.refs
rows[i] = r
except easyeda.EasyEdaError as exc:
raise _ExitWith(EXIT["TOOL_MISSING"], f"ERROR: NETWORK: {exc.message}")
out_dir = None
if getattr(args, "fetch", False):
out_dir = (Path(args.out).expanduser() if getattr(args, "out", None)
else ds.default_dir())
for row in rows:
if row.status != "resolved":
continue
dest = out_dir / ds.pdf_filename(row.lcsc, row.mpn)
try:
path, downloaded = ds.fetch_pdf(
row.url, dest, force=getattr(args, "force", False))
row.path = str(path)
row.status = "fetched" if downloaded else "cached"
except easyeda.EasyEdaError as exc:
if exc.kind in ("network", "timeout"):
sys.stderr.write(f"ERROR: NETWORK: {exc.message}\n")
return EXIT["TOOL_MISSING"]
row.status = "fetch-failed" # content problem: report, go on
row.note = exc.message
ok = sum(1 for r in rows if r.status in ("resolved", "fetched", "cached"))
page_link = sum(1 for r in rows if r.status == "page-link")
problems = sum(1 for r in rows
if r.status in ("not-found", "no-link", "fetch-failed"))
no_lcsc = sum(1 for r in rows if r.status == "no-lcsc")
if args.json:
_emit(_dumps(_stamp({"rows": [r.to_dict() for r in rows], "ok": ok,
"page_link": page_link, "problems": problems,
"no_lcsc": no_lcsc,
"out_dir": str(out_dir) if out_dir else None})))
else:
for r in rows:
name = " ".join(x for x in (r.lcsc, r.mpn) if x) or "?"
what = r.path or r.url or (f"- ({r.note})" if r.note else "-")
line = f"{r.status:<12} {name:<30} {what}"
if r.refs:
line += f" [{','.join(r.refs)}]"
_emit(line)
_emit(f"# {len(rows)} row(s): {ok} ok, {page_link} page-link(s) "
f"(browser/WebFetch takes it from here), {problems} "
f"problem(s), {no_lcsc} without an LCSC id")
if out_dir is not None:
_emit(f"# saved under {out_dir}")
if (problems or page_link) and not getattr(args, "exit_zero", False):
return EXIT["FINDINGS"]
return EXIT["OK"]
def _cmd_jlc_show(args: argparse.Namespace) -> int:
lcsc = getattr(args, "lcsc", None)
if not lcsc:
raise _ExitWith(EXIT["USAGE"], "ERROR: missing LCSC C-number")
from ..parts import search as parts_search # lazy
try:
part = parts_search.get(lcsc, cache_dir=parts_search.default_cache_dir())
except parts_search.JlcNetworkError as exc:
raise _ExitWith(EXIT["TOOL_MISSING"], f"ERROR: NETWORK: {exc.message}")
if part is None:
sys.stderr.write(f"no part {lcsc!r} found\n")
if args.json:
_emit(_dumps(None))
return EXIT["OK"]
info = _enrich(part.lcsc or lcsc) if getattr(args, "easyeda", False) else None
if args.json:
payload = _stamp(part.to_dict())
if getattr(args, "easyeda", False):
payload["easyeda"] = info.to_dict() if info is not None else None
_emit(_dumps(payload))
else:
out = _jlc_detail(part)
if info is not None:
out += "\n" + "\n".join(_easyeda_lines(info))
elif getattr(args, "easyeda", False):
out += "\n-- EasyEDA --\n(metadata unavailable)"
_emit(out)
return EXIT["OK"]
_VERIFY_CAVEAT = (
"NOTE: symbol/footprint/3D converted from EasyEDA/LCSC CAD data. Verify pin "
"mapping, footprint dimensions, and 3D alignment against the datasheet before use."
)
_ADD_EXIT = {
"NETWORK": EXIT["TOOL_MISSING"],
"CONVERT_PART_NOT_FOUND": EXIT["NOT_FOUND"],
"CONVERT_FAILED": EXIT["OPLIST"],
"CONVERT_NO_ARTIFACTS": EXIT["OPLIST"],
}
def _build_place_oplist(result, args, value: str | None) -> dict | None:
"""Thin arg-unpacking wrapper over :func:`akcli.parts.place_oplist.build_place_oplist`."""
from ..parts import place_oplist
return place_oplist.build_place_oplist(
result,
lib_name=getattr(args, "lib_name", None) or "akcli",
footprint_lib=getattr(args, "footprint_lib", None) or "footprint",
designator=args.designator, at=args.at, value=value)
def _cmd_jlc_add(args: argparse.Namespace) -> int:
from ..parts import search as parts_search # lazy: reuse the C-number normalizer
digits = parts_search._lcsc_digits(getattr(args, "lcsc", None))
if not digits:
raise _ExitWith(EXIT["USAGE"], "ERROR: missing/invalid LCSC C-number")
lcsc = "C" + digits
place = bool(getattr(args, "place", False))
if place:
if not getattr(args, "designator", None):
raise _ExitWith(EXIT["USAGE"], "ERROR: --place requires --designator REF")
if not getattr(args, "at", None):
raise _ExitWith(EXIT["USAGE"], "ERROR: --place requires --at X Y")
out_dir = getattr(args, "out", None)
if not out_dir:
# deliverable-class output: default stays in-repo (the library is a
# committed asset sym-lib-table points at), [paths].parts_dir relocates
parts_root = _load_cfg(args, None).paths.get("parts_dir") or "akcli-parts"
out_dir = str(Path(parts_root) / lcsc)
with_3d = bool(getattr(args, "with_3d", False))
# Advisory EasyEDA lookup: what is being fetched + whether 3D exists.
info = _enrich(lcsc)
if with_3d and info is not None and not info.has_3d:
sys.stderr.write(
f"warning: no 3D model is published for {lcsc} on EasyEDA; "
"no STEP will be produced\n"
)
from ..drivers import jlc2kicad # lazy: vendored converter (networked)
fp_lib = getattr(args, "footprint_lib", None) or "footprint"
model_path = getattr(args, "model_path", None) or "relative"
result = jlc2kicad.convert(
lcsc,
out_dir,
with_3d=with_3d,
lib_name=getattr(args, "lib_name", None) or "akcli",
footprint_lib=fp_lib,
model_path=model_path,
force=bool(getattr(args, "force", False)),
)
if result.error_code is not None:
sys.stderr.write(f"ERROR: {result.error_code}: {result.message}\n")
return _ADD_EXIT.get(result.error_code, EXIT["OPLIST"])
if fp_lib == "footprint":
sys.stderr.write(
"note: the symbol's Footprint field uses the nickname 'footprint:'; "
"pass --footprint-lib <nickname> to match your project's "
"fp-lib-table (or register the library as 'footprint').\n")
if with_3d and model_path == "relative":
sys.stderr.write(
"note: 3D model paths are bare-relative (resolve only next to the "
"library). Use --3d-path absolute for this machine, or "
"--3d-path '${VAR}' with a configured path variable.\n")
elif with_3d and model_path == "absolute":
sys.stderr.write(
"note: 3D model paths are absolute — always resolvable on this "
"machine, NOT portable across machines/checkouts.\n")
value = (info.mpn or info.title) if info is not None else None
place_doc = None
place_path = None
if place:
place_doc = _build_place_oplist(result, args, value)
if place_doc is not None:
place_path = Path(out_dir) / "place.json"
try:
place_path.write_text(_dumps(place_doc) + "\n", encoding="utf-8", newline="\n")
except OSError: # pragma: no cover - best-effort file write
place_path = None
else:
sys.stderr.write(
"warning: --place skipped: no KiCad symbol artifact to place\n"
)
if args.json:
payload = _stamp(result.to_dict())
payload["note"] = _VERIFY_CAVEAT
if place:
payload["place"] = place_doc
_emit(_dumps(payload))
else:
lines = [
f"converted {lcsc} -> kicad (in-process, vendored JLC2KiCadLib)",
f"out: {result.out_dir}",
"artifacts:",
]
for a in result.artifacts:
lines.append(f" {a}")
if place and place_path is not None:
lines.append(f"placement op-list: {place_path}")
# --symbols is NOT optional here: the symbol lives only in the
# freshly converted library, so a draw without it exits 6
# (SYMBOL_NOT_FOUND) unless the library is registered in the
# project's sym-lib-table.
sym_art = next(
(a for a in result.artifacts if a.endswith(".kicad_sym")), None)
symbols_flag = f" --symbols {sym_art}" if sym_art else ""
lines.append(" apply with: akcli draw <target.kicad_sch> --ops "
f"{place_path}{symbols_flag} --apply")
lines.append(_VERIFY_CAVEAT)
lines.append("hint: review with `akcli check`/`kicad-cli erc` before use.")
_emit("\n".join(lines))
return EXIT["OK"]
def register(sub, common) -> None:
# jlc — JLCPCB/LCSC part search (needs network; powered by jlcsearch)
p = sub.add_parser("jlc", parents=[common],
help="search JLCPCB/LCSC parts via jlcsearch (needs network)")
p.set_defaults(handler=_cmd_jlc)
jlc_sub = p.add_subparsers(dest="jlc_command", metavar="<subcommand>")
ps = jlc_sub.add_parser("search", parents=[common],
help="keyword search for parts (MPN, category, C-number)")
ps.add_argument("query", nargs="?", help="search keywords")
ps.add_argument("--limit", type=int, default=20, metavar="N",
help="max results (default: 20)")
ps.add_argument("--category", metavar="NAME",
help="keep only results whose category equals NAME "
"(case-insensitive exact match, e.g. Resistors, "
"Capacitors) — a value/package query like '0402 47K' "
"can otherwise mix in NTC thermistors from Sensors")
ps.set_defaults(handler=_cmd_jlc_search)
pb = jlc_sub.add_parser(
"bom", parents=[common],
help="check a schematic's BOM against the JLCPCB catalog "
"(stock/price via LCSC/MPN parameters; networked)")
pb.add_argument("path", nargs="*",
help="input schematic(s) (.kicad_sch/.SchDoc); several "
"boards merge into ONE cart (same C-number lines "
"combine, tier pricing at the merged quantity, "
"refs prefixed board:ref)")
pb.add_argument("--qty", type=int, default=1, metavar="N",
help="number of boards: stock and tier pricing are "
"evaluated at N x per-line quantity (default: 1)")
pb.add_argument("--min-stock", type=int, default=None, metavar="N",
help="flag lines with stock below N (default: 1, or "
"akcli.toml [bom].min_stock)")
pb.add_argument("--alternates", action="store_true",
help="propose second sources for at-risk lines too "
"(low-stock/out-of-stock/EOL), and note Basic "
"swaps for Extended passives (advisory)")
pb.add_argument("--offline", action="store_true",
help="answer from the HTTP cache only: cache misses "
"degrade to 'unverified' lines instead of exit 7; "
"the run is bannered as degraded")
pb.add_argument("--lock", metavar="OUT.json",
help="freeze this check as a BOM lockfile (resolved "
"C-numbers, prices, stock, Basic/Extended) for "
"reproducible orders")
pb.add_argument("--against-lock", dest="against_lock", metavar="LOCK.json",
help="diff this check against a lockfile: price drift, "
"stock below need, vanished parts (EOL suspects), "
"status/id changes")
pb.add_argument("--suggest", action="store_true",
help="search the catalog for not-found / no-part-id "
"lines (match by value + package) and print the "
"best candidate")
pb.add_argument("--fix", action="store_true",
help="write suggested C-numbers into the schematic's "
"LCSC parameters (implies --suggest; .kicad_sch "
"only; leaves a .bak — `akcli undo` reverts; "
"high-confidence suggestions only)")
pb.add_argument("--fix-all", dest="fix_all", action="store_true",
help="like --fix but also writes LOW-confidence "
"suggestions (package matched, value not verified "
"in the candidate description/MPN)")
pb.add_argument("--csv", metavar="OUT.csv",
help="also write a JLCPCB upload BOM CSV (Comment,"
"Designator,Footprint,LCSC Part #); '-' writes "
"to stdout")
pb.add_argument("--md", metavar="OUT.md", nargs="?", const="-",
help="write a Markdown BOM report (classification "
"summary + full table); '-' or bare = stdout")
pb.add_argument("--exit-zero", action="store_true",
help="always exit 0 (report mode)")
pb.set_defaults(handler=_cmd_jlc_bom)
pds = jlc_sub.add_parser(
"datasheet", parents=[common],
help="resolve/download datasheet PDFs for a C-number, an MPN, or a "
"schematic's whole BOM (via the EasyEDA record; networked)")
pds.add_argument("target", nargs="?",
help="LCSC C-number (C2984661), an exact MPN, or a "
"schematic path (.kicad_sch/.SchDoc)")
pds.add_argument("--fetch", action="store_true",
help="download the PDF(s); the %%PDF magic is verified "
"so an HTML challenge page is never saved")
pds.add_argument("-o", "--out", metavar="DIR",
help="download directory (default: AKCLI_DATASHEET_DIR "
"or ~/.cache/akcli/datasheets)")
pds.add_argument("--force", action="store_true",
help="re-download even when the file already exists")
pds.add_argument("--resolve-mpn", dest="resolve_mpn", action="store_true",
help="for MPN-only BOM lines, exact-match the catalog "
"(same policy as `jlc bom`) to pin a C-number "
"before resolving (one search per distinct MPN)")
pds.add_argument("--exit-zero", action="store_true",
help="always exit 0 (report mode)")
pds.set_defaults(handler=_cmd_jlc_datasheet)
psh = jlc_sub.add_parser("show", parents=[common],
help="show one part by LCSC C-number (e.g. C7593)")
psh.add_argument("lcsc", nargs="?", help="LCSC part number, e.g. C7593")
psh.add_argument("--easyeda", action="store_true",
help="also query EasyEDA for metadata + 3D-model availability")
psh.set_defaults(handler=_cmd_jlc_show)
pa = jlc_sub.add_parser(
"add", parents=[common],
help="fetch an LCSC part and convert it into a KiCad library (networked)",
)
pa.add_argument("lcsc", nargs="?", help="LCSC part number, e.g. C2040")
pa.add_argument("--3d", dest="with_3d", action="store_true",
help="also download the 3D STEP model")
pa.add_argument("-o", "--out", metavar="DIR",
help="output directory (default: <[paths].parts_dir or "
"./akcli-parts>/<C-number>/)")
pa.add_argument("--lib-name", metavar="NAME", default="akcli",
help="KiCad symbol library name (default: akcli)")
pa.add_argument("--footprint-lib", dest="footprint_lib", metavar="NICKNAME",
default="footprint",
help="fp-lib-table nickname written into the symbol's "
"Footprint field AND used as the footprint output "
"directory (default: footprint) — set it to the "
"nickname your project registers or KiCad will not "
"find the package")
pa.add_argument("--3d-path", dest="model_path", metavar="MODE",
default="relative",
help="how footprints reference the 3D model: 'relative' "
"(default; portable, resolves only next to the "
"library), 'absolute' (always resolves on this "
"machine, not portable), or a '${VAR}' prefix "
"(portable via a KiCad path variable)")
pa.add_argument("--force", action="store_true",
help="overwrite existing artifacts")
pa.add_argument("--place", action="store_true",
help="also emit a place_component op-list")
pa.add_argument("--designator", metavar="REF",
help="reference designator for --place (e.g. U1)")
pa.add_argument("--at", nargs=2, type=float, metavar=("X", "Y"),
help="placement position in mils for --place")
pa.set_defaults(handler=_cmd_jlc_add)
2126c3284This file is pinned to the selected release. Download the original to open it in your design tool.