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);