diff --git a/.cursor/skills/easyeda-to-kicad/SKILL.md b/.cursor/skills/easyeda-to-kicad/SKILL.md new file mode 100644 index 0000000..0576835 --- /dev/null +++ b/.cursor/skills/easyeda-to-kicad/SKILL.md @@ -0,0 +1,74 @@ +--- +name: easyeda-to-kicad +description: >- + Imports LCSC/EasyEDA parts into a Tiny Engineer KiCad board via + easyeda2kicad --full (symbol, footprint, 3D model). Use when adding + JLCPCB/LCSC parts, EasyEDA libraries, or filling + hardware/boards//libraries. +--- + +# EasyEDA → KiCad board + +Import an LCSC part into `hardware/boards//`. Library files only — do **not** place the symbol on the schematic. Do **not** merge into `TinyEngineerModules`. + +## Inputs + +| Input | Meaning | +| --- | --- | +| Part number | LCSC id starting with `C` (e.g. `C2040`) | +| Board | `hardware/boards//` (directory name = KiCad project) | + +Fail if the board dir has no `.kicad_pro`. Manufacturer PNs are not valid; find the LCSC `C…` id first. + +## Run + +Execute this script (do not reimplement the download/move/lib-table steps): + +```bash +python3 .cursor/skills/easyeda-to-kicad/scripts/import_lcsc.py \ + --lcsc-id C2040 \ + --board main-control-board +``` + +`--board` accepts `main-control-board` or `hardware/boards/main-control-board`. + +The script: + +1. Resolves the board from the repo root; creates `libraries/{symbols,footprints,3d}`. +2. Installs `easyeda2kicad` if missing. +3. Runs from the **board directory** (required for `--project-relative`): + +```bash +python3 -m easyeda2kicad --full --lcsc_id=Cxxxx \ + --output libraries/symbols/easyeda \ + --project-relative --overwrite +``` + +Always `--full`. Always `--overwrite`. Never `--use-cache`. + +4. Moves `libraries/symbols/easyeda.pretty/` → `libraries/footprints/easyeda.pretty/` and `libraries/symbols/easyeda.3dshapes/` → `libraries/3d/easyeda.3dshapes/`. +5. Rewrites 3D paths in footprints to `${KIPRJMOD}/libraries/3d/easyeda.3dshapes`. +6. Upserts lib nickname `easyeda` in `sym-lib-table` / `fp-lib-table`. + +## Layout + +``` +hardware/boards// + libraries/symbols/easyeda.kicad_sym + libraries/footprints/easyeda.pretty/ + libraries/3d/easyeda.3dshapes/ + sym-lib-table + fp-lib-table +``` + +Lib nickname **must** be `easyeda` (symbol footprint field is `easyeda:…`). URIs use `${KIPRJMOD}` only — no absolute local paths. + +## Example + +Import C2040 into main-control-board: + +```bash +python3 .cursor/skills/easyeda-to-kicad/scripts/import_lcsc.py \ + --lcsc-id C2040 \ + --board main-control-board +``` diff --git a/.cursor/skills/easyeda-to-kicad/scripts/import_lcsc.py b/.cursor/skills/easyeda-to-kicad/scripts/import_lcsc.py new file mode 100755 index 0000000..eb28552 --- /dev/null +++ b/.cursor/skills/easyeda-to-kicad/scripts/import_lcsc.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Import an LCSC/EasyEDA part into a Tiny Engineer KiCad board project.""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + +LIB_NICKNAME = "easyeda" +OUTPUT_PREFIX = Path("libraries") / "symbols" / "easyeda" +SRC_PRETTY = Path("libraries") / "symbols" / "easyeda.pretty" +SRC_3D = Path("libraries") / "symbols" / "easyeda.3dshapes" +DST_PRETTY = Path("libraries") / "footprints" / "easyeda.pretty" +DST_3D = Path("libraries") / "3d" / "easyeda.3dshapes" +OLD_3D_PATH = "${KIPRJMOD}/libraries/symbols/easyeda.3dshapes" +NEW_3D_PATH = "${KIPRJMOD}/libraries/3d/easyeda.3dshapes" +SYM_URI = "${KIPRJMOD}/libraries/symbols/easyeda.kicad_sym" +FP_URI = "${KIPRJMOD}/libraries/footprints/easyeda.pretty" +LIB_DESCR = "LCSC/EasyEDA imports" + + +def die(message: str, code: int = 1) -> None: + print(f"error: {message}", file=sys.stderr) + raise SystemExit(code) + + +def _is_repo_root(candidate: Path) -> bool: + return (candidate / "hardware" / "boards").is_dir() and ( + (candidate / ".git").exists() or (candidate / "docs" / "pcb.md").is_file() + ) + + +def find_repo_root(start: Path) -> Path: + searched = [] + for origin in (start, Path(__file__).resolve().parent): + for candidate in (origin, *origin.parents): + if candidate in searched: + continue + searched.append(candidate) + if _is_repo_root(candidate): + return candidate + die(f"could not find repo root from {start}") + + +def resolve_board_dir(repo_root: Path, board: str) -> Path: + board_name = Path(board.strip().rstrip("/")).name + board_dir = repo_root / "hardware" / "boards" / board_name + if not board_dir.is_dir(): + die(f"board directory not found: {board_dir}") + pro_files = list(board_dir.glob("*.kicad_pro")) + if not pro_files: + die(f"no .kicad_pro in {board_dir}") + return board_dir + + +def ensure_dirs(board_dir: Path) -> None: + for rel in ( + Path("libraries") / "symbols", + Path("libraries") / "footprints", + Path("libraries") / "3d", + ): + (board_dir / rel).mkdir(parents=True, exist_ok=True) + + +def easyeda2kicad_cmd() -> list[str]: + probe = [sys.executable, "-m", "easyeda2kicad", "--help"] + result = subprocess.run(probe, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if result.returncode == 0: + return [sys.executable, "-m", "easyeda2kicad"] + if shutil.which("easyeda2kicad"): + return ["easyeda2kicad"] + return [] + + +def ensure_easyeda2kicad() -> list[str]: + cmd = easyeda2kicad_cmd() + if cmd: + return cmd + print("easyeda2kicad not found; installing…") + install = subprocess.run( + [sys.executable, "-m", "pip", "install", "easyeda2kicad"], + check=False, + ) + if install.returncode != 0: + die("failed to install easyeda2kicad") + cmd = easyeda2kicad_cmd() + if not cmd: + die("easyeda2kicad installed but not runnable") + return cmd + + +def run_easyeda2kicad(board_dir: Path, lcsc_id: str, cmd: list[str]) -> None: + # Absolute --output: easyeda2kicad --project-relative does + # Path(f"{output}.3dshapes").relative_to(cwd) and that fails on a + # relative output path. + output = (board_dir / OUTPUT_PREFIX).resolve() + argv = [ + *cmd, + "--full", + f"--lcsc_id={lcsc_id}", + f"--output={output}", + "--project-relative", + "--overwrite", + ] + print("running:", " ".join(argv)) + result = subprocess.run(argv, cwd=board_dir, check=False) + if result.returncode != 0: + die(f"easyeda2kicad failed with exit {result.returncode}") + + +def move_tree(src: Path, dst: Path) -> None: + if not src.exists(): + return + dst.mkdir(parents=True, exist_ok=True) + for item in src.iterdir(): + target = dst / item.name + if target.exists(): + if target.is_dir(): + shutil.rmtree(target) + else: + target.unlink() + shutil.move(str(item), str(target)) + if src.exists() and not any(src.iterdir()): + src.rmdir() + + +def rewrite_3d_paths(pretty_dir: Path) -> None: + if not pretty_dir.is_dir(): + return + for mod in pretty_dir.glob("*.kicad_mod"): + text = mod.read_text(encoding="utf-8") + if OLD_3D_PATH not in text: + continue + mod.write_text(text.replace(OLD_3D_PATH, NEW_3D_PATH), encoding="utf-8") + + +def lib_entry(name: str, uri: str) -> str: + return ( + f'\t(lib (name "{name}") (type "KiCad") (uri "{uri}") ' + f'(options "") (descr "{LIB_DESCR}"))' + ) + + +def upsert_lib_table(path: Path, table_tag: str, uri: str) -> None: + entry = lib_entry(LIB_NICKNAME, uri) + if not path.is_file(): + path.write_text( + f"({table_tag}\n\t(version 7)\n{entry}\n)\n", + encoding="utf-8", + ) + return + text = path.read_text(encoding="utf-8") + if f'(name "{LIB_NICKNAME}")' in text: + return + if text.rstrip().endswith(")"): + stripped = text.rstrip() + # Insert before the final closing paren of the table. + without_close = stripped[: stripped.rfind(")")] + path.write_text(f"{without_close.rstrip()}\n{entry}\n)\n", encoding="utf-8") + return + die(f"could not parse lib table {path}") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Import an LCSC/EasyEDA part into a Tiny Engineer KiCad board." + ) + parser.add_argument("--lcsc-id", required=True, help="LCSC id, e.g. C2040") + parser.add_argument( + "--board", + required=True, + help="Board name or hardware/boards/", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv if argv is not None else sys.argv[1:]) + lcsc_id = args.lcsc_id.strip() + if not lcsc_id.startswith("C"): + die(f"lcsc id must start with C, got {lcsc_id!r}") + + repo_root = find_repo_root(Path.cwd().resolve()) + board_dir = resolve_board_dir(repo_root, args.board) + ensure_dirs(board_dir) + cmd = ensure_easyeda2kicad() + run_easyeda2kicad(board_dir, lcsc_id, cmd) + + move_tree(board_dir / SRC_PRETTY, board_dir / DST_PRETTY) + move_tree(board_dir / SRC_3D, board_dir / DST_3D) + rewrite_3d_paths(board_dir / DST_PRETTY) + + upsert_lib_table(board_dir / "sym-lib-table", "sym_lib_table", SYM_URI) + upsert_lib_table(board_dir / "fp-lib-table", "fp_lib_table", FP_URI) + + print(f"imported {lcsc_id} into {board_dir.relative_to(repo_root)}") + print(f" symbol: {SYM_URI}") + print(f" footprint: {FP_URI}") + print(f" 3d: {NEW_3D_PATH}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.cursor/skills/verify-jlcpcb-bom/SKILL.md b/.cursor/skills/verify-jlcpcb-bom/SKILL.md new file mode 100644 index 0000000..52ec719 --- /dev/null +++ b/.cursor/skills/verify-jlcpcb-bom/SKILL.md @@ -0,0 +1,129 @@ +--- +name: verify-jlcpcb-bom +description: >- + Verifies a KiCad board production/bom.csv against schematic fields, PCB + footprints, and the JLCPCB LCSC catalog, then interprets each FAIL and WARN, + marks string-match false positives, and recommends a fix only for real + mismatches. Use when checking a board BOM, LCSC part numbers, JLCPCB parts, + or whether production/bom.csv matches the schematic and footprints. +--- + +# Verify JLCPCB BOM + +Report only. Do not edit the schematic, PCB, BOM, or footprints. + +`hardware/boards//production/` is gitignored. Read `bom.csv` from disk. Do not infer nets from schematic geometry. + +## Run + +Execute this script (do not reimplement parsing or the JLCPCB request): + +```bash +python3 .cursor/skills/verify-jlcpcb-bom/scripts/verify_bom.py \ + --board main-control-board +``` + +`--board` accepts `main-control-board` or `hardware/boards/main-control-board`. + +If the user did not name a board, run it for the only `hardware/boards/*/production/bom.csv`. If several exist, ask which board. + +Missing `production/bom.csv`: tell the user to generate it with KiCad's Fabrication Toolkit. Do not invent a BOM. + +## How to read the report + +The script prints `FAIL` lines, then `WARN` lines, then one `PASS N` line. `N` is BOM rows with no fail. Exit code 1 means at least one fail. + +- **Fail** — wrong or missing LCSC id, BOM row disagrees with the schematic (designator, value, LCSC, footprint), schematic footprint was not pushed to the board, JLCPCB package is not in the footprint name, or a passive value disagrees with the catalog Resistance / Capacitance / Inductance. +- **Warn** — DNP part still in the BOM, JLCPCB stock is 0, part is extended (`expand`), schematic value is a label that does not contain the manufacturer part number, or a project-library footprint pad count disagrees with a catalog pin count. +- A lookup failure is a fail. It means package and value were not checked. Do not fill those in from memory. + +Quote the script output. Do not dump the JSON response. Do not re-fetch each part unless the script could not reach JLCPCB. + +After the quote, interpret every `FAIL` and `WARN`. Group lines that share one cause. Do not stop at the raw script text. + +## Interpret + +Verdict for each line: **real**, **false positive**, or **info**. + +A lookup failure is neither a design bug nor a false positive. For refs whose lookup failed, say the catalog was not reached and skip package and value claims. Continue interpreting findings for all other refs. + +### Package line is a name search + +`package … not in footprint …` means JLCPCB `componentSpecificationEn` was not found as a substring of the footprint name. The script lowercases, then strips spaces, `_`, `-`, and `=`. It skips only these generic tokens: `smd`, `tht`, `plugin`, `dip`, `radial`, `axial`, `chip`, `connector`, `throughhole`, `through-hole`. It splits on ASCII `,` `;` `/` only. A fullwidth comma `,` glues the surrounding words into one token, so `Surface Mount,Right Angle` never matches a KiCad name. + +`Surface Mount` is not in the skip list. It fails against footprints that say nothing, or only `SMD`. + +Pitch `P=1mm` does not match the text `P1.00mm` (`p1mm` vs `p1.00mm`). Body `3x3` does not match `L3.0-W3.0`. Those are format misses, not evidence the land pattern differs. + +### False positive + +Call the package line a **false positive** when every stated dimension agrees, even if the words differ: + +| JLCPCB wording | Same footprint wording | +| --- | --- | +| `1mm`, `P=1mm` | `P1.00mm`, `P1.0mm` | +| `3x3`, `EP(3x3)` | `L3.0-W3.0`, `3.0x3.0` (the `(3x3)` is the body, not the exposed-pad size) | +| `TQFN-16-EP` | `TQFN-16` plus `EP` in the name | +| `Right Angle`, `Horizontal` | the other of those two, on a connector | +| `Surface Mount` | `SMD`, or an SMD land pattern with no mount word | +| `D10xL10.5` | `10x10.5` | + +Also a false positive when the footprint name contains the manufacturer part number (or the standard equivalent, such as `53261-0271` for a PicoBlade-style 1.25 mm header) and pitch, pin count, and orientation agree. + +`could not compare value` and `package … not comparable` are inconclusive. Say what was not checked. Do not call them false positives. + +### Real package mismatch + +Keep **real** when any of these disagree: + +- Pitch (`P1.25` vs `P2.00`, `P0.50` vs `P0.65`) +- Pin count or row shape (`1x02` vs `1x04`, TQFN-16 vs TQFN-20) +- Body size that is not a zero/format difference (`0603` vs `0805`, `3x3` vs `4x4`) +- Orientation (`Horizontal` / `Right Angle` vs `Vertical` / top-entry / straight) +- Mount (`SMD` vs `THT` / pin-header vertical) +- Connector family whose land pattern differs (PicoBlade vs PH, JST SH vs GH) + +If the line is still ambiguous, read that ref's schematic footprint, value, and LCSC fields, and the footprint name on the board. Do not infer nets from coordinates. If it stays ambiguous, verdict **uncertain** and name the dimension that is missing. Do not downgrade that to a false positive. + +### Other lines + +| Line | Verdict | Meaning | +| --- | --- | --- | +| Passive `value … does not match JLCPCB Resistance/Capacitance/Inductance` | real | Schematic value and catalog value are different parts | +| Missing LCSC, LCSC not an id, LCSC not in catalog | real | BOM cannot be ordered as drawn | +| BOM designator, value, LCSC, or footprint disagrees with the schematic | real | Export is stale or the schematic field changed | +| Schematic footprint not on the board | real | PCB was not updated from the schematic | +| `stockCount is 0` | info | Purchasing risk. The part may still be the right design choice | +| `extended part (expand)` | info | JLCPCB extended library: higher price, longer lead. Not a schematic error | +| `schematic value "…" vs MPN …` | info | Value is a function label (`LED`, `Conn_OLED`, `3A PPTC`). Expected when the LCSC is intentional | +| `N pads, JLCPCB pins M` | real unless the extra pads are an exposed pad, mounting holes, or shield tabs | Open that footprint and count electrical pads before recommending a footprint swap | + +## Recommend a fix + +Recommend a fix only for **real** and for **info** the user would act on (stock 0, extended cost). False positives: `no change`. + +Name the field and the direction. Do not invent a replacement LCSC id. If a different part is required, say to search JLCPCB for the same footprint, pitch, pin count, and value. + +- Wrong passive value: set the schematic value to the catalog value, or replace the LCSC with the part whose Resistance / Capacitance / Inductance matches the schematic. Regenerate `production/bom.csv`. +- Wrong or missing LCSC: set `LCSC Part #` on the schematic symbol to the intended in-stock id, then regenerate the BOM. +- Footprint text or pad count disagrees with the ordered package: assign the footprint that matches that package, update the PCB from the schematic, regenerate the BOM. +- Stale BOM vs schematic: regenerate with Fabrication Toolkit. Do not hand-edit `bom.csv`. +- Extended or zero stock, and the land pattern is already right: optional search for a basic-library equivalent. No schematic edit until that part is chosen. +- Function label vs MPN: no rename. + +## Reply + +Use this shape. Quote the script output unchanged. One block per cause, not per ref, when the cause is the same. + +```markdown +