main
Rithesh03 Update 3: PCB design completed - routed board, manufacturing package (prototype, do not order yet), WLED guide, final checklist, Hydrogen feedback 1ed072d 13d ago
"""Build the JLCPCB manufacturing package (for review only - PROTOTYPE, DO NOT ORDER until the LED timing test passes).

Usage: python3 make_manufacturing.py <netlist.xml exported from the schematic>

Output: hardware/manufacturing/RITHESH-v1-PROTOTYPE/
  gerber/     Gerbers (copper, mask, paste, silkscreen, outline), Gerber job file, PTH + NPTH Excellon drills, drill map
  assembly/   BOM-JLCPCB.csv, CPL-JLCPCB.csv, BOM-full.csv (incl. DNP), assembly-front.pdf, assembly-back.pdf
  RITHESH-v1-PROTOTYPE-DO-NOT-ORDER-gerbers.zip   what JLCPCB asks for as the "Gerber file"
Coordinates are measured from the board's bottom-left corner (drill/place origin), in mm, Y up.
"""
import csv
import os
import shutil
import subprocess
import sys
import xml.etree.ElementTree as ET
import zipfile

import pcbnew

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import make_board as mb  # noqa: E402

OUT = os.path.join(mb.HW, "manufacturing", "RITHESH-v1-PROTOTYPE")
WARN = ("PROTOTYPE - DO NOT ORDER until the XL-5050RGBC-2812B LED timing pre-check (5-10 LEDs with WLED on an ESP32-C3) "
        "has passed and JLCPCB's DFM / placement preview has been reviewed.")

NOTES = """# RITHESH LED Display - fabrication and assembly notes (rev 0.1 prototype)

> **PROTOTYPE - DO NOT ORDER** until (1) the XL-5050RGBC-2812B LED timing pre-check (5-10 LEDs driven by WLED on an
> ESP32-C3) has passed and (2) JLCPCB's DFM check and 3D placement preview have been reviewed.

## Board (PCB order options)

| Option | Value |
|--------|-------|
| Size | 300.0 x 70.0 mm, 2 mm corner radius (outline: `*-Edge_Cuts.gm1`) |
| Layers | 2 |
| Material / thickness | FR-4, 1.6 mm |
| Copper | 1 oz (35 um) finished, both outer layers |
| Surface finish | HASL lead-free (or ENIG) |
| Solder mask / silkscreen | green / white (any colour works) |
| Vias | 0.30 mm drill, 0.60 mm pad, **tented** (covered by mask); no via-in-pad, no filling or capping needed |
| Minimum track / gap | 0.25 mm / 0.20 mm (0.20 mm only inside the USB-C footprint, set by its pins) |
| Edge rails | 5 mm on the two long edges, added by JLCPCB, if the assembly service asks for them |
| Order number | JLCPCB prints one unless a location is chosen; there is free space on the bottom side |

## Holes

| File | Holes |
|------|-------|
| `*-PTH.drl` (plated) | 287 vias 0.30 mm + 4 plated slots 0.60 mm wide (USB-C shield legs) |
| `*-NPTH.drl` (non-plated) | 4 mounting holes 3.2 mm (M3) + 2 USB-C locating pegs 0.65 mm |

## Assembly (PCBA)

- **Top side only**, 250 placed parts (`assembly/BOM-JLCPCB.csv`, `assembly/CPL-JLCPCB.csv`).
- **Not fitted (DNP):** C13, C14 (USB capacitor spots), C302, C303 (spare 220 uF spots). They are left out of the BOM and CPL.
  KiCad 7 still puts paste openings on their pads: harmless (small solder bumps on empty pads).
- **Test pads TP1-TP10** are bare copper, not parts.
- Positions are from the board's **bottom-left corner** (mm, Y up), matching the Gerbers and drill files.
- **Rotations are KiCad's.** JLCPCB's library sometimes uses a different zero angle. In the JLCPCB placement preview, check
  every part type's pin 1 / polarity against `assembly/assembly-front.pdf` before paying, especially:
  - LEDs (pin 1 = VDD, corner mark)
  - D1 (cathode band to VBUS)
  - C301 / C304 (+ to +5V)
  - U1-U5 (pin-1 dot)
  - J1, U4 (ESP32 antenna toward the board edge)
- The board is 300 mm long: confirm on the quote which PCBA service (Economic or Standard) accepts that size.
"""

LAYERS = "F.Cu,B.Cu,F.Paste,B.Paste,F.SilkS,B.SilkS,F.Mask,B.Mask,Edge.Cuts"


def run(*cmd):
    subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)


def main():
    net = sys.argv[1]
    board = pcbnew.LoadBoard(mb.PCB)
    ds = board.GetDesignSettings()
    origin = mb.pt(0, mb.H)                                    # bottom-left corner of the board
    if ds.GetAuxOrigin() != origin:
        ds.SetAuxOrigin(origin)
        pcbnew.SaveBoard(mb.PCB, board)
        board = pcbnew.LoadBoard(mb.PCB)
    if os.path.isdir(OUT):
        shutil.rmtree(OUT)
    g = os.path.join(OUT, "gerber")
    a = os.path.join(OUT, "assembly")
    os.makedirs(g)
    os.makedirs(a)
    run("kicad-cli", "pcb", "export", "gerbers", "-l", LAYERS, "--subtract-soldermask", "--use-drill-file-origin",
        "-o", g + "/", mb.PCB)
    run("kicad-cli", "pcb", "export", "drill", "--format", "excellon", "--drill-origin", "plot", "-u", "mm",
        "--excellon-separate-th", "--generate-map", "--map-format", "pdf", "-o", g + "/", mb.PCB)
    pos = os.path.join(a, "positions-kicad.csv")
    run("kicad-cli", "pcb", "export", "pos", "--side", "both", "--format", "csv", "--units", "mm",
        "--use-drill-file-origin", "-o", pos, mb.PCB)
    run("kicad-cli", "pcb", "export", "pdf", "-l", "F.Fab,F.SilkS,Edge.Cuts", "--ibt", "--ev", "-o",
        os.path.join(a, "assembly-front.pdf"), mb.PCB)
    run("kicad-cli", "pcb", "export", "pdf", "-l", "B.Fab,B.SilkS,Edge.Cuts", "--ibt", "--ev", "-m", "-o",
        os.path.join(a, "assembly-back.pdf"), mb.PCB)

    # ---- BOM (grouped by value + footprint + LCSC) from the board, fields from the schematic netlist
    lcsc = {}
    for c in ET.parse(net).getroot().iter("comp"):
        fields = {f.get("name"): (f.text or "") for f in c.iter("field")}
        lcsc[c.get("ref")] = fields.get("LCSC", "")
    skip_flag = pcbnew.FP_EXCLUDE_FROM_BOM
    groups, full = {}, []
    for f in board.GetFootprints():
        ref = f.GetReference()
        if ref.startswith("H"):
            continue                                            # mounting holes: board features, not parts
        fp = str(f.GetFPID().GetLibItemName())
        dnp = bool(f.GetAttributes() & skip_flag)
        kind = "test pad (bare copper)" if ref.startswith("TP") else ("DNP - do not fit" if dnp else "fit")
        full.append((ref, f.GetValue(), fp, lcsc.get(ref, ""), kind))
        if dnp:
            continue
        key = (f.GetValue(), fp, lcsc.get(ref, ""))
        groups.setdefault(key, []).append(ref)

    def natural(r):
        import re
        m = re.match(r"([A-Z]+)(\d+)", r)
        return (m.group(1), int(m.group(2))) if m else (r, 0)
    with open(os.path.join(a, "BOM-JLCPCB.csv"), "w", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["Comment", "Designator", "Footprint", "LCSC Part #", "Quantity"])
        for (val, fp, part), refs in sorted(groups.items(), key=lambda kv: natural(sorted(kv[1], key=natural)[0])):
            refs = sorted(refs, key=natural)
            w.writerow([val, ",".join(refs), fp, part, len(refs)])
    with open(os.path.join(a, "BOM-full.csv"), "w", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["Reference", "Value", "Footprint", "LCSC Part #", "Assembly"])
        for row in sorted(full, key=lambda r: natural(r[0])):
            w.writerow(row)

    # ---- CPL in JLCPCB's format (KiCad already leaves out parts marked "exclude from position files")
    rows = list(csv.DictReader(open(pos)))
    with open(os.path.join(a, "CPL-JLCPCB.csv"), "w", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["Designator", "Mid X", "Mid Y", "Layer", "Rotation"])
        for r in sorted(rows, key=lambda r: natural(r["Ref"])):
            if r["Ref"].startswith("H"):
                continue
            w.writerow([r["Ref"], f'{float(r["PosX"]):.4f}mm', f'{float(r["PosY"]):.4f}mm',
                        "Top" if r["Side"].lower().startswith("top") else "Bottom", f'{float(r["Rot"]) % 360:.0f}'])
    os.remove(pos)

    for d in (OUT, g, a):
        with open(os.path.join(d, "PROTOTYPE-DO-NOT-ORDER.txt"), "w") as fh:
            fh.write(WARN + "\n")
    with open(os.path.join(OUT, "FABRICATION-NOTES.md"), "w") as fh:
        fh.write(NOTES)
    zp = os.path.join(OUT, "RITHESH-v1-PROTOTYPE-DO-NOT-ORDER-gerbers.zip")
    with zipfile.ZipFile(zp, "w", zipfile.ZIP_DEFLATED) as z:
        for name in sorted(os.listdir(g)):
            if name.endswith(".pdf"):
                continue                                        # drill map is for review, not for the fab
            z.write(os.path.join(g, name), name)
    print("package written to", OUT)
    for root, _, files in os.walk(OUT):
        for n in sorted(files):
            print("  ", os.path.relpath(os.path.join(root, n), OUT))


if __name__ == "__main__":
    main()