mohan-bee/mini-mp3-player

Portable, battery-powered MP3 player hardware with USB charging, microSD storage, an MCU, VS10xx audio decoder, headphone output, status controls, and regulated power rails.

Version
1.2.0
License
unset
Stars
0

scripts/release.mjs

import './audit.mjs'
import fs from 'node:fs'
import path from 'node:path'
import assert from 'node:assert/strict'
import {execFileSync} from 'node:child_process'
import JSZip from 'jszip'
import {convertCircuitJsonToPickAndPlaceCsv} from 'circuit-json-to-pnp-csv'

const circuit = JSON.parse(fs.readFileSync('dist/index/circuit.json','utf8'))
assert(!circuit.some(e => e.type.endsWith('_error')), 'Resolve all DRC errors before export')
const sources = new Map(circuit.filter(e => e.type === 'source_component').map(c => [c.source_component_id,c]))
const fitted = circuit.filter(e => e.type === 'pcb_component' && !e.do_not_place && sources.has(e.source_component_id))
const ref = name => fitted.find(c => sources.get(c.source_component_id).name === name)

// These asymmetrical connectors have no conventional pin-1 edge classification.
// Verify their pad coordinates against the exact LCSC supplier footprints instead.
function checkConnector(name, code, expected) {
 const c = ref(name)
 assert.equal(sources.get(c.source_component_id).supplier_part_numbers.jlcpcb[0],code)
 const angle = -c.rotation*Math.PI/180
 const origin = circuit.find(e => e.type === 'pcb_smtpad' && e.pcb_component_id === c.pcb_component_id && e.port_hints.includes(`pin${expected[0][0]}`))
 assert(origin)
 for (const [pin,x,y] of expected) {
  const p = circuit.find(e => e.type === 'pcb_smtpad' && e.pcb_component_id === c.pcb_component_id && e.port_hints.includes(`pin${pin}`))
  assert(p,`${name} pad ${pin} missing`)
  const dx = p.x-origin.x, dy=p.y-origin.y
  assert(Math.abs(dx*Math.cos(angle)-dy*Math.sin(angle)-(x-expected[0][1]))<0.002,`${name}.${pin} X orientation mismatch`)
  assert(Math.abs(dx*Math.sin(angle)+dy*Math.cos(angle)-(y-expected[0][2]))<0.002,`${name}.${pin} Y orientation mismatch`)
 }
}
checkConnector('J1','C2765186',[-3.2,-2.4,-1.75,-1.25,-0.75,-0.25,0.25,0.75,1.25,1.75,2.4,3.2].map((x,i)=>[i+15,x,2.12502755]))
checkConnector('J4','C431535',[[1,4.82495225,-3.499993],[2,-3.57508175,3.499993],[3,-0.57508775,3.499993],[4,3.42490425,3.499993]])
const orientationNotes=[]
const cpl = convertCircuitJsonToPickAndPlaceCsv(circuit,{
 supplier:'jlcpcb',
 onRotationWarning(w) {
  assert(['J1','J4'].includes(w.designator),w.message)
  orientationNotes.push(`${w.designator}: rotation verified against exact supplier pad geometry; using authored PCB rotation.`)
 }
})

const packages={U1:'VQFN-16-EP',U2:'SOT-23-5',U3:'SOT-23-3',U4:'SOT-23-5',U5:'LQFP-48',U6:'LQFP-48',J1:'USB-C-16P',J2:'JST-PH-3-SMT',J3:'TF-01A',J4:'PJ-320D',C43:'D6.3mm-SMD',C44:'D6.3mm-SMD',Q1:'SOT-23',Q2:'SOT-23',Y1:'3225-4P',Y2:'3225-4P',D1:'LED-0603',D2:'LED-0805',SW7:'MSK12C02'}
const groups = new Map()
for (const c of fitted) {
 const s=sources.get(c.source_component_id)
 if (s.ftype==='simple_test_point') continue
 const code=s.supplier_part_numbers.jlcpcb[0]
 const value=s.display_resistance || s.display_capacitance || s.manufacturer_part_number || code
 const footprint=packages[s.name] || (s.name.startsWith('SW')?'TS-1088':s.capacitance===10e-6?'0805':'0603')
 const key=`${code}:${value}:${footprint}`
 if(!groups.has(key)) groups.set(key,{value,footprint,code,refs:[]})
 groups.get(key).refs.push(s.name)
}
const cell = s => '"'+String(s).replaceAll('"','""')+'"'
const bom = [['Comment','Designator','Footprint','LCSC Part #'],...Array.from(groups.values()).map(g=>[g.value,g.refs.join(','),g.footprint,g.code])].map(row=>row.map(cell).join(',')).join('\r\n')+'\r\n'
fs.mkdirSync('manufacturing',{recursive:true})
// The installed core omits paste for pill pads and emits paste on test pads.
// Build an explicit top stencil without changing copper, drills or connectivity.
const fittedIds = new Set(fitted.map(c=>c.pcb_component_id))
const pads = circuit.filter(e=>e.type==='pcb_smtpad' && fittedIds.has(e.pcb_component_id) && !e.is_covered_with_solder_mask)
const paste=[]
function aperture(p,geometry) {
 paste.push({type:'pcb_solder_paste',pcb_solder_paste_id:`release_paste_${paste.length}`,layer:'top',x:p.x,y:p.y,pcb_component_id:p.pcb_component_id,pcb_smtpad_id:p.pcb_smtpad_id,subcircuit_id:p.subcircuit_id,...geometry})
}
for(const p of pads) {
 assert.equal(p.layer,'top')
 const component=fitted.find(c=>c.pcb_component_id===p.pcb_component_id)
 const isThermalPad=sources.get(component.source_component_id).name==='U1' && p.width>1 && p.height>1
 if(isThermalPad) {
  // Four 0.65 mm windows provide 58.5% coverage of the 1.7 mm exposed pad.
  for(const dx of [-0.4,0.4]) for(const dy of [-0.4,0.4]) aperture(p,{shape:'rect',x:p.x+dx,y:p.y+dy,width:0.65,height:0.65})
 } else if(p.shape==='pill' || p.shape==='rotated_pill') {
  const short=Math.min(p.width,p.height)
  const width=p.width<=p.height?p.width*0.85:p.width-short*0.5
  const height=p.height<=p.width?p.height*0.85:p.height-short*0.5
  aperture(p,{shape:p.shape==='rotated_pill'?'rotated_rect':'rect',width,height,...(p.shape==='rotated_pill'?{ccw_rotation:p.ccw_rotation}: {})})
 } else if(p.shape==='rect' || p.shape==='rotated_rect') {
  aperture(p,{shape:p.shape,width:p.width*0.9,height:p.height*0.9,...(p.shape==='rotated_rect'?{ccw_rotation:p.ccw_rotation}: {})})
 } else if(p.shape==='circle') {
  aperture(p,{shape:'circle',radius:p.radius*0.9})
 } else throw new Error(`Unreviewed stencil pad shape: ${p.shape}`)
}
assert(pads.every(p=>paste.some(s=>s.pcb_smtpad_id===p.pcb_smtpad_id)))
const releaseCircuit = circuit.filter(e=>e.type!=='pcb_solder_paste' || (e.layer==='top' && !e.pcb_smtpad_id))
releaseCircuit.push(...paste)
fs.writeFileSync('manufacturing/release.circuit.json',JSON.stringify(releaseCircuit,null,2)+'\n')
fs.writeFileSync('validation/stencil.json',JSON.stringify({fittedSmdPads:pads.length,generatedSmdApertures:paste.length,bottomPasteApertures:0,testPadPasteApertures:0,thermalPadCoveragePercent:58.5},null,2)+'\n')
const cli='node_modules/.bin/tsci'
execFileSync(cli,['export','manufacturing/release.circuit.json','--format','gerbers','--output',path.resolve('manufacturing/mini-mp3-revB.zip')],{stdio:'inherit'})
const zip=await JSZip.loadAsync(fs.readFileSync('manufacturing/mini-mp3-revB.zip'))
zip.remove('bom.csv');zip.remove('pick_and_place.csv')
zip.remove('B_Paste.gbr')
zip.file('bom.csv',bom);zip.file('pick_and_place.csv',cpl)
fs.writeFileSync('manufacturing/bom.csv',bom)
fs.writeFileSync('manufacturing/pick_and_place.csv',cpl)
fs.writeFileSync('manufacturing/mini-mp3-revB.zip',await zip.generateAsync({type:'nodebuffer'}))
fs.writeFileSync('validation/assembly-orientation.txt',orientationNotes.join('\n')+'\n')
for (const [format,file] of [['schematic-svg','schematic.svg'],['pcb-svg','pcb.svg'],['assembly-svg','assembly.svg'],['readable-netlist','netlist.txt']]) {
 execFileSync(cli,['export','dist/index/circuit.json','--format',format,'--output',path.resolve(`dist/index/${file}`)],{stdio:'inherit'})
}
console.log(`Exported ${groups.size} BOM lines, ${fitted.filter(c=>sources.get(c.source_component_id).ftype!=='simple_test_point').length} fitted parts`)