AnasSarkiz/muse-voice-remote

Muse Voice Remote — bare ESP32-S3R8, 155 TOP JLCPCB parts with real supplier CAD, native 3D assembly and GLB exports. Revised 17 mm case allocation; 0.30 mm via drill /0.45 mm pad. Checked prototype PCB; mechanical, RF and MUSE qualification pending.

Version
1.3.2
License
unset
Stars
0

scripts/export-fabrication.py

#!/usr/bin/env python3
"""Export a checked prototype manufacturing handoff from native imported CAD.

Supplier electronic copper is immutable. A separate factory copy receives
standard reference designators and thermal-pad stencil windows at 60% (TI DRC regulator: 80%) coverage.
"""
import csv,datetime,hashlib,json,math,os,pathlib,re,shutil,subprocess,sys,zipfile
import pcbnew
sys.path.insert(0,"/workspace/.cache/native-python")
from shapely.geometry import Polygon,box
from shapely.ops import unary_union
root=pathlib.Path(__file__).resolve().parents[1]
version=json.loads((root/'package.json').read_text())['version']
out=root/'manufacturing';out.mkdir(exist_ok=True)
source=root/'hardware/MuseVoiceRemote.kicad_pcb'
copper=json.loads((root/'design/routed-copper.json').read_text())
if hashlib.sha256(source.read_bytes()).hexdigest()!=copper['cad_sha256']:raise RuntimeError('Stale checked native CAD')
if hashlib.sha256((root/'docs/routing-input.json').read_bytes()).hexdigest()!=copper['input_sha256']:raise RuntimeError('Stale layout/net map')
env={**os.environ,'XDG_CONFIG_HOME':'/workspace/.config','XDG_DATA_HOME':'/workspace/.local/share','XDG_CACHE_HOME':'/workspace/.cache'}
def run(args):subprocess.run(args,cwd=root,env=env,check=True)
run(['kicad-cli','pcb','drc',str(source),'--output',str(out/'source-drc.txt'),'--exit-code-violations'])
run(['/usr/bin/python3',str(root/'scripts/audit-via-in-pad.py'),str(source),'--output',str(out/'via-in-pad-audit.json')])
run(['/usr/bin/python3',str(root/'scripts/audit-via-sizes.py'),str(source),'--output',str(out/'source-via-size-audit.json')])
catalog=json.loads((root/'docs/component-catalog.json').read_text());selection=json.loads((root/'docs/jlcpcb-import-selection.json').read_text())
references={p['reference']:p for p in catalog}
rendered=json.loads((root/'circuit.json').read_text())
if sum(e['type']=='pcb_component' for e in rendered)!=len(catalog) or sum(e['type']=='cad_component' for e in rendered)!=len(catalog):raise RuntimeError('Incomplete published components/CAD')
if sum(e['type']=='pcb_copper_pour' for e in rendered)<3:raise RuntimeError('Native power/ground planes missing from circuit.json')
board=pcbnew.LoadBoard(str(source));footprints=list(board.GetFootprints())
if len([f for f in footprints if f.GetReference() in references])!=len(catalog):raise RuntimeError('Native assembly count mismatch')
if any(f.GetLayer()!=pcbnew.F_Cu for f in footprints if f.GetReference() in references):raise RuntimeError('Assembly must be top-only')
if any(not isinstance(t,pcbnew.PCB_VIA) and t.GetLayer()==pcbnew.In1_Cu and t.GetNetname()!='GND' for t in board.GetTracks()):raise RuntimeError('Signal track on Layer 2 ground')
def copper_geometry_signature():
 records=[]
 for f in footprints:
  pads=list(f.Pads())
  for p in pads:
   if p.GetAttribute()==pcbnew.PAD_ATTRIB_NPTH:continue # Separate acoustic-port adaptation audit below.
   polygon=p.GetEffectivePolygon(pcbnew.F_Cu)
   outlines=[]
   for i in range(polygon.OutlineCount()):
    chain=polygon.COutline(i)
    outer=[[chain.CPoint(j).x,chain.CPoint(j).y] for j in range(chain.PointCount())]
    holes=[]
    for h in range(polygon.HoleCount(i)):
     inner=polygon.CHole(i,h)
     holes.append([[inner.CPoint(j).x,inner.CPoint(j).y] for j in range(inner.PointCount())])
    outlines.append([outer,holes])
   position=p.GetPosition();drill=p.GetDrillSize()
   records.append([p.m_Uuid.AsString(),p.GetNumber(),p.GetNetname(),[position.x,position.y],
                   [drill.x,drill.y],p.GetDrillShape(),
                   [p.GetLayerSet().Contains(layer) for layer in [pcbnew.F_Cu,pcbnew.In1_Cu,pcbnew.In2_Cu,pcbnew.B_Cu,pcbnew.F_Mask,pcbnew.B_Mask]],outlines])
 return hashlib.sha256(json.dumps(sorted(records),separators=(',',':')).encode()).hexdigest()
supplier_copper_signature=copper_geometry_signature()
# Preserve every electronic copper land and plated hole. Factory-only enlargement
# of the imported microphone acoustic NPTH ports meets the published 0.50 mm
# drilling minimum; the original supplier library/source footprints stay intact.
npth_adaptations=[]
for f in footprints:
 for p in list(f.Pads()):
  if p.GetAttribute()!=pcbnew.PAD_ATTRIB_NPTH:continue
  old=[pcbnew.ToMM(p.GetDrillSize().x),pcbnew.ToMM(p.GetDrillSize().y)]
  if min(old)>=.5:continue
  if f.GetReference() not in ['MIC1','MIC2'] or max(abs(x-.4) for x in old)>1e-6:raise RuntimeError('Unexpected undersized imported NPTH')
  at=p.GetPosition();npth_adaptations.append(dict(logical=f.GetReference(),at_native_mm=[pcbnew.ToMM(at.x),pcbnew.ToMM(at.y)],original_drill_mm=old,factory_drill_mm=[.5,.5],reason='JLCPCB published minimum non-plated hole diameter 0.50 mm'))
  p.SetDrillSize(pcbnew.VECTOR2I(pcbnew.FromMM(.5),pcbnew.FromMM(.5)));p.SetSize(pcbnew.VECTOR2I(pcbnew.FromMM(.5),pcbnew.FromMM(.5)))
if len(npth_adaptations)!=2:raise RuntimeError('Microphone acoustic-port adaptation count changed')
board.GetTitleBlock().SetTitle('Muse Voice Remote')
board.GetTitleBlock().SetRevision(version)
for via in list(board.GetTracks()):
 if isinstance(via,pcbnew.PCB_VIA):
  via.SetFrontTentingMode(pcbnew.TENTING_MODE_TENTED)
  via.SetBackTentingMode(pcbnew.TENTING_MODE_TENTED)
# Preserve U1 for the bare MCU; normalize the remaining factory designators.
refmap={'U1':'U1'};counts={'U':1}
for ref in sorted(references):
 if ref=='U1':continue
 prefix=ref.split('_')[0]
 if prefix=='LED':prefix='D'
 if prefix=='MIC':prefix='MIC'
 if re.fullmatch(r'MIC\d+',ref):prefix='MIC'
 if re.fullmatch(r'Y\d+',ref):prefix='Y'
 counts[prefix]=counts.get(prefix,0)+1;refmap[ref]=prefix+str(counts[prefix])
if len(set(refmap.values()))!=len(catalog):raise RuntimeError('Duplicate factory designator')
# Input and plot origin at the lower-left PCB corner, matching the JLC CPL.
board.GetDesignSettings().SetAuxOrigin(pcbnew.VECTOR2I(pcbnew.FromMM(-33),pcbnew.FromMM(24)))
positions=[];windows=[];thermal_holes=[]
for f in footprints:
 if f.GetReference()=='U1':
  for p in f.Pads():
   if p.GetAttribute()==pcbnew.PAD_ATTRIB_PTH and p.GetNumber()=='57':thermal_holes.append(dict(reference='U1',x_mm=pcbnew.ToMM(p.GetPosition().x)+33,y_mm=24-pcbnew.ToMM(p.GetPosition().y),drill_mm=pcbnew.ToMM(p.GetDrillSize().x)))
seeding=json.loads((root/'docs/power-plane-seeding.json').read_text())
for p in seeding['thermal_vias']:
 x,y=p['at_native_mm'];thermal_holes.append(dict(reference=refmap[p['reference']],x_mm=x+33,y_mm=24-y,drill_mm=p['drill_mm']))
if len(thermal_holes)!=19:raise RuntimeError('Exposed-pad filled-via map is incomplete')
for f in footprints:
 logical=f.GetReference()
 if logical not in references:continue
 positions.append(dict(logical=logical,reference=refmap[logical],x_mm=pcbnew.ToMM(f.GetPosition().x)+33,
                       y_mm=24-pcbnew.ToMM(f.GetPosition().y),rotation_deg=f.GetOrientationDegrees()%360,layer='Top'))
 # Keep electrical pads exactly imported; adjust only the factory paste artwork.
 for p in f.Pads():
  if p.GetAttribute()!=pcbnew.PAD_ATTRIB_SMD or logical not in ['U1','U_CHARGE','U_AMP','U_REG'] or p.GetNumber()!={'U1':'57','U_CHARGE':'17','U_AMP':'17','U_REG':'11'}[logical]:continue
  if not p.GetLayerSet().Contains(pcbnew.F_Paste):continue
  # Imported custom lands can have a tiny anchor and large polygon lobes.
  # Window the actual copper outline rather than the anchor's GetSize().
  effective=p.GetEffectivePolygon(pcbnew.F_Cu);polygons=[]
  for i in range(effective.OutlineCount()):
   chain=effective.COutline(i)
   outer=[(pcbnew.ToMM(chain.CPoint(j).x),pcbnew.ToMM(chain.CPoint(j).y)) for j in range(chain.PointCount())]
   holes=[]
   for h in range(effective.HoleCount(i)):
    inner=effective.CHole(i,h)
    holes.append([(pcbnew.ToMM(inner.CPoint(j).x),pcbnew.ToMM(inner.CPoint(j).y)) for j in range(inner.PointCount())])
   polygons.append(Polygon(outer,holes))
  land=unary_union(polygons);left,bottom,right,top=land.bounds
  width=right-left;height=top-bottom
  columns=3 if min(width,height)>=3.2 else 2;rows=columns;gap=.35 if columns==3 else .18
  target=.80 if logical=='U_REG' else .60 # TI DRC0010J calls for 80%.
  centre_x=(left+right)/2;centre_y=(bottom+top)/2
  cell_w=(width-(columns-1)*gap)/columns;cell_h=(height-(rows-1)*gap)/rows
  if cell_w<=0 or cell_h<=0:raise RuntimeError('Stencil grid does not fit imported land')
  def apertures(scale):
   result=[]
   for column in range(columns):
    for row in range(rows):
     x=centre_x+(column-(columns-1)/2)*(cell_w+gap)
     y=centre_y+(row-(rows-1)/2)*(cell_h+gap)
     clipped=land.intersection(box(x-cell_w*scale/2,y-cell_h*scale/2,x+cell_w*scale/2,y+cell_h*scale/2))
     if clipped.is_empty:continue
     result.extend([clipped] if clipped.geom_type=='Polygon' else list(clipped.geoms))
   return result
  if sum(g.area for g in apertures(1))/land.area<target:raise RuntimeError('Target paste coverage cannot fit imported land')
  lo,hi=0.,1.
  for iteration in range(50):
   mid=(lo+hi)/2
   if sum(g.area for g in apertures(mid))/land.area<target:lo=mid
   else:hi=mid
  artwork=apertures(hi)
  layers=p.GetLayerSet();layers.RemoveLayer(pcbnew.F_Paste);p.SetLayerSet(layers)
  measured_area=0
  for aperture in artwork:
   if aperture.interiors:raise RuntimeError('Stencil aperture contains unsupported holes')
   points=list(aperture.exterior.coords)[:-1]
   native_polygon=pcbnew.SHAPE_POLY_SET();native_polygon.NewOutline()
   for x,y in points:native_polygon.Append(pcbnew.FromMM(x),pcbnew.FromMM(y))
   shape=pcbnew.PCB_SHAPE(board);shape.SetShape(pcbnew.SHAPE_T_POLY);shape.SetPolyShape(native_polygon)
   shape.SetLayer(pcbnew.F_Paste);shape.SetFilled(True);shape.SetWidth(0);board.Add(shape)
   measured_area+=aperture.area
  coverage=measured_area/land.area
  if abs(coverage-target)>1e-6:raise RuntimeError('Stencil coverage mismatch')
  windows.append(dict(logical=logical,reference=refmap[logical],pad=p.GetNumber(),coverage=coverage,
                      target_coverage=target,columns=columns,rows=rows,apertures=len(artwork),
                      land_area_mm2=land.area,paste_area_mm2=measured_area,gap_mm=gap,
                      actual_imported_copper_outline_used=True))
 f.SetReference(refmap[logical])
if copper_geometry_signature()!=supplier_copper_signature:raise RuntimeError('Factory preparation changed imported electronic copper, drills or mask layers')
factory=out/'MuseVoiceRemote-factory.kicad_pcb';pcbnew.SaveBoard(str(factory),board)
# Restore checked rules after pcbnew serialization, which can otherwise create defaults.
shutil.copyfile(root/'hardware/MuseVoiceRemote.kicad_pro',out/'MuseVoiceRemote-factory.kicad_pro')
run(['/usr/bin/python3',str(root/'scripts/fill-ground-plane.py'),str(factory)])
run(['/usr/bin/python3',str(root/'scripts/audit-via-sizes.py'),str(factory),'--output',str(out/'factory-via-size-audit.json')])
run(['kicad-cli','pcb','drc',str(factory),'--output',str(out/'factory-drc.txt'),'--exit-code-violations'])
run(['kicad-cli','pcb','export','gerbers',str(factory),'--layers','F.Cu,In1.Cu,In2.Cu,B.Cu,F.Mask,B.Mask,F.SilkS,B.SilkS,Edge.Cuts,F.Paste,B.Paste','--use-drill-file-origin','--subtract-soldermask','--output',str(out/'gerbers')+'/'])
run(['kicad-cli','pcb','export','drill',str(factory),'--output',str(out/'drill')+'/','--drill-origin','plot','--excellon-units','mm','--excellon-separate-th','--excellon-oval-format','route','--generate-map','--map-format','svg'])
run(['kicad-cli','pcb','export','pos',str(factory),'--output',str(out/'KiCad-top-positions.csv'),'--format','csv','--units','mm','--side','front','--smd-only','--use-drill-file-origin'])
with (out/'BOM-JLCPCB.csv').open('w',newline='') as fd:
 w=csv.writer(fd);w.writerow(['Comment','Designator','Footprint','LCSC Part #'])
 for s in selection['parts']:
  raw=json.loads((root/s['raw_response']).read_text())
  supplier=next(p for p in raw['data']['componentPageInfo']['list'] if p['componentCode']==s['lcsc'])
  w.writerow([s['mpn'],','.join(refmap[r] for r in s['references']),supplier.get('componentSpecificationEn') or s.get('package') or s['lcsc'],s['lcsc']])
with (out/'CPL-JLCPCB.csv').open('w',newline='') as fd:
 w=csv.writer(fd);w.writerow(['Designator','Mid X','Mid Y','Rotation','Layer'])
 for p in sorted(positions,key=lambda p:p['reference']):w.writerow([p['reference'],f"{p['x_mm']:.6f}mm",f"{p['y_mm']:.6f}mm",f"{p['rotation_deg']:.6f}",'Top'])
with (out/'thermal-via-fill.csv').open('w',newline='') as fd:
 w=csv.DictWriter(fd,fieldnames=['reference','x_mm','y_mm','drill_mm']);w.writeheader();w.writerows(thermal_holes)
(out/'reference-map.json').write_text(json.dumps(dict(logical_to_factory=refmap,placements=positions,stencil_windows=windows),indent=2)+'\n')
if len(positions)!=len(catalog):raise RuntimeError('CPL count mismatch')
with (out/'KiCad-top-positions.csv').open() as fd:actual=list(csv.DictReader(fd))
if len(actual)!=len(catalog):raise RuntimeError('Native positions export dropped populated components')
expected={p['reference']:p for p in positions}
for p in actual:
 item=expected[p['Ref']]
 if abs(float(p['PosX'])-item['x_mm'])>1e-5 or abs(float(p['PosY'])-item['y_mm'])>1e-5:raise RuntimeError('Origin or placement mismatch')
 if abs((float(p['Rot'])-item['rotation_deg']+180)%360-180)>1e-5:raise RuntimeError('Rotation mismatch')
 if p['Side']!='top':raise RuntimeError('Incorrect assembly side')
for suffix in ['gtl','g1','g2','gbl','gts','gbs','gto','gbo','gm1','gtp','gbp']:
 candidates=list((out/'gerbers').glob('*.'+suffix))
 if len(candidates)!=1 or 'M02*' not in candidates[0].read_text():raise RuntimeError('Missing or incomplete Gerber '+suffix)
drills=list((out/'drill').glob('*.drl'))
if len(drills)!=2 or any('METRIC' not in f.read_text() or 'M30' not in f.read_text() for f in drills):raise RuntimeError('Incomplete metric PTH/NPTH drilling')
notes=root/'docs/fabrication-notes.md'
if not notes.exists():raise RuntimeError('Fabrication profile is missing')
shutil.copyfile(notes,out/'FABRICATION-NOTES.md')
report=dict(version=version,checked_at=datetime.datetime.now(datetime.timezone.utc).isoformat(),release_scope='prototype PCB/PCBA artwork',populated_top_components=len(catalog),unique_supplier_imports=len(selection['parts']),native_drc='0 violations / 0 unconnected / 0 footprint errors',supplier_copper_geometry_sha256=supplier_copper_signature,imported_electronic_copper_plated_drills_and_mask_layers_preserved=True,factory_npth_acoustic_port_adaptations=npth_adaptations,minimum_via_drill_mm=.3,minimum_via_pad_mm=.45,minimum_radial_annulus_mm=.075,circuit_json_has_native_filled_planes=True,stencil_windows=windows,device_bench_tested=False,muse_service_verified=False,impedance_coupon_measured=False,filled_capped_thermal_holes=len(thermal_holes),source_native_sha256=hashlib.sha256(source.read_bytes()).hexdigest(),factory_native_sha256=hashlib.sha256(factory.read_bytes()).hexdigest())
(out/'verification.json').write_text(json.dumps(report,indent=2)+'\n')
gerber_archive=out/f'MuseVoiceRemote-Gerbers-JLCPCB-v{version}.zip'
with zipfile.ZipFile(gerber_archive,'w',compression=zipfile.ZIP_DEFLATED,compresslevel=9) as z:
 for p in sorted(list((out/'gerbers').glob('*'))+drills):
  if p.is_file():z.write(p,p.name)
with zipfile.ZipFile(gerber_archive) as z:
 if z.testzip() is not None:raise RuntimeError('Gerber ZIP integrity failure')
files=[p for p in out.rglob('*') if p.is_file() and p.suffix not in ['.lck','.kicad_prl'] and (p.suffix!='.zip' or p==gerber_archive) and p.name!='SHA256SUMS']
(out/'SHA256SUMS').write_text(''.join(hashlib.sha256(p.read_bytes()).hexdigest()+'  '+str(p.relative_to(out))+'\n' for p in sorted(files)))
archive=out/f'MuseVoiceRemote-fabrication-v{version}.zip'
with zipfile.ZipFile(archive,'w',compression=zipfile.ZIP_DEFLATED,compresslevel=9) as z:
 for p in sorted(out.rglob('*')):
  if p.is_file() and p.suffix not in ['.lck','.kicad_prl'] and (p.suffix!='.zip' or p==gerber_archive):z.write(p,str(p.relative_to(out)))
with zipfile.ZipFile(archive) as z:
 if z.testzip() is not None:raise RuntimeError('ZIP integrity failure')
print(f'Checked prototype handoff: {len(catalog)} top parts; {len(selection["parts"])} imports; {archive.name}',flush=True)
os._exit(0)