board
RITHESH LED Display
Public Unreviewedby Rithesh03
A 300 x 70 mm PCB that spells RITHESH with 107 WS2812B RGB LEDs, controlled over Wi-Fi.
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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
"""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()