AnasSarkiz/usb-c-pd-brushed-motor
USB-C PD-powered motor controller with protected input, selectable 5/9/12 V buck supply, MCU qualification, PWM H-bridge drive, overvoltage sensing, regenerative energy-dump resistors, and safety interlocks.
- Version
- 0.0.1-a48-cloud-pipeline9-unrouted-c41d79a-305b1e7a
- License
- unset
- Stars
- 0
scripts/build-firmware.ts
import { createHash } from "node:crypto"
import { spawnSync } from "node:child_process"
import { mkdir, readFile, writeFile } from "node:fs/promises"
import { z } from "zod"
const mode = z.enum(["bringup", "qualified"]).parse(process.argv[2])
const provisioning = z.object({
nvm_qualification_id: z.number().int().positive().max(4294967295),
receive_path_qualification_id: z.number().int().positive().max(4294967295),
nvm_image: z.array(z.number().int().min(0).max(255)).length(40),
evidence: z.object({
nvm: z.string().min(1),
receive_path: z.string().min(1),
measurement: z.string().min(1),
}),
measurement: z.object({
qualification_id: z.number().int().positive().max(4294967295),
factory_vref: z.number().int().min(33).max(4062),
adc_error_half_counts: z.number().int().min(14).max(64),
noise_half_counts: z.number().int().min(0).max(64),
factory_error_half_counts: z.number().int().min(14).max(64),
upper_divider_ppm: z.number().int().min(3000).max(20000),
lower_divider_ppm: z.number().int().min(3000).max(20000),
reference_drift_ppm: z.number().int().min(9000).max(20000),
leakage_na: z.number().int().min(70).max(1000),
filter_tau_us: z.number().int().min(1).max(2000),
vdda_slew_uv_per_us: z.number().int().min(0).max(10000),
rail_slew_uv_per_us: z.tuple([
z.number().int().min(0).max(1000000),
z.number().int().min(0).max(1000000),
]),
}),
})
const directory = `dist/firmware/${mode}`
await mkdir(directory, { recursive: true })
let configurationSource =
'#include "motor_owner.h"\nconst struct motor_owner_config motor_build_config={0};\nconst bool motor_environment_qualification_present=false;\n'
let provisioningHash: string | null = null
if (mode === "qualified") {
if (!process.argv[3])
throw new Error(
"Qualified build blocked: independently approved 40-byte NVM, RX-path and measurement provisioning evidence is required; use explicit bringup mode for an inhibited diagnostic image.",
)
const bytes = await readFile(process.argv[3])
const approved = provisioning.parse(JSON.parse(bytes.toString("utf8")))
for (const evidence of Object.values(approved.evidence))
await readFile(evidence)
provisioningHash = createHash("sha256").update(bytes).digest("hex")
const profileFields = Object.entries(approved.measurement)
.map(
([field, contents]) =>
`.${field}=${Array.isArray(contents) ? `{${contents.join(",")}}` : contents}`,
)
.join(",")
configurationSource =
'#include "motor_owner.h"\n' +
`static const struct stusb_nvm_image image={.bytes={${approved.nvm_image.join(",")}}};\n` +
`static const struct motor_measurement_profile profile={.approved=true,${profileFields}};\n` +
`const struct motor_owner_config motor_build_config={.nvm_image=&image,.measurement_profile=&profile,.nvm_qualification_id=${approved.nvm_qualification_id},.receive_path_qualification_id=${approved.receive_path_qualification_id}};\n` +
"const bool motor_environment_qualification_present=true;\n"
}
const configurationPath = `${directory}/build_configuration.c`
await writeFile(configurationPath, configurationSource)
const productionModules = [
"pd_policy",
"pd_sequence",
"stm32_safe_gpio",
"stm32_adc",
"stm32_i2c",
"stm32_clock",
"motor_measurement",
"stusb4500_rx",
"stusb4500_startup",
"stusb4500_request",
"stusb4500_nvm",
"motor_owner",
"motor_main",
"freestanding_memory",
]
const sourcePaths = [
...productionModules.map((name) => `firmware/${name}.c`),
"firmware/startup_stm32g030f6.S",
"firmware/stm32g030f6.ld",
configurationPath,
]
const sourceHashes = await Promise.all(
sourcePaths.map(async (path) => ({
path,
sha256: createHash("sha256")
.update(await readFile(path))
.digest("hex"),
})),
)
const gcc = "arm-none-eabi-gcc"
const common = [
"-mcpu=cortex-m0plus",
"-mthumb",
"-std=c11",
"-Os",
"-ffreestanding",
"-fno-builtin",
"-fno-common",
"-ffunction-sections",
"-fdata-sections",
"-fno-unwind-tables",
"-fno-asynchronous-unwind-tables",
"-fstack-usage",
"-Wall",
"-Wextra",
"-Werror",
`-ffile-prefix-map=${process.cwd()}=.`,
"-Ifirmware",
"-Ifirmware/vendor/stm32g0/Include",
"-Ifirmware/vendor/cmsis/Core/Include",
]
function run(command: string, argumentsList: string[]) {
const result = spawnSync(command, argumentsList, { encoding: "utf8" })
if (result.status !== 0)
throw new Error(
`${command} failed (${result.status}): ${result.stdout}${result.stderr}`,
)
return result.stdout + result.stderr
}
const objectPaths: string[] = []
for (const sourcePath of sourcePaths.filter((path) => !path.endsWith(".ld"))) {
const objectPath = `${directory}/${sourcePath.split("/").slice(-1)[0]}.o`
run(gcc, [...common, "-c", sourcePath, "-o", objectPath])
objectPaths.push(objectPath)
}
const elfPath = `${directory}/motor-controller-A22.elf`,
binPath = `${directory}/motor-controller-A22.bin`
run(gcc, [
...common,
"-nostdlib",
...objectPaths,
"-Tfirmware/stm32g030f6.ld",
"-Wl,--gc-sections,--build-id=none,--fatal-warnings,-z,noexecstack",
`-Wl,-Map=${directory}/motor-controller-A22.map`,
"-lgcc",
"-o",
elfPath,
])
run("arm-none-eabi-objcopy", ["-O", "binary", elfPath, binPath])
const size = run("arm-none-eabi-size", [elfPath])
const columns = size.trim().split("\n").slice(-1)[0]?.trim().split(/\s+/)
const memory = z
.object({
text: z.number().int().nonnegative(),
data: z.number().int().nonnegative(),
bss: z.number().int().nonnegative(),
})
.parse({
text: Number(columns?.[0]),
data: Number(columns?.[1]),
bss: Number(columns?.[2]),
})
if (memory.text + memory.data > 32768 || memory.data + memory.bss + 2048 > 8192)
throw new Error(
"Firmware exceeds STM32G030F6 flash/RAM and 2KiB stack reservation",
)
const binary = await readFile(binPath)
if (binary.readUInt32LE(0) !== 0x20002000 || !(binary.readUInt32LE(4) & 1))
throw new Error("Invalid target reset vector/initial stack")
const symbols = run("arm-none-eabi-nm", ["-n", elfPath])
for (const [index, symbol] of [
[16 + 16, "TIM3_IRQHandler"],
[16 + 7, "EXTI4_15_IRQHandler"],
] as const) {
const address = symbols
.split("\n")
.find((line) => line.endsWith(` ${symbol}`))
?.split(" ")[0]
if (
!address ||
binary.readUInt32LE(index * 4) !== (Number.parseInt(address, 16) | 1)
)
throw new Error(`Vector does not bind ${symbol} to ST's IRQ position`)
}
await writeFile(`${directory}/symbols.txt`, symbols)
await writeFile(
`${directory}/sections.txt`,
run("arm-none-eabi-objdump", ["-h", elfPath]),
)
const artifacts = await Promise.all(
[elfPath, binPath].map(async (path) => ({
path,
sha256: createHash("sha256")
.update(await readFile(path))
.digest("hex"),
})),
)
const report = {
revision: "A22",
mode,
status:
mode === "bringup"
? "linked diagnostic image; motor qualification intentionally unavailable and inhibited"
: "linked provisioned image; physical testing remains pending",
provisioningHash,
compiler: run(gcc, ["--version"]).split("\n")[0],
memory: {
...memory,
flashBytes: memory.text + memory.data,
staticRamBytes: memory.data + memory.bss,
reservedStackBytes: 2048,
},
sourceHashes,
artifacts,
}
await writeFile(
`${directory}/build-report.json`,
JSON.stringify(report, null, 2) + "\n",
)
await writeFile(
`evidence/firmware-linked-${mode}-A22.json`,
JSON.stringify(report, null, 2) + "\n",
)
console.log(
JSON.stringify(
{ mode, status: report.status, memory: report.memory, artifacts },
null,
2,
),
)