imrishabh18/rp2040-motor-controller
An RP2040-based NEMA17 stepper-motor controller with USB-C programming and PD motor-power negotiation, DRV8825 dual H-bridge drive, current/temperature telemetry, protected power filtering, status RGB LED, buzzer alarm, and optional magnetic encoder.
- Version
- 1.0.41
- License
- unset
- Stars
- 3
tests/board.test.tsx
import { assertUsb20v } from "./helpers/assertUsb20v";
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import {
checkPadTraceClearance,
checkSourceTracesMatchPcbTraceThickness,
runAllRoutingChecks,
} from "@tscircuit/checks";
import type { CircuitJson, PcbSmtPad, PcbTrace, PcbVia, SourceTrace } from "circuit-json";
import { Circuit } from "tscircuit";
import Rp2040MotorController from "../index.circuit";
import { routedViaOverlapsPad } from "./helpers/routedViaOverlapsPad";
import { assertCommonGround } from "./helpers/assertCommonGround";
import { checkJlcClearances } from "./helpers/checkJlcClearances";
test("renders the complete RP2040 stepper-motor controller", async () => {
let circuitJson: CircuitJson;
if (process.env.CIRCUIT_JSON_PATH) {
circuitJson = JSON.parse(readFileSync(process.env.CIRCUIT_JSON_PATH, "utf8"));
} else {
const circuit = new Circuit();
circuit.add(<Rp2040MotorController routingDisabled={false} />);
await circuit.renderUntilSettled();
circuitJson = circuit.getCircuitJson();
}
assertCommonGround(circuitJson);
assertUsb20v(circuitJson);
const crystalTraceNames = ["XIN", "XOUT_R", "XOUT", "CXIN", "CXOUT"];
const crystalSourceTraces = circuitJson.filter(
(element): element is SourceTrace =>
element.type === "source_trace" &&
crystalTraceNames.includes(element.name ?? ""),
);
const crystalPcbTraces = circuitJson.filter(
(element): element is PcbTrace =>
element.type === "pcb_trace" &&
crystalSourceTraces.some(
(sourceTrace) =>
sourceTrace.source_trace_id === element.source_trace_id,
),
);
const sourceComponents = circuitJson.filter(
(element) => element.type === "source_component",
);
const pcbBoard = circuitJson.find((element) => element.type === "pcb_board");
const componentsMissingJlcpcbPartNumbers = sourceComponents
.filter(
(component) =>
component.ftype !== "simple_test_point" &&
!/^MH[1-4]$/.test(component.name ?? "") &&
!component.supplier_part_numbers?.jlcpcb?.length,
)
.map((component) => component.name)
.sort();
const schematicSheets = circuitJson
.filter((element) => element.type === "schematic_sheet")
.sort((a, b) => (a.sheet_index ?? 0) - (b.sheet_index ?? 0));
const schematicComponents = circuitJson.filter(
(element) => element.type === "schematic_component",
);
const schematicOutsideSheetWarnings = circuitJson.filter(
(element) =>
String(element.type) === "schematic_element_outside_sheet_warning",
);
const schematicSheetIds = new Set(
schematicSheets.map((sheet) => sheet.schematic_sheet_id),
);
const schematicText = new Set(
circuitJson.flatMap((element) =>
element.type === "schematic_text" ? [element.text] : [],
),
);
expect(
sourceComponents.some(
(component) => component.manufacturer_part_number === "RP2040",
),
).toBe(true);
expect(
pcbBoard?.type === "pcb_board"
? { width: pcbBoard.width, height: pcbBoard.height }
: undefined,
).toEqual({ width: 42.29999159999999, height: 42.320006799999994 });
expect(pcbBoard?.num_layers).toBe(4);
expect(pcbBoard?.min_via_hole_diameter).toBe(0.3);
expect(pcbBoard?.min_via_pad_diameter).toBe(0.5);
expect(
crystalSourceTraces
.map((trace) => ({ name: trace.name, maxViaCount: trace.max_via_count }))
.sort((a, b) => a.name!.localeCompare(b.name!)),
).toEqual(
crystalTraceNames
.map((name) => ({ name, maxViaCount: 0 }))
.sort((a, b) => a.name.localeCompare(b.name)),
);
expect(crystalPcbTraces.length).toBe(crystalTraceNames.length);
const supplierPin = (name: string, number: number) => {
const source = sourceComponents.find(component => component.name === name);
const port = circuitJson.find(element => element.type === "source_port" &&
element.source_component_id === source?.source_component_id && element.pin_number === number);
const pcbPort = circuitJson.find(element => element.type === "pcb_port" &&
element.source_port_id === (port?.type === "source_port" ? port.source_port_id : undefined));
return { port, pcbPort };
};
for (const [name, x, y] of [["D_PWR", -18.749, 11.5], ["D_MOTOR_TVS", -6.35, 7.5]] as const) {
const { port, pcbPort } = supplierPin(name, 1);
expect(port?.type === "source_port" && port.port_hints?.includes("cathode")).toBe(true);
expect(pcbPort?.type === "pcb_port" ? [pcbPort.x, pcbPort.y] : null).toEqual([x, y]);
}
expect(
crystalPcbTraces.flatMap((trace) =>
trace.route.filter((point) => point.route_type === "via"),
),
).toEqual([]);
expect(
sourceComponents.some(
(component) => component.manufacturer_part_number === "DRV8825PWPR",
),
).toBe(true);
expect(
sourceComponents.some(
(component) => component.manufacturer_part_number === "CH224K",
),
).toBe(true);
expect(
sourceComponents.some(
(component) =>
component.manufacturer_part_number === "TYPE-C-31-M-12",
),
).toBe(true);
const stepperConnector = sourceComponents.find(
(component) => component.name === "J1",
);
expect(stepperConnector?.manufacturer_part_number).toBe(
"B4B-PH-K-S(LF)(SN)",
);
expect(stepperConnector?.supplier_part_numbers?.jlcpcb).toEqual([
"C131334",
]);
expect(
circuitJson
.flatMap((element) =>
element.type === "source_port" &&
element.source_component_id === stepperConnector?.source_component_id &&
typeof element.pin_number === "number"
? [element.pin_number]
: [],
)
.sort((a, b) => a - b),
).toEqual([1, 2, 3, 4]);
expect(sourceComponents.some((component) => component.name === "J2")).toBe(
false,
);
expect(componentsMissingJlcpcbPartNumbers).toEqual([]);
expect(
circuitJson
.flatMap((element) => {
if (
element.type !== "source_trace" ||
!/^MOTOR_[AB]_(PLUS|MINUS)$/.test(element.name ?? "")
) {
return [];
}
return [{ name: element.name, displayName: element.display_name }];
})
.sort((a, b) => a.name!.localeCompare(b.name!)),
).toEqual([
{
name: "MOTOR_A_MINUS",
displayName: ".DRIVER > .AOUT2 to .J1 > .pin2",
},
{
name: "MOTOR_A_PLUS",
displayName: ".DRIVER > .AOUT1 to .R_PHASE_A > .pin1",
},
{
name: "MOTOR_B_MINUS",
displayName: ".DRIVER > .BOUT2 to .J1 > .pin4",
},
{
name: "MOTOR_B_PLUS",
displayName: ".DRIVER > .BOUT1 to .R_PHASE_B > .pin1",
},
]);
expect(
schematicSheets.map((sheet) => ({
name: sheet.name,
displayName: (sheet as typeof sheet & { display_name?: string })
.display_name,
sheetIndex: sheet.sheet_index,
})),
).toEqual([
{
name: "controller",
displayName: "RP2040 Controller",
sheetIndex: 1,
},
{
name: "controller_programming",
displayName: "Programming USB-C & QSPI",
sheetIndex: 2,
},
{
name: "motor_driver",
displayName: "Stepper Motor Driver",
sheetIndex: 3,
},
{
name: "motor_power",
displayName: "USB-C PD Motor Power",
sheetIndex: 4,
},
{
name: "thermal_protection",
displayName: "Temperature Telemetry",
sheetIndex: 5,
},
{
name: "position_alarm",
displayName: "Optional Encoder & Audible Alarm",
sheetIndex: 6,
},
{name:"current_telemetry", displayName:"Winding Current Measurement", sheetIndex:7},
{name:"status_led", displayName:"RGB Status Indicator", sheetIndex:8},
{name:"pd_voltage_control", displayName:"PD Voltage Selection", sheetIndex:9},
]);
expect(
schematicComponents.every(
(component) =>
Boolean(component.schematic_sheet_id) &&
schematicSheetIds.has(component.schematic_sheet_id!),
),
).toBe(true);
expect(schematicComponents.length).toBeGreaterThan(0);
expect(schematicOutsideSheetWarnings).toEqual([]);
expect([...schematicText]).toEqual(
expect.arrayContaining([
"RP2040 & Power",
"Programming USB-C",
"QSPI Flash",
"3.3 V Supply",
"3.3 V Bypass",
"Clock",
"Controls, Status & SWD",
"H-Bridge, Power & Control",
"Stepper Motor Output",
"USB-C PD Negotiation",
"Power Filtering",
]),
);
const unexpectedErrors = circuitJson.filter((element) =>
element.type.endsWith("_error"),
);
// Length limits from the reusable RP2040 module are reported as warnings by
// the latest checker. Keep fabrication-blocking routing errors as the gate.
const routingIssues = (
await runAllRoutingChecks(structuredClone(circuitJson))
).filter((element) => element.type.endsWith("_error"));
const widthWarnings = checkSourceTracesMatchPcbTraceThickness(circuitJson);
const powerSourceTraceIds = new Set<string>(
circuitJson.flatMap((element) =>
element.type === "source_trace" &&
(element.min_trace_thickness ?? 0) >= 0.5
? [element.source_trace_id!]
: [],
),
);
const powerPcbTraceIds = new Set(
circuitJson.flatMap((element) =>
element.type === "pcb_trace" &&
Boolean(element.source_trace_id) &&
powerSourceTraceIds.has(element.source_trace_id!)
? [element.pcb_trace_id]
: [],
),
);
const preferredPowerPadClearanceIssues = checkPadTraceClearance(circuitJson, {
minClearance: 0.15,
}).filter((issue) => powerPcbTraceIds.has(issue.pcb_trace_id));
const uniqueUnderWidthTraceIds = new Set(
widthWarnings.map((warning) => warning.pcb_trace_id),
);
const copperPours = circuitJson.filter(
(element) => element.type === "pcb_copper_pour",
);
const pcbTraceIds = new Set(
circuitJson.flatMap((element) =>
element.type === "pcb_trace" ? [element.pcb_trace_id] : [],
),
);
const routedVias = circuitJson.filter(
(element): element is PcbVia =>
element.type === "pcb_via" &&
Boolean(element.pcb_trace_id) &&
pcbTraceIds.has(element.pcb_trace_id!),
);
const fixedVias = circuitJson.filter((e): e is PcbVia => e.type === "pcb_via" && !e.pcb_trace_id);
expect(routedVias.length).toBeGreaterThan(0);
for (const via of routedVias) {
expect(via.hole_diameter).toBe(0.3);
expect(via.from_layer).toBe("top");
expect(via.to_layer).toBe("bottom");
const fixed = fixedVias.find(v => Math.hypot(v.x-via.x,v.y-via.y)<1e-6);
// A routed path may reuse a declared thermal/MCU barrel. New vias use 0.5 mm pads.
expect(via.outer_diameter).toBe(fixed ? fixed.outer_diameter : 0.5);
expect(new Set(via.layers)).toEqual(new Set(["top", "inner1", "inner2", "bottom"]));
}
const consecutiveViaRuns = circuitJson.flatMap((element) => {
if (element.type !== "pcb_trace") return [];
const runs: number[] = [];
let runLength = 0;
for (const point of element.route) {
if (point.route_type === "via") {
runLength++;
} else {
if (runLength > 1) runs.push(runLength);
runLength = 0;
}
}
if (runLength > 1) runs.push(runLength);
return runs;
});
const smtPads = circuitJson.filter(
(element): element is PcbSmtPad => element.type === "pcb_smtpad",
);
const traceEndpointPorts = new Map<string, Set<string>>();
for (const element of circuitJson) {
if (element.type !== "pcb_trace") continue;
const endpointPorts = new Set<string>();
for (const point of element.route) {
if (point.route_type !== "wire") continue;
if (point.start_pcb_port_id) endpointPorts.add(point.start_pcb_port_id);
if (point.end_pcb_port_id) endpointPorts.add(point.end_pcb_port_id);
}
traceEndpointPorts.set(element.pcb_trace_id, endpointPorts);
}
const routedViaPadOverlaps = routedVias.flatMap((via) =>
smtPads
// An escape via may intentionally merge with the terminal pad belonging
// to that same trace. Only contact with a foreign pad is a DRC problem.
.filter(
(pad) =>
routedViaOverlapsPad(via, pad) &&
!traceEndpointPorts.get(via.pcb_trace_id!)?.has(pad.pcb_port_id!),
)
.map((pad) => ({
pcb_via_id: via.pcb_via_id,
pcb_trace_id: via.pcb_trace_id,
pcb_smtpad_id: pad.pcb_smtpad_id,
})),
);
const copperPourNetIds = new Set(
copperPours.map((pour) => pour.source_net_id),
);
const copperPourNet = circuitJson.find(
(element) =>
element.type === "source_net" &&
copperPourNetIds.has(element.source_net_id),
);
const rIsenBSourceTrace = circuitJson.find(
(element) =>
element.type === "source_trace" &&
element.name === "ISEN_B",
);
const rIsenBSourceTraceId =
rIsenBSourceTrace?.type === "source_trace"
? rIsenBSourceTrace.source_trace_id
: undefined;
const rIsenBPortIds = new Set(circuitJson.flatMap(element =>
element.type === "pcb_port" && element.layers.includes("top") && rIsenBSourceTrace?.type === "source_trace" &&
rIsenBSourceTrace.connected_source_port_ids.includes(element.source_port_id!)
? [element.pcb_port_id] : []));
const rIsenBPcbTrace = circuitJson.find(element => element.type === "pcb_trace" &&
[...rIsenBPortIds].every(id => element.route.some(point => point.route_type === "wire" &&
(point.start_pcb_port_id === id || point.end_pcb_port_id === id))));
expect(unexpectedErrors).toEqual([]);
expect(routingIssues).toEqual([]);
expect(routedViaPadOverlaps).toEqual([]);
// The connectivity router can represent one shared ground transition in two
// MST branches. The no-stitching invariant is that a route never contains a
// run of inserted vias; coincident same-net records render as one transition.
// A normal layer transition has one via. Multiple consecutive via route
// points indicate that a post-routing via-stitching pass modified the route.
expect(consecutiveViaRuns).toEqual([]);
// v1.0.37 removes the 37 reduced-spacing exceptions. Every power route
// must now meet 0.15 mm, with the explicit JLC profile checked separately.
expect(preferredPowerPadClearanceIssues).toEqual([]);
for (const issues of Object.values(checkJlcClearances(circuitJson))) {
expect(issues).toEqual([]);
}
expect(copperPours.length).toBeGreaterThanOrEqual(2);
expect(new Set(copperPours.map((pour) => pour.layer))).toEqual(
new Set(["top", "inner1", "inner2", "bottom"]),
);
expect(copperPours.some((pour) => pour.layer === "top")).toBe(true);
expect(copperPours.some((pour) => pour.layer === "bottom")).toBe(true);
expect(copperPourNetIds.size).toBe(1);
expect(copperPourNet?.type === "source_net" && copperPourNet.name).toBe(
"GND",
);
expect(
copperPours.every(
(pour) =>
pour.shape === "brep" &&
pour.brep_shape.outer_ring.vertices.length >= 3 &&
pour.covered_with_solder_mask,
),
).toBe(true);
// Inline shunts split the positive outputs into two routes. Check all six
// motor-current paths, rather than the old single A-minus route's shape.
// 0.35 mm necks are limited to short package escapes; most copper is wider.
// The resistance estimate conservatively assumes 18 um copper on every layer
// at 20 C and excludes vias, pads and shunts. It is not a thermal rating.
for (const name of ["MOTOR_A_PLUS", "MOTOR_A_MINUS", "MOTOR_B_PLUS", "MOTOR_B_MINUS", "CURRENT_A_WINDING", "CURRENT_B_WINDING"]) {
const source=circuitJson.find((e):e is SourceTrace=>e.type==="source_trace"&&e.name===name);
expect(source).toBeDefined();
const routes=circuitJson.filter((e):e is PcbTrace=>e.type==="pcb_trace"&&e.source_trace_id===source!.source_trace_id);
expect(routes).toHaveLength(1);
let length=0, resistance=0, shortNeck=0, longestNeck=0;
for (let i=0;i<routes[0].route.length-1;i++) {
const a=routes[0].route[i],b=routes[0].route[i+1];
if(a.route_type!=="wire"||b.route_type!=="wire"||a.layer!==b.layer){shortNeck=0;continue;}
const segment=Math.hypot(b.x-a.x,b.y-a.y), width=Math.min(a.width,b.width);
if(segment<1e-7)continue;
expect(width).toBeGreaterThanOrEqual(0.35-1e-6);
length+=segment;
resistance+=1.724e-8*(segment/1000)/((width/1000)*18e-6);
shortNeck=width<0.5-1e-6?shortNeck+segment:0;
longestNeck=Math.max(longestNeck,shortNeck);
}
expect(length).toBeGreaterThan(0);
expect(length).toBeLessThan(40);
expect(longestNeck).toBeLessThan(1.5);
expect(resistance).toBeLessThan(0.05);
expect(routes[0].route.filter(p=>p.route_type==="via").length).toBeLessThanOrEqual(3);
}
expect(rIsenBSourceTrace?.type).toBe("source_trace");
expect(rIsenBPcbTrace?.type).toBe("pcb_trace");
const rIsenBVias =
rIsenBPcbTrace?.type === "pcb_trace"
? rIsenBPcbTrace.route.filter((point) => point.route_type === "via")
: [];
// Pipeline 7 may briefly use the bottom layer to escape this dense driver
// area. Bound the detour to one down/up via pair.
expect(rIsenBVias.length).toBeLessThanOrEqual(2);
// This check reports the minimum route-point width for an entire connected
// net, so it is a board-integration regression budget rather than a
// length-weighted quality metric. Keep Pipeline 7's current escape budget
// bounded while the route-specific assertions above guard copper quality.
// The optional encoder adds fine-pitch escapes and shares the 3.3 V net.
// The checker repeats a shared narrow escape for each source connection.
// Retain the earlier advisory budget across the DRV8825 redesign; all six
// winding paths have independent width, escape-length and resistance checks
// above. The hard 0.1 mm clearance requirement is unchanged.
expect(widthWarnings.length).toBeLessThanOrEqual(101);
expect(uniqueUnderWidthTraceIds.size).toBeLessThanOrEqual(34);
}, 600_000);
