pixalynx/cm5-minimal-carrier

A 4-layer Raspberry Pi CM5 carrier board routes USB‑C 5V power through an RT9742 load switch with TVS protection and capacitive filtering, while providing ESD-protected HDMI output, CM5 connectors, test points, and ground/power planes.

Version
0.1.1
License
unset
Stars
0

scripts/validate.ts

import design from '../lib/design.json'
import fp from '../lib/footprints.json'
import pins from '../lib/cm5-pinout.json'
import routes from '../lib/routes.json'
import manufacturing from '../lib/manufacturing.json'
import stackup from '../lib/stackup.json'
import { strict as assert } from 'node:assert'
import {createHash} from 'node:crypto'
import { physicalPin } from '../lib/footprints'
const checks:string[]=[]
const check=(name:string,fn:()=>void)=>{fn();checks.push(name)}
const parts=new Map(design.parts.map(p=>[p.ref,p]))
const net=(ref:string,pin:number)=> (parts.get(ref)!.nets as Record<string,string|undefined>)[pin]
check('200 pin assignments verified from current official CM5 table 4',()=>assert.equal(Object.keys(pins).length,200))
const verified=await Bun.file('references/pinout-verification.json').json()
check('Official PDF extraction matches all 200 names',()=>{assert.equal(verified.pinsVerified,200);assert.deepEqual(verified.differences,[])})
check('Two distinct 100-contact footprints; 34 mm centre spacing',()=>{
 assert.equal(fp['cm5-1'].pads.length,100);assert.equal(fp['cm5-2'].pads.length,100)
 assert.equal(parts.get('J2')!.x-parts.get('J1')!.x,34)
 for(const pad of fp.cm5.pads){const n=Number(pad.pin),s=n>100?2:1;const p=fp[`cm5-${s}` as 'cm5-1'|'cm5-2'].pads.find(p=>Number(p.pin)===n-(s-1)*100)!;assert.ok(Math.abs(p.x+(s===1?-17:17)-pad.x)<1e-6);assert.ok(Math.abs(p.y-2.5-pad.y)<1e-6)}
})
check('All CM5 ground pins and all six 5 V pins connected',()=>{
 for(const [n,s] of Object.entries(pins))if(s==='GND')assert.equal(net(Number(n)<=100?'J1':'J2',((Number(n)-1)%100)+1),'GND')
 for(const n of [77,79,81,83,85,87])assert.equal(net('J1',n),'CM5_5V')
})
check('Mandatory GPIO_VREF supplied by CM5 3.3 V; control defaults are NC',()=>{
 for(const n of [78,84,86])assert.equal(net('J1',n),'CM5_3V3')
 for(const n of [20,73,75,76,88,89,90,91,92,93,95,99])assert.equal(net('J1',n),undefined)
})
check('CM5 internal PD is wired directly to separate CC contacts, with no external Rd',()=>{
 assert.equal(net('J1',94),net('J3',5));assert.equal(net('J1',96),net('J3',17));assert.notEqual(net('J3',5),net('J3',17));assert.ok(!design.parts.some(p=>p.ref.startsWith('R')))
 for(const n of [6,7,8,18,19,20])assert.equal(net('J3',n),undefined)
})
check('All four physical VBUS and GND contacts join their shared GCT lands',()=>{
 for(const n of ['A4','A9','B4','B9'])assert.equal(net('J3',Number(physicalPin('usb-c',n))),'CM5_5V')
 for(const n of ['A1','A12','B1','B12'])assert.equal(net('J3',Number(physicalPin('usb-c',n))),'GND')
})
const mapping=[[170,1],[172,3],[176,4],[178,6],[182,7],[184,9],[188,10],[190,12],[151,13],[153,19],[199,16],[200,15]]
check('HDMI0 TMDS polarity, clock, CEC, HPD, SCL and SDA match official pinout',()=>{for(const [cm,h] of mapping)assert.equal(net('J2',cm-100),net('J4',h));assert.equal(net('J4',14),undefined);assert.equal(net('J4',18),'HDMI_5V')})
check('TVS cathode faces VBUS and HDMI output uses reference current-limit switch',()=>{assert.equal(net('D1',1),'CM5_5V');assert.equal(net('D1',2),'GND');assert.equal(net('U1',1),'CM5_5V');assert.equal(net('U1',2),'HDMI_5V');assert.equal(net('U1',3),'GND')})
check('ESD NC land pairs are not declared internally connected; copper bridges every channel',()=>{
 for(const ref of ['U2','U3','U4'])for(const n of [1,2,4,5])assert.equal(net(ref,n),net(ref,11-n))
})
const length=(r:typeof routes[number])=>r.path.slice(1).reduce((s,b,i)=>s+Math.hypot(b.x-r.path[i].x,b.y-r.path[i].y),0)
const pairs=['TX2','TX1','TX0','CLK'].map((lane,i)=>({lane,p:length(routes[i*2]),n:length(routes[i*2+1]),skew:Math.abs(length(routes[i*2])-length(routes[i*2+1]))}))
check('All four HDMI pairs: skew <= 0.15 mm; total inter-pair difference <= 25 mm; zero vias',()=>{
 for(const p of pairs)assert.ok(p.skew<=.15)
 assert.ok(Math.max(...pairs.map(p=>p.p))-Math.min(...pairs.map(p=>p.p))<=25)
 for(const r of routes.slice(0,8)){assert.ok(!r.path.some(p=>'via' in p));assert.equal(r.width,stackup.traceWidthMm)}
})
const circuit:any[]=await Bun.file('dist/index/circuit.json').json()
const errors=circuit.filter(e=>e.type.endsWith('_error'))
check('tsCircuit emitted no errors',()=>assert.deepEqual(errors,[]))
check('All 15 placed components have 3D body models with usable materials',()=>{
 const ids=new Set(circuit.filter(e=>e.type==='source_component' && parts.has(e.name)).map(e=>e.source_component_id))
 const placed=circuit.filter(e=>e.type==='pcb_component' && ids.has(e.source_component_id))
 const bodies=circuit.filter(e=>e.type==='cad_component')
 assert.equal(placed.length,15);assert.equal(bodies.length,16)
 for(const part of placed){
  const body=bodies.find(e=>e.pcb_component_id===part.pcb_component_id && !e.model_glb_url)
  assert.ok(body && !body.show_as_bounding_box)
  const name=circuit.find(e=>e.type==='source_component' && e.source_component_id===part.source_component_id).name
  if(name==='U1'||name==='D1'){
   assert.ok(body.model_obj_url.includes(name==='U1'?'RT9742SNGV.obj':'SMAJ5_0A_13_F.obj'))
   assert.ok(body.model_step_url.includes(name==='U1'?'RT9742SNGV.step':'SMAJ5_0A_13_F.step'))
   assert.equal(body.rotation.z,name==='U1'?270:0)
  }else{
   assert.ok(body.model_obj_url.startsWith('data:'))
   const obj=Buffer.from(body.model_obj_url.split(',')[1],'base64').toString()
   const materials=new Set([...obj.matchAll(/^newmtl (.+)$/gm)].map(m=>m[1]))
   assert.ok(materials.size>0 && obj.includes('endmtl'))
   for(const m of obj.matchAll(/^usemtl (.+)$/gm))assert.ok(materials.has(m[1]),`Missing material ${name}/${m[1]}`)
  }
 }
})
check('CM5 3D module is aligned over the sockets at 1.5 mm mating height',()=>{
 const modules=circuit.filter(e=>e.type==='cad_component' && e.model_glb_url)
 assert.equal(modules.length,1)
 const module=modules[0]
 assert.ok(module.model_glb_url.includes('cm5-module'))
 assert.deepEqual(module.position,{x:-10,y:0,z:2.3})
 assert.deepEqual(module.rotation,{x:0,y:0,z:0})
 assert.deepEqual(module.model_origin_position,{x:0,y:0,z:0})
 assert.equal(module.model_unit_to_mm_scale_factor,1)
 const parent=circuit.find(e=>e.type==='source_component' && e.source_component_id===module.source_component_id)
 assert.equal(parent.name,'J1')
})
// Confirm the emitted netlist, not merely the source intent.
const components=new Map(circuit.filter(e=>e.type==='source_component').map(e=>[e.name,e.source_component_id]))
const nets=new Map(circuit.filter(e=>e.type==='source_net').map(e=>[e.name,e.subcircuit_connectivity_map_key]))
check('Six bare probe pads connect only to power/ground and have no paste or component bodies',()=>{
 for(const tp of manufacturing.testPoints){
  const source=components.get(tp.ref)
  const port=circuit.find(e=>e.type==='source_port' && e.source_component_id===source)
  assert.equal(port.subcircuit_connectivity_map_key,nets.get(tp.net))
  const pcb=circuit.find(e=>e.type==='pcb_component' && e.source_component_id===source)
  assert.equal(pcb.layer,'bottom')
  const pad=circuit.find(e=>e.type==='pcb_smtpad' && e.pcb_component_id===pcb.pcb_component_id)
  assert.equal(pad.shape,'circle');assert.equal(pad.radius,tp.diameter/2)
  assert.equal(pad.x,tp.x);assert.equal(pad.y,tp.y)
  assert.ok(!circuit.some(e=>e.type==='cad_component' && e.source_component_id===source))
  assert.ok(!circuit.some(e=>e.type==='pcb_solder_paste' && e.pcb_smtpad_id===pad.pcb_smtpad_id && (e.radius>0 || e.width>0)))
 }
})
check('Every intended source pin maps to the correct emitted connectivity net',()=>{
 for(const p of design.parts)for(const [pin,n] of Object.entries(p.nets)){
  const ports=circuit.filter(e=>e.type==='source_port'&&e.source_component_id===components.get(p.ref)&&(e.pin_number===Number(pin)||e.port_hints?.includes(`pin${pin}`)))
  assert.ok(ports.length,`${p.ref}.${pin} missing`);assert.ok(ports.some(e=>e.subcircuit_connectivity_map_key===nets.get(n)),`${p.ref}.${pin} incorrect net`)
 }
})
check('Emitted diode polarity identifies pin 1 as cathode',()=>{
 const p=circuit.find(e=>e.type==='source_port'&&e.source_component_id===components.get('D1')&&e.pin_number===1)
 assert.ok(p.name==='cathode'||p.port_hints?.includes('cathode'))
})
const drc=await Bun.file('output/drc.json').json()
check('Independent KiCad DRC: zero violations and zero unconnected items',()=>{assert.equal(drc.violations.length,0);assert.equal(drc.unconnected_items.length,0)})
const plane=await Bun.file('output/plane-audit.json').json()
check('Filled L2 plane is connected and covers every HDMI trace centre/edge sample',()=>{assert.equal(plane.groundPlaneIslands,1);assert.deepEqual(plane.uncoveredSamples,[]);assert.ok(plane.samples>1000)})
const awaitPdf=await Bun.file('references/cm5-datasheet.pdf').arrayBuffer()
check('Official CM5 PDF snapshot hash matches extraction record',()=>assert.equal(createHash('sha256').update(new Uint8Array(awaitPdf)).digest('hex'),verified.sha256))
// The build runs the electrical checks before CAM export, then runs this full
// check again after export. Normal `bun run check` must reject stale artifacts.
if(!process.argv.includes('--design-only')){
 const cam=await Bun.file('output/manufacturing-audit.json').json()
 const mismatches:string[]=[]
 for(const [path,digest] of Object.entries(cam.sha256)){
  const bytes=await Bun.file(path).arrayBuffer()
  if(createHash('sha256').update(new Uint8Array(bytes)).digest('hex')!==digest)mismatches.push(path)
 }
 check('Manufacturing audit hashes match the current circuit, board, DRC and CAM files',()=>assert.deepEqual(mismatches,[]))
}
const report={plane,status:'DESIGN CHECKS PASS / FABRICATION RELEASE PENDING',checks,hdmiPairs:pairs,tscircuitWarnings:circuit.filter(e=>e.type.endsWith('_warning')),kicad:{violations:drc.violations.length,unconnected:drc.unconnected_items.length},remaining:['JLC controlled impedance approval','Assembly rotation and sourcing review','USB-C PD/inrush/load bench validation','Connector current/temperature qualification','HDMI and thermal prototype validation']}
await Bun.write('output/validation.json',JSON.stringify(report,null,2))
console.log(`${checks.length} checks passed. KiCad DRC 0 violations / 0 unconnected. Release pending physical validation.`)