Controller board: ESP32 + 2x StepStick carrier (v0.4), FluidNC config
- KiCad project generated by pcb/tools (design, route, schematic, fab outputs). - v0.4 fixes the v0.3 review findings: low-dropout 3.3 V regulator, OR'd 5 V supply, polyfuse, USB ESD protection, input clamps, ceramic decoupling at the drivers, A4988/DRV8825 jumper, ground stitching. DRC/ERC clean. - v0.3 (as ordered) kept in pcb/archive/v0.3. - FluidNC config.yaml for the board; rho hard limit on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c498882bbb
commit
1400cc8afd
46 files changed
+226470
No files matched your search
@@ -0,0 +1,219 @@
|
||||
"""Build the PCBWay order files and copy the KiCad project + outputs into the repo.
|
||||
|
||||
Run after route.py and sch.py. Writes to ~/kicad/sandtable-controller/fab (the KiCad snap can't
|
||||
read inside ~/snap), then copies the results to ../ (the repo's pcb/ folder).
|
||||
"""
|
||||
import csv
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
import design as D
|
||||
|
||||
WORK = os.path.expanduser('~/kicad/sandtable-controller')
|
||||
NAME = 'sandtable-controller'
|
||||
PCB = os.path.join(WORK, NAME + '.kicad_pcb')
|
||||
SCH = os.path.join(WORK, NAME + '.kicad_sch')
|
||||
FAB = os.path.join(WORK, 'fab')
|
||||
GERB = os.path.join(FAB, 'gerbers')
|
||||
REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
CLI = 'kicad.kicad-cli'
|
||||
|
||||
|
||||
def cli(*args):
|
||||
r = subprocess.run([CLI, *args], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
sys.exit(f'kicad-cli {" ".join(args[:3])} failed:\n{r.stdout}\n{r.stderr}')
|
||||
|
||||
|
||||
|
||||
|
||||
def bom(pcb_text):
|
||||
"""PCBWay BOM straight from design.py (one line per sourcing group)."""
|
||||
parts = {r: p for r, p in D.PARTS.items() if not r.startswith(('H', 'JP'))} # holes, solder jumpers
|
||||
covered = [r for refs, *_ in D.SOURCING for r in refs]
|
||||
dup = sorted({r for r in covered if covered.count(r) > 1})
|
||||
missing = sorted(set(parts) - set(covered))
|
||||
extra = sorted(set(covered) - set(parts))
|
||||
if dup or missing or extra:
|
||||
sys.exit(f'SOURCING mismatch: duplicate {dup}, missing {missing}, unknown {extra}')
|
||||
rows = []
|
||||
for i, (refs, mfr, mpn, desc, notes) in enumerate(D.SOURCING, 1):
|
||||
fps = {parts[r][1].split(':')[1] for r in refs}
|
||||
tht = any(pcb_text.count(f'(footprint "{parts[r][1]}"') and _is_tht(pcb_text, r) for r in refs)
|
||||
qty = D.SOURCING_QTY.get(tuple(refs), len(refs))
|
||||
rows.append([i, ','.join(refs), qty, mfr, mpn, desc, ' / '.join(sorted(fps)),
|
||||
'Thru-Hole' if tht else 'SMD', notes])
|
||||
return rows
|
||||
|
||||
|
||||
def _is_tht(pcb_text, ref):
|
||||
# find the footprint block that carries this reference and read its (attr ...)
|
||||
key = f'(property "Reference" "{ref}"'
|
||||
i = pcb_text.find(key)
|
||||
start = pcb_text.rfind('(footprint ', 0, i)
|
||||
j = pcb_text.find('(attr ', start, i + 4000)
|
||||
return pcb_text[j:j + 30].startswith('(attr through_hole')
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(GERB, exist_ok=True)
|
||||
for f in glob.glob(os.path.join(GERB, '*')):
|
||||
os.remove(f)
|
||||
# Gerbers + drill, all relative to the board's bottom-left corner
|
||||
cli('pcb', 'export', 'gerbers', '-o', GERB + '/', '-l',
|
||||
'F.Cu,B.Cu,F.Paste,F.SilkS,B.SilkS,F.Mask,B.Mask,Edge.Cuts', '--use-drill-file-origin', PCB)
|
||||
cli('pcb', 'export', 'drill', '-o', GERB + '/', '--format', 'excellon', '--excellon-units', 'mm',
|
||||
'--excellon-separate-th', '--drill-origin', 'plot', PCB)
|
||||
zpath = os.path.join(FAB, NAME + '-gerbers.zip')
|
||||
with zipfile.ZipFile(zpath, 'w', zipfile.ZIP_DEFLATED) as z:
|
||||
for f in sorted(os.listdir(GERB)):
|
||||
z.write(os.path.join(GERB, f), f)
|
||||
print('gerbers:', ', '.join(sorted(os.listdir(GERB))))
|
||||
|
||||
# Centroid (pick and place)
|
||||
raw = os.path.join(FAB, 'pos-raw.csv')
|
||||
cli('pcb', 'export', 'pos', '-o', raw, '--format', 'csv', '--units', 'mm', '--side', 'both',
|
||||
'--use-drill-file-origin', PCB)
|
||||
pos = list(csv.DictReader(open(raw)))
|
||||
os.remove(raw)
|
||||
with open(os.path.join(FAB, 'centroid-pcbway.csv'), 'w', newline='') as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(['Designator', 'Mid X (mm)', 'Mid Y (mm)', 'Layer', 'Rotation', 'Value', 'Footprint'])
|
||||
for r in sorted(pos, key=lambda r: (r['Ref'][0], int(''.join(c for c in r['Ref'] if c.isdigit())))):
|
||||
w.writerow([r['Ref'], f"{float(r['PosX']):.3f}", f"{float(r['PosY']):.3f}",
|
||||
'Top' if r['Side'] == 'top' else 'Bottom', f"{float(r['Rot']) % 360:g}", r['Val'], r['Package']])
|
||||
print('centroid:', len(pos), 'parts')
|
||||
|
||||
# BOM
|
||||
pcb_text = open(PCB).read()
|
||||
rows = bom(pcb_text)
|
||||
with open(os.path.join(FAB, 'bom-pcbway.csv'), 'w', newline='') as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(['Item #', 'Designator', 'Qty', 'Manufacturer', 'Mfg Part #', 'Description / Value',
|
||||
'Package / Footprint', 'Type', 'Notes'])
|
||||
w.writerows(rows)
|
||||
print('bom:', len(rows), 'lines,', sum(r[2] for r in rows), 'pieces')
|
||||
|
||||
# 3D model, renders, schematic PDF
|
||||
cli('pcb', 'export', 'step', '-f', '-o', os.path.join(FAB, NAME + '.step'), '--subst-models', PCB)
|
||||
cli('pcb', 'render', '-o', os.path.join(FAB, 'render-iso.png'), '--width', '1800', '--height', '1200',
|
||||
'--quality', 'high', '--perspective', '--floor', '--rotate', '310,0,325', '--zoom', '0.9', PCB)
|
||||
for side in ('top', 'bottom'):
|
||||
cli('pcb', 'render', '-o', os.path.join(FAB, f'render-{side}.png'), '--side', side,
|
||||
'--width', '1800', '--height', '1400', '--quality', 'high', PCB)
|
||||
cli('sch', 'export', 'pdf', '-o', os.path.join(WORK, NAME + '-schematic.pdf'), SCH)
|
||||
|
||||
# Assembly drawing (part outlines, references, polarity marks)
|
||||
assembly_pdf(os.path.join(FAB, 'assembly-top.pdf'))
|
||||
|
||||
# Copy into the repo: KiCad project, the PCBWay order folder, renders
|
||||
kdir = os.path.join(REPO, 'kicad')
|
||||
odir = os.path.join(REPO, 'pcbway-order')
|
||||
rdir = os.path.join(REPO, 'renders')
|
||||
for d in (kdir, odir, rdir):
|
||||
os.makedirs(d, exist_ok=True)
|
||||
for f in glob.glob(os.path.join(odir, '*')):
|
||||
os.remove(f)
|
||||
for f in (NAME + '.kicad_pro', NAME + '.kicad_pcb', NAME + '.kicad_sch', 'sym-lib-table', 'fp-lib-table',
|
||||
'sandtable.kicad_sym'):
|
||||
shutil.copy2(os.path.join(WORK, f), kdir)
|
||||
tag = f'{NAME}-v{D.VERSION}'
|
||||
shutil.copy2(zpath, os.path.join(odir, f'1-gerbers-{tag}.zip'))
|
||||
shutil.copy2(os.path.join(FAB, 'bom-pcbway.csv'), os.path.join(odir, f'2-bom-{tag}.csv'))
|
||||
shutil.copy2(os.path.join(FAB, 'centroid-pcbway.csv'), os.path.join(odir, f'3-centroid-{tag}.csv'))
|
||||
shutil.copy2(os.path.join(FAB, 'assembly-top.pdf'), os.path.join(odir, f'assembly-top-v{D.VERSION}.pdf'))
|
||||
shutil.copy2(os.path.join(WORK, NAME + '-schematic.pdf'), os.path.join(odir, f'schematic-v{D.VERSION}.pdf'))
|
||||
write_order_sheet(os.path.join(odir, 'ORDER.md'), tag, rows, pcb_text)
|
||||
for f in ('render-iso.png', 'render-top.png', 'render-bottom.png'):
|
||||
shutil.copy2(os.path.join(FAB, f), rdir)
|
||||
shutil.copy2(os.path.join(FAB, NAME + '.step'), REPO)
|
||||
shutil.copy2(os.path.join(WORK, NAME + '-schematic.pdf'), REPO)
|
||||
print('order folder:', ', '.join(sorted(os.listdir(odir))))
|
||||
|
||||
|
||||
def assembly_pdf(out):
|
||||
"""KiCad plots on an A4 sheet with the board small in one corner: crop to the board and fit to A4."""
|
||||
full, crop = out + '.full.pdf', out + '.crop.pdf'
|
||||
cli('pcb', 'export', 'pdf', '-o', full, '--mode-single', '-l', 'F.Fab,F.SilkS,Edge.Cuts',
|
||||
'--ev', '--black-and-white', PCB)
|
||||
mm, page_h, m = 72 / 25.4, 210.0, 5.0 # A4 landscape sheet, 5 mm margin
|
||||
ox, oy = D.ORIGIN
|
||||
box = [(ox - m) * mm, (page_h - (oy + D.BOARD_H + m)) * mm, (ox + D.BOARD_W + m) * mm, (page_h - (oy - m)) * mm]
|
||||
gs = ['gs', '-q', '-sDEVICE=pdfwrite']
|
||||
subprocess.run(gs + ['-o', crop, '-c', '[/CropBox [' + ' '.join(f'{v:.1f}' for v in box) + '] /PAGES pdfmark',
|
||||
'-f', full], check=True)
|
||||
subprocess.run(gs + ['-o', out, '-dUseCropBox', '-dFIXEDMEDIA', '-dPDFFitPage',
|
||||
'-dDEVICEWIDTHPOINTS=842', '-dDEVICEHEIGHTPOINTS=595', crop], check=True)
|
||||
os.remove(full)
|
||||
os.remove(crop)
|
||||
|
||||
|
||||
def write_order_sheet(path, tag, rows, pcb_text):
|
||||
smd = pcb_text.count('(attr smd')
|
||||
tht_pieces = sum(r[2] for r in rows if r[7] == 'Thru-Hole')
|
||||
tht_parts = sum(len(r[1].split(',')) for r in rows if r[7] == 'Thru-Hole')
|
||||
text = f"""# PCBWay order: SandTable Controller v{D.VERSION}
|
||||
|
||||
## Files
|
||||
|
||||
| File | Where it goes |
|
||||
|---|---|
|
||||
| `1-gerbers-{tag}.zip` | PCB order: **Upload Gerber file** |
|
||||
| `2-bom-{tag}.csv` | Assembly order: **BOM file** |
|
||||
| `3-centroid-{tag}.csv` | Assembly order: **Centroid / pick-and-place file** |
|
||||
| `assembly-top-v{D.VERSION}.pdf` | Attach if PCBWay asks for an assembly drawing |
|
||||
| `schematic-v{D.VERSION}.pdf` | Attach if PCBWay asks for a schematic |
|
||||
|
||||
## PCB settings
|
||||
|
||||
| Setting | Value |
|
||||
|---|---|
|
||||
| Board type | Single pieces |
|
||||
| Size | {D.BOARD_W:g} x {D.BOARD_H:g} mm |
|
||||
| Quantity | 5 (the minimum) |
|
||||
| Layers | 2 |
|
||||
| Material | FR-4, TG130-140 |
|
||||
| Thickness | 1.6 mm |
|
||||
| Min track / spacing | 6/6 mil |
|
||||
| Min hole size | 0.3 mm |
|
||||
| Solder mask / silkscreen | any colour / white |
|
||||
| Surface finish | HASL lead-free (or ENIG) |
|
||||
| Via process | Tenting vias |
|
||||
| Finished copper | 1 oz |
|
||||
| Product No. | Specify a location (the back of the board has a "WayWayWay" marker for it) |
|
||||
|
||||
## Assembly settings (PCB Assembly quote)
|
||||
|
||||
| Setting | Value |
|
||||
|---|---|
|
||||
| Service | Turnkey (PCBWay buys the parts) |
|
||||
| Assembly side | Top side only |
|
||||
| Quantity | 1 or 2 (fewer than the 5 bare boards is fine) |
|
||||
| Unique parts | {len(rows)} |
|
||||
| SMD parts | {smd} |
|
||||
| BGA / QFP parts | 0 |
|
||||
| Through-hole parts | {tht_pieces} ({tht_parts} designators; each driver socket is 2 headers) |
|
||||
| Accept alternatives | Yes, for lines marked "Any brand" |
|
||||
|
||||
**Note for PCBWay** (paste into the comments box):
|
||||
|
||||
> Please fit the female header sockets for A1 and A2 only. Do not supply the stepper driver modules; I will plug in my own. BOM lines marked "Any brand" may use any equivalent part to the description. U3 (AP7361C-33E) has a different pinout from AMS1117-type regulators, so please do not substitute it. Leave solder jumper JP1 open. C16 and C17 sit between the driver socket strips, under where the modules plug in; that is intended. Centroid coordinates are in mm, with the origin at the board's bottom-left corner.
|
||||
|
||||
## When PCBWay sends their review
|
||||
- Check the polarity of C1, C2 (electrolytic), D1-D3, D6, D7 (diodes) and D4, D5 (LEDs) against the + and bar marks in `assembly-top-v{D.VERSION}.pdf`.
|
||||
- Check that the ESP32 (U1) antenna end sits at the board edge, over the copper-free area.
|
||||
- Check that pin 1 of U2, U3, U4 and U5 is at the dot or triangle on the silkscreen.
|
||||
- U3 is an AP7361C-33E, not an AMS1117: its pinout is different (pin 1 IN, pin 2 GND/tab, pin 3 OUT), so it must not be swapped for a 1117-type regulator.
|
||||
- JP1 is a solder jumper (bare pads), not a part: leave it open.
|
||||
"""
|
||||
with open(path, 'w') as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in new issue
Block a user