imrishabh18/nema-23-stepper-controller

A 57 mm four-layer RP2040 NEMA23 stepper-controller PCB with USB‑C/USB‑PD power input, protected motor rail, DRV8452 dual-winding driver, bidirectional current sensing, temperature/enable interlock, optional magnetic encoder, RGB LED and buzzer status indicators, and motor/test connectors.

Version
1.2.3
License
unset
Stars
0

scripts/verify-built.mjs

import { readFileSync } from 'node:fs'
import assert from 'node:assert/strict'
const c = JSON.parse(readFileSync(new URL('../dist/index/circuit.json', import.meta.url), 'utf8'))
const of = type => c.filter(e=>e.type===type)
const errors = c.filter(e=>e.type.endsWith('_error') || e.type==='error')
assert.equal(errors.length, 0, JSON.stringify(errors.map(e=>({type:e.type,message:e.message})),null,2))
assert.ok(of('pcb_trace').length > 100, 'Only partial routing is present')
const parent = new Map()
function root(x) { if(!parent.has(x))parent.set(x,x); if(parent.get(x)!==x)parent.set(x,root(parent.get(x))); return parent.get(x) }
for(const t of of('source_trace')) {
  const ids=[...(t.connected_source_port_ids??[]),...(t.connected_source_net_ids??[])]
  for(const id of ids.slice(1)) parent.set(root(id),root(ids[0]))
}
function port(name,pin) {
  const component=of('source_component').find(e=>e.name===name)
  assert.ok(component, 'Missing component '+name)
  const p=of('source_port').find(e=>e.source_component_id===component.source_component_id&&e.pin_number===pin)
  assert.ok(p,`Missing ${name}.${pin}`);return p.source_port_id
}
function net(name) { const n=of('source_net').find(e=>e.name===name);assert.ok(n,'Missing net '+name);return n.source_net_id }
function on(name,pin,n) { assert.equal(root(port(name,pin)),root(net(n)),`${name}.${pin} must be on ${n}`) }
assert.equal(of('source_component').find(e=>e.name==='DRIVER').manufacturer_part_number,'DRV8452DDWR')
assert.equal(of('source_component').find(e=>e.name==='D_TVS').manufacturer_part_number,'SMBJ33A')
const phaseGroups={A_DRIVE:[4,5,6],A_MINUS:[7,8,9],B_MINUS:[14,15,16],B_DRIVE:[17,18,19]}
for(const [n,pins] of Object.entries(phaseGroups)) for(const p of pins) on('DRIVER',p,n)
for(const p of [2,11,12,21])on('DRIVER',p,'VM')
for(const p of [3,10,13,20,22,23,45])on('DRIVER',p,'GND')
for(const [p,n] of [[25,'V3V3'],[28,'V3V3'],[33,'VREF'],[34,'SPI_CS'],[36,'SPI_MISO'],[37,'SPI_MOSI'],[38,'SPI_SCK'],[39,'STEP'],[40,'DIR'],[41,'ENABLE_SAFE'],[42,'WAKE']])on('DRIVER',p,n)
on('U_GATE',1,'ARM');on('U_GATE',2,'TEMP_OK');on('U_GATE',4,'ENABLE_SAFE')
on('U_TEMP',3,'TEMP_OK');on('U_TEMP',4,'GND');on('U_TEMP',5,'V3V3')
on('J_MOTOR',1,'A_PLUS');on('J_MOTOR',2,'A_MINUS');on('J_MOTOR',3,'B_PLUS');on('J_MOTOR',4,'B_MINUS')
assert.ok(!of('source_component').some(e=>e.name==='J_A'||e.name==='J_B'), 'Old motor terminals remain')
assert.equal(of('source_component').find(e=>e.name==='J_MOTOR').manufacturer_part_number,'B4B-XH-A(LF)(SN)')
assert.ok(!of('source_component').some(e=>e.name==='J_POWER'), 'Power screw terminal remains')
assert.equal(of('source_component').find(e=>e.name==='J_USB_POWER').manufacturer_part_number,'USB4105-GF-A')
for(const [name,count,shellPins] of [['J_USB',16,[13,14,15,16]],['J_USB_POWER',20,[1,2,3,4]]]) {
  const component=of('source_component').find(e=>e.name===name)
  assert.equal(component.ftype,'simple_connector');assert.equal(component.standard,'usb_c')
  const pins=of('source_port').filter(e=>e.source_component_id===component.source_component_id)
  assert.equal(pins.length,count)
  for(const pin of pins){
    const schematic=of('schematic_port').find(e=>e.source_port_id===pin.source_port_id)
    assert.ok(schematic,`${name}.${pin.pin_number} missing from schematic`)
    assert.equal(schematic.side_of_component,shellPins.includes(pin.pin_number)?'bottom':'right',`${name}.${pin.pin_number}: standard USB-C pin grouping`)
  }
  const schematic=of('schematic_component').find(e=>e.source_component_id===component.source_component_id)
  assert.notEqual(schematic.is_box_with_pins,false,`${name}: native connector box must be enabled`)
  assert.ok(!schematic.schematic_symbol_id&&!schematic.symbol_name,`${name}: custom USB-C symbol remains`)
  assert.deepEqual(schematic.port_arrangement.bottom_side.pins,shellPins)
  assert.equal(schematic.port_arrangement.right_side.pins.length,count-shellPins.length)
  assert.ok(c.some(e=>['schematic_path','schematic_line','schematic_rect','schematic_circle'].includes(e.type)&&e.schematic_component_id===schematic.schematic_component_id),`${name} must include the native USB-C illustration`)
}
for(const p of [13,14,15,16,17,28])on('J_USB',p,'GND')
for(const p of [7,8,17,18])on('J_USB_POWER',p,'VIN')
for(const p of [1,2,3,4,5,6,19,20])on('J_USB_POWER',p,'GND')
on('J_USB_POWER',15,'PD_CC1');on('J_USB_POWER',9,'PD_CC2')
on('U_PD',7,'PD_CC1');on('U_PD',6,'PD_CC2');on('U_PD',11,'GND')
on('U_PD',1,'VIN');on('U_PD',8,'VIN');on('U_PD',9,'PD_CFG');on('U_PD',10,'PD_PG_N')
on('F1',1,'VIN');on('F1',2,'VFUSED')
for(const p of [1,2,5])on('U_EFUSE',p,'VFUSED')
for(const p of [17,18])on('U_EFUSE',p,'VSWITCHED')
on('U_EFUSE',8,'GND');on('U_EFUSE',25,'GND')
on('U_EFUSE',6,'UVLO');on('U_EFUSE',7,'OVP');on('U_EFUSE',9,'SLEW');on('U_EFUSE',10,'ILIM')
on('U_EFUSE',12,'POWER_ARM');on('U_EFUSE',14,'POWER_FAULT_N');on('U_EFUSE',15,'PGTH');on('U_EFUSE',16,'TEMP_OK')
on('U1',2,'POWER_ARM');on('U1',3,'PD_PG_N');on('U1',4,'POWER_FAULT_N')
on('C_BULK',1,'VM');on('C_BULK',2,'GND')
for(const [name,ohms] of [['R_PD_CFG',210000],['R_UV_TOP',51000],['R_UV_BOT',10000],['R_OV_TOP',243000],['R_OV_BOT',10000],['R_ILIM',7320],['R_PWR_ARM',10000],['R_REF_TOP',2610],['R_REF_BOT',10000]]) {
  assert.equal(of('source_component').find(e=>e.name===name).resistance,ohms,`${name} setting changed`)
}
on('D_VBUS',1,'VSYS');on('D_VBUS',2,'VBUS')
on('D_REVERSE',2,'VSWITCHED');on('D_REVERSE',1,'VM')
on('D_TVS',2,'GND');on('D_TVS',1,'VM')
for(const name of ['D_PWR','D_REVERSE','D_TVS']) {
  const p=of('source_port').find(e=>e.source_port_id===port(name,1))
  assert.ok(p.port_hints.includes('cathode'),`${name} supplier pin 1 must be cathode`)
}
const separated=['GND','VM','VIN','VFUSED','VSWITCHED','VBUS','VSYS','V3V3','DVDD','VREF','A_DRIVE','A_PLUS','A_MINUS','B_DRIVE','B_PLUS','B_MINUS','ARM','TEMP_OK','ENABLE_SAFE','POWER_ARM','PD_PG_N','POWER_FAULT_N','PD_CC1','PD_CC2']
assert.equal(new Set(separated.map(n=>root(net(n)))).size,separated.length,'Power, phase or interlock nets have merged')
const holes=of('pcb_hole').filter(h=>h.pcb_component_id==null)
assert.equal(of('pcb_board')[0].width,57);assert.equal(of('pcb_board')[0].height,57)
assert.equal(holes.length,4)
for(const h of holes) {
  assert.ok(Math.abs(Math.abs(h.x)-23.57)<1e-4 && Math.abs(Math.abs(h.y)-23.57)<1e-4)
  assert.ok(Math.abs(h.hole_diameter-5.2)<1e-4)
}
console.log(`PASS: no circuit error records; ${of('pcb_trace').length} PCB traces; driver pinout, separate rails/outputs/interlock, and four mounting holes verified.`)
console.log('Run check:shorts separately. This does not establish electrical, thermal or mechanical hardware performance.')

// Hardware parity: actual ADC channels, shunts in series, independent sense branches,
// unpopulated encoder, buffered programmable PD, and reset-safe alarm/enable.
for (const [p,n] of [[5,'BUZZER_PWM'],[6,'I2C0_SDA'],[7,'I2C0_SCL'],[8,'WAKE'],[9,'HOME_N'],[11,'PD_BUS_ENABLE'],[13,'LED_G'],[14,'LED_B'],[37,'LED_R'],[40,'CURRENT_A'],[41,'CURRENT_B']]) on('U1',p,n)
for(const phase of ['A','B']) {
  const shunt='R_PHASE_'+phase, amp='U_CURRENT_'+phase
  assert.equal(of('source_component').find(e=>e.name===shunt).manufacturer_part_number,'WSLP1206R0100FEA')
  on(shunt,1,phase+'_DRIVE');on(shunt,2,phase+'_PLUS')
  assert.notEqual(root(port(shunt,1)),root(port(shunt,2)),'The telemetry shunt must not be bypassed')
  assert.equal(of('source_component').find(e=>e.name===shunt).resistance,0.01)
  for(const [pin,n] of [[8,phase+'_DRIVE'],[1,phase+'_PLUS'],[2,'GND'],[3,'GND'],[4,'GND'],[6,'V3V3'],[7,'V3V3'],[5,'CURRENT_'+phase+'_RAW']])on(amp,pin,n)
  on('R_ADC_'+phase,1,'CURRENT_'+phase+'_RAW');on('R_ADC_'+phase,2,'CURRENT_'+phase)
  on('C_ADC_'+phase,1,'CURRENT_'+phase);on('C_ADC_'+phase,2,'GND')
  for(const [sensePin,ampPin] of [[1,8],[2,1]]) {
    assert.ok(of('source_trace').some(t=>(t.connected_source_port_ids??[]).includes(port(shunt,sensePin))&&(t.connected_source_port_ids??[]).includes(port(amp,ampPin))),'Dedicated Kelvin branch must start at shunt pad')
    const shuntPad=of('pcb_port').find(p=>p.source_port_id===port(shunt,sensePin))
    const ampPad=of('pcb_port').find(p=>p.source_port_id===port(amp,ampPin))
    const branch=of('pcb_trace').find(t=>{
      const ends=t.route.flatMap(p=>[p.start_pcb_port_id,p.end_pcb_port_id]).filter(Boolean)
      return ends.includes(shuntPad.pcb_port_id)&&ends.includes(ampPad.pcb_port_id)
    })
    assert.ok(branch,`${phase} sense ${sensePin} must physically branch at the shunt pad, not a power via`)
    assert.ok(branch.route.every(p=>p.route_type==='wire'&&p.layer==='top'),`${phase} sense branch must stay on top copper`)
  }
}
for (const [p,n] of [[1,'V3V3'],[2,'V3V3'],[4,'GND'],[6,'I2C0_SDA'],[7,'I2C0_SCL'],[8,'GND']])on('U_ENCODER',p,n)
for(const n of ['U_ENCODER','C_ENCODER','C_ENCODER_BULK']) {
  const e=of('source_component').find(e=>e.name===n)
  assert.equal(of('pcb_component').find(p=>p.source_component_id===e.source_component_id).do_not_place,true,n+' must remain DNP')
  assert.equal(of('pcb_component').find(p=>p.source_component_id===e.source_component_id).layer,'bottom')
}
for(const [p,n] of [[1,'V3V3'],[2,'I2C0_SCL'],[3,'I2C0_SDA'],[4,'GND'],[5,'PD_BUS_ENABLE'],[6,'PD_SDA'],[7,'PD_SCL'],[8,'V3V3']])on('U_PD_BUFFER',p,n)
on('U_PD',2,'PD_SCL');on('U_PD',3,'PD_SDA')
assert.notEqual(root(net('I2C0_SDA')),root(net('PD_SDA')))
assert.notEqual(root(net('I2C0_SCL')),root(net('PD_SCL')))
on('R_PD_BUS_OFF',1,'PD_BUS_ENABLE');on('R_PD_BUS_OFF',2,'GND')
on('R_ENC_SDA',1,'V3V3');on('R_ENC_SDA',2,'I2C0_SDA')
on('R_ENC_SCL',1,'V3V3');on('R_ENC_SCL',2,'I2C0_SCL')
on('TP_PD',1,'VIN');on('TP_VMOTOR',1,'VM');on('TP_PD_GND',1,'GND')
on('D_STATUS',4,'VBUS')
for(const [i,col] of ['R','G','B'].entries()) {
  on('Q_STATUS_'+col,1,'GND');on('Q_STATUS_'+col,2,'LED_'+col)
  assert.equal(root(port('D_STATUS',i+1)),root(port('R_STATUS_'+col,1)))
  assert.equal(root(port('R_STATUS_'+col,2)),root(port('Q_STATUS_'+col,3)))
}
assert.ok(!of('source_component').some(e=>e.name==='D1'||e.name==='R_LED'),'Old GPIO25 LED must be removed')
on('R_BUZZER_GATE',1,'BUZZER_PWM');on('R_BUZZER_GATE',2,'BUZZER_GATE')
on('R_BUZZER_PD',1,'BUZZER_GATE');on('R_BUZZER_PD',2,'GND')
on('Q_BUZZER',1,'BUZZER_GATE');on('Q_BUZZER',2,'GND');on('Q_BUZZER',3,'BUZZER_SINK')
on('BZ1',1,'V3V3');on('BZ1',2,'BUZZER_SINK')
on('D_BUZZER',1,'V3V3');on('D_BUZZER',2,'BUZZER_SINK')
assert.equal(of('source_component').find(e=>e.name==='R_PG_TOP').resistance,51000)
console.log('PASS: reference hardware functions, NEMA23 current-sensor range, I2C isolation, DNP encoder, and safe default controls verified.')

// A4 fit is a release requirement: core reports overflow as warnings, not errors.
const sheetOverflow = of('schematic_element_outside_sheet_warning')
assert.equal(sheetOverflow.length,0,JSON.stringify(sheetOverflow,null,2))
for(const sheet of of('schematic_sheet')) {
  assert.equal(sheet.sheet_size,'a4')
  assert.equal(sheet.sheet_width,297)
  assert.equal(sheet.sheet_height,210)
}
const nativeNames=['U1','U2','U3','DRIVER','U_TEMP','U_GATE','U_PD','U_EFUSE','U_PD_BUFFER','U_CURRENT_A','U_CURRENT_B','U_ENCODER']
for(const name of nativeNames) {
  const source=of('source_component').find(e=>e.name===name)
  const chip=of('schematic_component').find(e=>e.source_component_id===source.source_component_id)
  assert.notEqual(chip.is_box_with_pins,false,`${name}: native chip-box rendering must be enabled`)
  assert.ok(!chip.symbol_name&&!chip.schematic_symbol_id&&chip.pin_spacing>0&&Object.keys(chip.port_labels??{}).length>0,`${name}: must use native labeled pins`)
  assert.ok(!c.some(e=>['schematic_path','schematic_line','schematic_rect','schematic_circle'].includes(e.type)&&e.schematic_component_id===chip.schematic_component_id),`${name}: imported IC artwork remains`)
}
const {sectionDescriptions}=JSON.parse(readFileSync('engineering/schematic-style.json'))
for(const text of Object.values(sectionDescriptions))assert.ok(of('schematic_text').some(e=>e.text.replace(/\s+/g,' ')===text),`Missing section explanation: ${text}`)
console.log(`PASS: ${of('schematic_sheet').length} standard A4 sheets fit their drawing areas; native IC symbols and section explanations present.`)

for(const description of Object.values(sectionDescriptions)) {
  const text=of('schematic_text').find(e=>e.text.replace(/\s+/g,' ')===description)
  const sheet=of('schematic_sheet').find(e=>e.schematic_sheet_id===text.schematic_sheet_id)
  const lines=text.text.split('\n'), font=text.font_size
  const box={minX:text.position.x,maxX:text.position.x+Math.max(...lines.map(l=>l.length))*font*0.62,minY:text.position.y-lines.length*font,maxY:text.position.y}
  const scale=1.1/10.16,hw=(297/2-5)*scale,hh=(210/2-5)*scale
  assert.ok(box.minX>=sheet.center.x-hw&&box.maxX<=sheet.center.x+hw&&box.minY>=sheet.center.y-hh&&box.maxY<=sheet.center.y+hh,`Explanation outside A4 drawing area: ${description}`)
  for(const component of of('schematic_component').filter(e=>e.schematic_sheet_id===sheet.schematic_sheet_id)) {
    const b={minX:component.center.x-component.size.width/2,maxX:component.center.x+component.size.width/2,minY:component.center.y-component.size.height/2,maxY:component.center.y+component.size.height/2}
    assert.ok(!(box.minX<b.maxX&&box.maxX>b.minX&&box.minY<b.maxY&&box.maxY>b.minY),`Explanation overlaps a component: ${description}`)
  }
}