shibosoftwaredev/f1c100s-linux-nema8-stepper-controller
Restores component part numbers and CAD models, removes test-point 3D bodies, and validates that solder paste exists only on top-side SMT pads without altering copper or DRC.
- Version
- 0.3.5
- License
- unset
- Stars
- 0
scripts/verify.mjs
import assert from "node:assert/strict"
import crypto from "node:crypto"
import fs from "node:fs"
const circuitPath = "dist/index/circuit.json"
const circuitBytes = fs.readFileSync(circuitPath)
const circuit = JSON.parse(circuitBytes)
const of = (type) => circuit.filter((item) => item.type === type)
const parent = new Map()
const root = (id) => {
if (!parent.has(id)) parent.set(id, id)
if (parent.get(id) !== id) parent.set(id, root(parent.get(id)))
return parent.get(id)
}
const join = (a, b) => parent.set(root(a), root(b))
for (const trace of of("source_trace")) {
const ids = [...(trace.connected_source_port_ids ?? []), ...(trace.connected_source_net_ids ?? [])]
ids.slice(1).forEach((id) => join(ids[0], id))
}
const component = (name) => {
const matches = of("source_component").filter((item) => item.name === name)
assert.equal(matches.length, 1, `unique component ${name}`)
return matches[0]
}
const port = (name, pin) => {
const source = component(name)
const result = of("source_port").find((item) =>
item.source_component_id === source.source_component_id &&
(item.pin_number === pin || item.name === pin || item.port_hints?.includes(pin)))
assert.ok(result, `${name}.${pin}`)
return result.source_port_id
}
const net = (name) => {
const result = of("source_net").find((item) => item.name === name)
assert.ok(result, `net ${name}`)
return result.source_net_id
}
const expectNet = (name, pin, netName) => {
assert.equal(root(port(name, pin)), root(net(netName)), `${name}.${pin} -> ${netName}`)
}
const expectPair = (aName, aPin, bName, bPin) => {
assert.equal(root(port(aName, aPin)), root(port(bName, bPin)), `${aName}.${aPin} -> ${bName}.${bPin}`)
}
const expectPart = (name, mpn, lcsc) => {
assert.equal(component(name).manufacturer_part_number, mpn, `${name} MPN`)
assert.deepEqual(component(name).supplier_part_numbers.jlcpcb, [lcsc], `${name} JLCPCB part`)
}
// Fabrication geometry and DRC invariants.
const errors = circuit.filter((item) => item.type.endsWith("_error"))
assert.deepEqual(errors, [], "circuit JSON contains no DRC errors")
const board = of("pcb_board")[0]
assert.equal(board.num_layers, 4)
assert.equal(board.width, 81)
assert.equal(board.height, 70)
assert.deepEqual(board.outline, [
{ x: -31, y: -38 }, { x: 50, y: -38 },
{ x: 50, y: 32 }, { x: -31, y: 32 },
])
assert.equal(board.min_via_hole_diameter, 0.3)
assert.equal(board.min_via_pad_diameter, 0.45)
assert.equal(of("pcb_keepout").length, 0, "routing-only keepouts must not reach fabrication output")
const vias = of("pcb_via")
assert.ok(vias.length > 0)
assert.ok(vias.every((via) => via.hole_diameter === 0.3 && via.outer_diameter === 0.45),
"every via is exactly 0.30 mm drill / 0.45 mm pad")
assert.equal(new Set(vias.map((via) => `${via.x.toFixed(6)},${via.y.toFixed(6)}`)).size, vias.length,
"no coincident vias")
for (const placed of of("pcb_component")) {
const x0 = placed.center.x - placed.width / 2
const x1 = placed.center.x + placed.width / 2
const y0 = placed.center.y - placed.height / 2
const y1 = placed.center.y + placed.height / 2
assert.ok(x0 >= -31 - 1e-6 && x1 <= 50 + 1e-6 && y0 >= -38 - 1e-6 && y1 <= 32 + 1e-6,
`${placed.pcb_component_id} is within the board outline`)
}
// BOM and assembly invariants.
const populated = of("source_component").filter((item) => item.ftype !== "simple_test_point")
assert.equal(populated.length, 151)
for (const item of populated) {
assert.ok(item.supplier_part_numbers?.jlcpcb?.some((code) => /^C\d+$/.test(code)),
`${item.name} has a JLCPCB part number`)
}
const sourceById = new Map(of("source_component").map((item) => [item.source_component_id, item]))
const assembled = of("pcb_component").filter((placed) => {
const source = sourceById.get(placed.source_component_id)
return source && source.ftype !== "simple_test_point"
})
assert.equal(assembled.length, 151)
assert.ok(assembled.every((placed) => placed.layer === "top"), "single-sided top assembly")
assert.equal(of("pcb_smtpad").filter((pad) => pad.layer === "bottom").length, 0)
expectPart("J_USB_DATA", "TYPE-C-31-M-12", "C165948")
expectPart("J_USB_PWR", "TYPE-C-31-M-12", "C165948")
expectPart("R_USB_DATA_CC1", "0402WGF5101TCE", "C25905")
expectPart("R_USB_DATA_CC2", "0402WGF5101TCE", "C25905")
expectPart("U_PD", "HUSB238A-BB001-QN16R", "C24833806")
expectPart("Q_VBUS", "AO4407A", "C3011194")
expectPart("U_BUCK", "TPS54302DDCR", "C311983")
expectPart("U_3V3", "AP2112K-3.3TRG1", "C51118")
expectPart("U_ETH", "W5500", "C32843")
expectPart("J_ETH", "HR911105A", "C12074")
expectPart("U_MOTION", "STM32G030F6P6", "C724040")
expectPart("U_MOTOR", "DRV8825PWPR", "C81582")
expectPart("U_TEMP", "TMP102AIDRLR", "C99269")
// Linux talks to the real-time MCU; it does not generate step pulses.
expectNet("U1", "UART0_TX", "MOTION_RX")
expectNet("U1", "UART0_RX", "MOTION_TX")
expectNet("U1", "PE2", "MOTION_BOOT")
expectNet("U1", "PE3", "MOTION_NRST")
expectNet("U_MOTION", "PA3", "MOTION_RX")
expectNet("U_MOTION", "PA2", "MOTION_TX")
expectNet("U_MOTION", "PA6", "MOTOR_STEP")
expectNet("U_MOTION", "PA7", "MOTOR_DIR")
expectNet("U_MOTION", "PA4", "MOTOR_ENABLE_RAW")
expectNet("U_MOTION", "PA5", "MOTOR_FAULT")
expectNet("U_MOTION", "PA0", "MOTION_TEMP_ALERT")
expectNet("U_MOTOR", "STEP", "MOTOR_STEP")
expectNet("U_MOTOR", "DIR", "MOTOR_DIR")
expectNet("U_MOTOR", "nSLEEP", "MOTOR_ENABLE")
expectNet("U_MOTOR", "nRESET", "MOTOR_ENABLE")
expectNet("U_MOTOR", "nFAULT", "MOTOR_FAULT")
expectNet("Q_PD_ENABLE", "S", "MOTOR_ENABLE_RAW")
expectNet("Q_PD_ENABLE", "D", "MOTOR_ENABLE")
expectPair("Q_PD_ENABLE", "G", "U_PD", "GATE")
expectPair("R_PD_GOOD_PU", 1, "U_PD", "GATE")
expectPair("R_PD_GOOD_PU", 2, "Q_VBUS", "S")
expectNet("R_ENABLE_PD", 1, "MOTOR_ENABLE")
expectNet("R_ENABLE_PD", 2, "BOARD_GND")
// Fixed 1/16 microstepping and approximately 0.58 A peak per phase.
expectNet("U_MOTOR", "NENBL", "BOARD_GND")
expectNet("U_MOTOR", "MODE0", "BOARD_GND")
expectNet("U_MOTOR", "MODE1", "BOARD_GND")
expectNet("U_MOTOR", "MODE2", "V3V3")
assert.equal(component("R_REF_TOP").resistance, 5600)
assert.equal(component("R_REF_BOTTOM").resistance, 2000)
assert.equal(component("R_ISEN_A").resistance, 0.3)
assert.equal(component("R_ISEN_B").resistance, 0.3)
const traceWidths = (name) => {
const source = of("source_trace").find((item) => item.name === name)
assert.ok(source, `source trace ${name}`)
return of("pcb_trace").filter((item) => item.source_trace_id === source.source_trace_id)
.flatMap((item) => item.route.filter((point) => point.route_type === "wire").map((point) => point.width))
}
for (const name of ["MOTOR_A_PLUS", "MOTOR_A_MINUS", "MOTOR_B_PLUS", "MOTOR_B_MINUS"]) {
const widths = traceWidths(name)
assert.ok(widths.length > 0 && Math.min(...widths) >= 0.275 && Math.max(...widths) === 0.3,
`${name} is routed at approximately 0.30 mm`)
}
assert.ok(traceWidths("VM_FUSED").every((width) => width === 0.8))
// The power-only USB-C input negotiates PD; the self-powered DATA connector
// carries only USB2/FEL and has independent Type-C device pull-downs.
expectPair("J_USB_PWR", 15, "J_USB_PWR", 16)
expectPair("J_USB_PWR", 15, "Q_VBUS", "S")
expectPair("J_USB_PWR", 15, "U_PD", "VBUS")
assert.notEqual(root(port("J_USB_PWR", 15)), root(net("VBUS_PD")),
"raw receptacle VBUS remains isolated from switched VBUS_PD")
expectPair("J_USB_PWR", 6, "U_PD", "CC1")
expectPair("J_USB_PWR", 12, "U_PD", "CC2")
expectNet("J_USB_PWR", 13, "BOARD_GND")
expectNet("J_USB_PWR", 14, "BOARD_GND")
for (const pin of [7, 9]) assert.notEqual(root(port("J_USB_PWR", pin)), root(net("USB_N")))
for (const pin of [8, 10]) assert.notEqual(root(port("J_USB_PWR", pin)), root(net("USB_P")))
expectNet("J_USB_DATA", 7, "USB_N")
expectNet("J_USB_DATA", 9, "USB_N")
expectNet("J_USB_DATA", 8, "USB_P")
expectNet("J_USB_DATA", 10, "USB_P")
expectPair("J_USB_DATA", 6, "R_USB_DATA_CC1", 1)
expectNet("R_USB_DATA_CC1", 2, "BOARD_GND")
expectPair("J_USB_DATA", 12, "R_USB_DATA_CC2", 2)
expectNet("R_USB_DATA_CC2", 1, "BOARD_GND")
assert.equal(component("R_USB_DATA_CC1").resistance, 5100)
assert.equal(component("R_USB_DATA_CC2").resistance, 5100)
for (const pin of [15, 16]) {
assert.notEqual(root(port("J_USB_DATA", pin)), root(net("VBUS_PD")))
assert.notEqual(root(port("J_USB_DATA", pin)), root(net("V5_LOGIC")))
}
// USB-PD power path and protected 15 V motor rail. HUSB238A can cold-start
// from its raw VBUS input while VDD is unavailable, then drives the P-FET.
expectNet("U_PD", "VDD", "V3V3")
expectNet("U_PD", "EN_N", "BOARD_GND")
expectPair("U_PD", "SNK_VSET", "R_PD_VSET", 1)
expectPair("U_PD", "SNK_ISET", "R_PD_ISET", 1)
expectPair("U_PD", "DBG_N", "R_PD_DEBUG", 1)
assert.equal(component("R_PD_VSET").resistance, 11000)
assert.equal(component("R_PD_ISET").resistance, 21000)
assert.equal(component("R_PD_DEBUG").resistance, 910000)
expectPair("Q_VBUS", "G", "U_PD", "GATE")
expectNet("Q_VBUS", "D", "VBUS_PD")
for (const pin of [2, 3]) expectPair("Q_VBUS", "S", "Q_VBUS", pin)
for (const pin of [6, 7, 8]) expectPair("Q_VBUS", "D", "Q_VBUS", pin)
for (const name of ["USB_VBUS_A", "VBUS_SOURCE_BRIDGE_12", "VBUS_SOURCE_BRIDGE_23",
"VBUS_DRAIN_BRIDGE_56", "VBUS_DRAIN_BRIDGE_67", "VBUS_DRAIN_BRIDGE_78"]) {
const widths = traceWidths(name)
assert.ok(widths.length > 0 && Math.min(...widths) >= 0.6,
`${name} maintains at least 0.60 mm copper outside package pads`)
}
expectNet("F_VM", 1, "VBUS_PD")
expectPair("F_VM", 2, "D_VM_BLOCK", "anode")
expectNet("D_VM_BLOCK", "cathode", "VMOTOR")
expectNet("U_MOTOR", "VMA", "VMOTOR")
expectNet("U_MOTOR", "VMB", "VMOTOR")
assert.notEqual(root(net("VMOTOR")), root(net("V5_LOGIC")))
assert.notEqual(root(net("VMOTOR")), root(net("V3V3")))
// WIZnet reference MDI network: 49.9 ohm 1% termination, RX AC coupling,
// 10 ohm TX center-tap feed and the prescribed center-tap decoupling values.
for (const name of ["R_ETH_TXN", "R_ETH_TXP", "R_ETH_RXN", "R_ETH_RXP"])
assert.equal(component(name).resistance, 49.9)
assert.equal(component("C_ETH_RXN_AC").capacitance, 6.8e-9)
assert.equal(component("C_ETH_RXP_AC").capacitance, 6.8e-9)
assert.equal(component("R_ETH_TCT").resistance, 10)
assert.equal(component("C_ETH_TCT").capacitance, 22e-9)
assert.equal(component("C_ETH_RCT").capacitance, 10e-9)
expectPair("U_ETH", "TXP", "J_ETH", 1)
expectPair("U_ETH", "TXN", "J_ETH", 2)
expectPair("U_ETH", "RXP", "C_ETH_RXP_AC", 1)
expectPair("C_ETH_RXP_AC", 2, "J_ETH", 3)
expectPair("U_ETH", "RXN", "C_ETH_RXN_AC", 1)
expectPair("C_ETH_RXN_AC", 2, "J_ETH", 6)
// W5500 is the dedicated internal SPI1 peripheral; there are no expansion headers.
for (const [socPin, ethNet] of [["PE7", "ETH_CS"], ["PE8", "ETH_MOSI"],
["PE10", "ETH_MISO"], ["PE5", "ETH_RST"], ["PE6", "ETH_INT"]]) {
expectNet("U1", socPin, ethNet)
}
expectNet("U1", "PE9", "ETH_SCLK_SOC")
expectNet("U_ETH", "SCSn", "ETH_CS")
expectNet("U_ETH", "MOSI", "ETH_MOSI")
expectPair("U_ETH", "SCLK", "R_ETH_SCLK", 1)
expectNet("R_ETH_SCLK", 2, "ETH_SCLK_SOC")
expectNet("U_ETH", "MISO", "ETH_MISO")
expectNet("U_ETH", "RSTn", "ETH_RST")
expectNet("U_ETH", "INTn", "ETH_INT")
assert.equal(of("source_component").filter((item) => ["J_SPI", "J_I2C"].includes(item.name)).length, 0)
// Connector mapping and motion-MCU-owned temperature telemetry.
for (const [driverPin, connectorPin] of [["AOUT1", 1], ["AOUT2", 2], ["BOUT1", 3], ["BOUT2", 4]]) {
expectPair("U_MOTOR", driverPin, "J_MOTOR", connectorPin)
}
expectNet("U_TEMP", "SDA", "MOTION_I2C_SDA")
expectNet("U_TEMP", "SCL", "MOTION_I2C_SCL")
expectNet("U_TEMP", "ADD0", "BOARD_GND")
expectNet("U_TEMP", "ALERT", "MOTION_TEMP_ALERT")
const motorPlaced = of("pcb_component").find((item) =>
item.source_component_id === component("U_MOTOR").source_component_id)
const thermalVias = vias.filter((via) =>
Math.abs(via.x - motorPlaced.center.x) <= 2 && Math.abs(via.y - motorPlaced.center.y) <= 2)
assert.equal(thermalVias.length, 12)
// Stock report must correspond to this exact generated circuit and contain no failures.
const stock = JSON.parse(fs.readFileSync("reports/jlcpcb-stock-audit.json", "utf8"))
const circuitSha256 = crypto.createHash("sha256").update(circuitBytes).digest("hex")
assert.equal(stock.circuitSha256, circuitSha256, "stock report matches current circuit JSON")
assert.equal(stock.populatedComponentCount, 151)
assert.equal(stock.uniquePartCount, 67)
assert.deepEqual(stock.missingCodes, [])
assert.ok(stock.parts.every((part) => part.status === "in-stock" && part.orderableStock >= part.quantityPerBoard),
"all JLCPCB parts reported in stock for one board")
const report = {
circuitSha256,
board: { widthMm: board.width, heightMm: board.height, layers: board.num_layers },
drcErrors: errors.length,
pcbTraces: of("pcb_trace").length,
pcbVias: vias.length,
viaHoleDiameterMm: 0.3,
viaPadDiameterMm: 0.45,
populatedComponents: populated.length,
bottomAssemblyComponents: assembled.filter((placed) => placed.layer === "bottom").length,
jlcpcbUniqueParts: stock.uniquePartCount,
jlcpcbStockIssues: stock.parts.filter((part) => part.status !== "in-stock").length,
targetMotor: "StepperOnline 8HS15-0604S NEMA 8, 0.6 A/phase",
motorStage: { driver: "DRV8825PWPR", supply: "15 V USB-PD", peakCurrentPerPhaseA: 0.58, microstep: "1/16" },
control: { linux: "F1C100S", realtimeMotion: "STM32G030F6P6", link: "UART0" },
ethernet: "W5500 + HR911105A",
thermalVias: thermalVias.length,
}
fs.writeFileSync("verification.json", `${JSON.stringify(report, null, 2)}\n`)
console.log(JSON.stringify(report, null, 2))
