diff --git a/src/board-catalog.mjs b/src/board-catalog.mjs index f49e3ef..b30caa3 100644 --- a/src/board-catalog.mjs +++ b/src/board-catalog.mjs @@ -1,3 +1,8 @@ +// ST's on-board ST-LINK debug probe (VID 0x0483): V2 0x3748, V2-1 0x374B and +// 0x3752, V3 0x374E. The probe, not the board model, is what enumerates, so +// these IDs say "an STM32 board" rather than which one. +const stlinkUsb = Object.freeze({ vendorId: '0483', productIds: ['3748', '374b', '3752', '374e'] }) + export const knownBoards = Object.freeze([ { id: 'waveshare-lcd-35b', @@ -259,6 +264,24 @@ export const knownBoards = Object.freeze([ gps: false, sensors: ['PCF85063 RTC', 'buttons', 'battery sense', 'rear lighting'] } + }, + { + id: 'st-stm32f746g-disco', + label: 'STMicroelectronics STM32F746G-Discovery', + alias: 'stm32f746g-disco', + target: 'stm32f746g-disco', + adapter: 'stm32-cube', + mcu: 'STM32F746NG', + usb: stlinkUsb, + capabilities: { + display: '4.3 inch 480x272 RGB LCD (LTDC)', + touch: 'FT5336 capacitive touch', + wireless: [], + storage: ['1 MB flash', '16 MB QSPI flash', '8 MB SDRAM', 'microSD'], + audio: ['WM8994 codec'], + gps: false, + sensors: ['user button'] + } } ]) @@ -304,3 +327,11 @@ export function boardById(id) { export function boardByTarget(target) { return knownBoards.find((board) => board.target === target) || null } + +// The catalog board a serial device enumerates as, by its USB vendor and +// product IDs; null when no board claims them. +export function boardByUsbDevice(device) { + if (!device.vendorId) return null + + return knownBoards.find((board) => board.usb?.vendorId === device.vendorId && board.usb.productIds.includes(device.productId)) || null +} diff --git a/src/boards/resolve.mjs b/src/boards/resolve.mjs index 9423639..f852d09 100644 --- a/src/boards/resolve.mjs +++ b/src/boards/resolve.mjs @@ -5,7 +5,7 @@ import { boardConfigOrigins, boardConfigPath, loadBoardConfig, normalizeBoardCon import { loadTargets } from './targets.mjs' import { resolveUsbSerialPort } from './usb.mjs' -export const usbSerialAdapters = new Set(['esp32-idf', 'rp2350-pico']) +export const usbSerialAdapters = new Set(['esp32-idf', 'rp2350-pico', 'stm32-cube']) export const geaosAdapters = new Set(['geaos-linux', 'geaos-arm64']) function boardTransport(board, name) { diff --git a/src/boards/usb.mjs b/src/boards/usb.mjs index 7f1477e..9cf4912 100644 --- a/src/boards/usb.mjs +++ b/src/boards/usb.mjs @@ -131,9 +131,9 @@ function macUsbCalloutPortsForSerial(serial, ioreg = runIoreg) { return [...matches].sort() } -// Every USB callout port the registry knows, with the serial and product -// name inherited from the enclosing USB device: the inverse of -// macUsbCalloutPortsForSerial, for `gea boards discover` and the setup +// Every USB callout port the registry knows, with the serial, product name +// and vendor/product IDs inherited from the enclosing USB device: the inverse +// of macUsbCalloutPortsForSerial, for `gea boards discover` and the setup // wizard. The /dev name carries no identity (the usbmodem number changes on // every enumeration and identical boards share it), so this is the only // place a port's serial can come from on macOS. @@ -154,7 +154,7 @@ export function listMacUsbCalloutPorts(ioreg = runIoreg) { // interface would look shallower than the device holding its serial. const depth = node[1].length while (stack.length > 0 && stack[stack.length - 1].depth >= depth) stack.pop() - stack.push({ depth, serial: '', product: '' }) + stack.push({ depth, serial: '', product: '', vendorId: 0, productId: 0 }) continue } if (stack.length === 0) continue @@ -163,6 +163,8 @@ export function listMacUsbCalloutPorts(ioreg = runIoreg) { if (serialValue) current.serial = serialValue const productValue = ioregStringProperty(line, 'kUSBProductString') || ioregStringProperty(line, 'USB Product Name') if (productValue) current.product = productValue + current.vendorId ||= ioregNumberProperty(line, 'idVendor') + current.productId ||= ioregNumberProperty(line, 'idProduct') const callout = ioregStringProperty(line, 'IOCalloutDevice') if (!callout || !path.basename(callout).startsWith('cu.')) continue // A callout with no USB serial above it is not a USB device at all @@ -170,11 +172,17 @@ export function listMacUsbCalloutPorts(ioreg = runIoreg) { const serial = [...stack].reverse().find((entry) => entry.serial)?.serial || '' if (!serial) continue const label = [...stack].reverse().find((entry) => entry.product)?.product || '' - ports.set(callout, { path: callout, serial, label }) + const usbDevice = [...stack].reverse().find((entry) => entry.vendorId) || {} + ports.set(callout, { path: callout, serial, label, vendorId: usbIdHex(usbDevice.vendorId), productId: usbIdHex(usbDevice.productId) }) } return [...ports.values()].sort((a, b) => a.path.localeCompare(b.path)) } +// ioreg prints USB IDs in decimal; lsusb, sysfs and Windows spell them as four hex digits. +function usbIdHex(value) { + return value ? value.toString(16).padStart(4, '0') : '' +} + function runIoreg(args, options = {}) { return execFileSync('ioreg', args, { encoding: 'utf8', ...options }) } diff --git a/src/commands/board.mjs b/src/commands/board.mjs index c685d8f..3fd66e4 100644 --- a/src/commands/board.mjs +++ b/src/commands/board.mjs @@ -15,6 +15,7 @@ import { manifestRequestsBleOta } from '../esp32/capabilities.mjs' import { runGeaos } from '../geaos/adapter.mjs' import { assertTargetEnabled, assertValidApp, discoverApps, resolveRequestedApp, targetEnabledForApp } from '../manifest.mjs' import { buildRp2350, flashRp2350, rp2350BuildDir } from '../rp2350/adapter.mjs' +import { buildStm32, flashStm32, stm32BuildDir } from '../stm32/adapter.mjs' import { runXbox } from '../xbox/adapter.mjs' import { success } from '../report.mjs' import {runLinux} from '../linux/adapter.mjs' @@ -134,6 +135,9 @@ export async function buildCommand(ctx, parsed, rest, options) { case 'rp2350-pico': buildRp2350({ ctx, selection, app, env, dryRun: base.dryRun, stdout: base.stdout, stderr: base.stderr, configureOnly: flag(parsed, 'configure-only'), output: option(parsed, 'output', '') }) return 0 + case 'stm32-cube': + buildStm32({ ctx, selection, app, env, dryRun: base.dryRun, stdout: base.stdout, configureOnly: flag(parsed, 'configure-only') }) + return 0 case 'geaos-linux': case 'geaos-arm64': return runGeaos({ ctx, selection, action: 'build', app, positionals: rest, env, dryRun: base.dryRun, stdout: base.stdout }) @@ -150,8 +154,9 @@ export async function cleanCommand(ctx, parsed, rest, options) { fullCleanEsp32({ ctx, selection, app, env: base.env, stdout: base.stdout }) return 0 } - if (selection.adapter === 'rp2350-pico') { - const dir = rp2350BuildDir(ctx, selection) + const armBuildDirs = { 'rp2350-pico': rp2350BuildDir, 'stm32-cube': stm32BuildDir } + if (armBuildDirs[selection.adapter]) { + const dir = armBuildDirs[selection.adapter](ctx, selection) base.stdout(`Removing build artifacts in ${dir}...`) const { rmSync } = await import('node:fs') rmSync(dir, { recursive: true, force: true }) @@ -309,6 +314,12 @@ export async function flashCommand(ctx, parsed, rest, options, { monitor = false flashRp2350({ selection, uf2, env, dryRun: base.dryRun, stdout: base.stdout }) return monitor ? monitorCommand(ctx, parsed, rest, options, selection) : 0 } + case 'stm32-cube': { + const app = optionalApp(ctx, parsed, rest, selection) + const { elf } = buildStm32({ ctx, selection, app, env, dryRun: base.dryRun, stdout: base.stdout }) + flashStm32({ selection, elf, env, dryRun: base.dryRun, stdout: base.stdout }) + return monitor ? monitorCommand(ctx, parsed, rest, options, selection) : 0 + } case 'geaos-linux': case 'geaos-arm64': { const app = optionalApp(ctx, parsed, rest, selection) diff --git a/src/commands/boards.mjs b/src/commands/boards.mjs index 0155882..6f062fa 100644 --- a/src/commands/boards.mjs +++ b/src/commands/boards.mjs @@ -10,6 +10,7 @@ import { readBoardConfigFile, writeBoardConfigFile } from '../boards/config.mjs' +import { boardById, boardByUsbDevice } from '../board-catalog.mjs' import { normalizedSerial } from '../boards/usb.mjs' import { SerialDevice, geadev } from '../device/serial.mjs' import { ExitCode, fail } from '../errors.mjs' @@ -158,8 +159,9 @@ export function parsePong(reply) { } // Pure: pairs detected serial devices with configured aliases by USB serial -// (the MAC on ESP32 boards, so a PONG's mac= confirms the same thing), and -// records what each device answered. +// (the MAC on ESP32 boards, so a PONG's mac= confirms the same thing), names +// the catalog board a device's USB IDs identify (an STM32 board's ST-LINK), +// and records what each device answered. export async function discoverBoards({ devices, boards, probe }) { const results = [] for (const device of devices) { @@ -175,6 +177,7 @@ export async function discoverBoards({ devices, boards, probe }) { serial: device.serial || probed.mac || '', alias: alias || '', target: alias ? boards[alias].target || '' : '', + knownBoard: boardByUsbDevice(device)?.id || '', configuredHost: alias ? boards[alias].transports?.ota?.host || '' : '', responds: probed.ok, app: probed.app || '', @@ -187,7 +190,7 @@ export async function discoverBoards({ devices, boards, probe }) { } export function formatDiscovery(result) { - const who = result.alias ? `${result.alias} (${result.target})` : 'not configured' + const who = result.alias ? `${result.alias} (${result.target})` : unconfiguredLabel(result) const bits = [result.path, who] if (result.serial) bits.push(`serial ${result.serial}`) if (result.responds) { @@ -199,6 +202,11 @@ export function formatDiscovery(result) { return bits.join(' ') } +function unconfiguredLabel(result) { + const board = boardById(result.knownBoard) + return board ? `not configured, ${board.label}` : 'not configured' +} + // ---- command -------------------------------------------------------------------- export async function boardsCommand(ctx, parsed, rest, options) { diff --git a/src/commands/doctor.mjs b/src/commands/doctor.mjs index 519c566..93313c8 100644 --- a/src/commands/doctor.mjs +++ b/src/commands/doctor.mjs @@ -7,6 +7,7 @@ import { ExitCode } from '../errors.mjs' import { findEspIdf, findIdfPythonEnv } from '../esp32/idf-env.mjs' import { exists } from '../fs-utils.mjs' import { discoverApps, resolveRequestedApp, validateApp } from '../manifest.mjs' +import { stm32ToolChecks } from '../stm32/toolchain.mjs' import { commandVersion, nodeAtLeast, onPath } from '../toolchain.mjs' import pc from 'picocolors' @@ -48,6 +49,7 @@ export async function doctorCommand(ctx, parsed, rest, options) { add('ccache', onPath('ccache', env), onPath('ccache', env) ? 'on PATH' : 'optional', false) add('arm-none-eabi-gcc (RP2350)', onPath('arm-none-eabi-gcc', env) || Boolean(env.PICO_TOOLCHAIN_PATH), env.PICO_TOOLCHAIN_PATH || 'optional', false) add('picotool (RP2350)', onPath('picotool', env), onPath('picotool', env) ? 'on PATH' : 'optional', false) + for (const check of stm32ToolChecks(env)) add(check.name, check.ok, check.detail, false) add('swift (BLE OTA)', onPath('swift', env), onPath('swift', env) ? 'on PATH' : 'optional; macOS only', false) // gea simulate compiles the app with emcc and links with em++, and hashes // both binaries into its object-cache profile, so both are worth naming. diff --git a/src/gea.mjs b/src/gea.mjs index 34ebb20..f1ef363 100644 --- a/src/gea.mjs +++ b/src/gea.mjs @@ -18,7 +18,7 @@ const version = JSON.parse(fs.readFileSync(new URL('../package.json', import.met // The platforms board selection serves: an app declaring one of them has a // board to resolve, which is what a bare `gea build` is asking for. -const boardPlatforms = Object.freeze(['esp32', 'rp2350', 'geaos']) +const boardPlatforms = Object.freeze(['esp32', 'rp2350', 'stm32', 'geaos']) function declaresBoardTarget(app) { return boardPlatforms.some((platform) => Boolean(app?.targets?.[platform])) @@ -213,7 +213,7 @@ export async function runGea(argv, io = {}) { }) } if (target && target !== 'xbox' && knownPlatforms.includes(target) && ['build', 'flash', 'run', 'monitor', 'ota'].includes(command)) { - if (target === 'esp32' || target === 'rp2350' || target === 'geaos') { + if (boardPlatforms.includes(target)) { fail(`--target ${target} names a platform; pass --board (gea boards list) or --target (gea targets list).`, ExitCode.usage) } fail(`The ${target} build is not driven by gea; run its build script in the ${target} target project.`, ExitCode.usage) @@ -312,7 +312,7 @@ ${heading('Native desktop, mobile and watch apps (scripts ship in @geastack/appl ${heading('Build and deploy (device commands take --board , see gea boards list; with one registered board it can be left out):')} gea config --board [--app ] effective build settings and their origins (JSON) - gea build --board [--app ] build firmware (ESP-IDF / Pico SDK / geaos) [--verbose] [--output file.bin] + gea build --board [--app ] build firmware (ESP-IDF / Pico SDK / STM32 / geaos) [--verbose] [--output file.bin] gea flash --board [--app ] build + flash over USB [--monitor] [--manual-boot] [--no-reset] [--flash-baud N] --slot ota_N [--image file] stage an app image into an OTA slot only --slot-image ota_N=file ... provision several prebuilt images diff --git a/src/manifest.mjs b/src/manifest.mjs index 66be1d4..43ba981 100644 --- a/src/manifest.mjs +++ b/src/manifest.mjs @@ -161,7 +161,7 @@ export function assertTargetEnabled(ctx, app, targetOrPlatform) { fail(`App '${app.id}' does not enable target '${platform}'.`, ExitCode.targetUnavailable) } -export const knownPlatforms = Object.freeze(['web', 'esp32', 'rp2350', 'geaos', 'raspberry-pi-os', 'macos', 'ios', 'android', 'windows', 'xbox', 'pebble']) +export const knownPlatforms = Object.freeze(['web', 'esp32', 'rp2350', 'stm32', 'geaos', 'raspberry-pi-os', 'macos', 'ios', 'android', 'windows', 'xbox', 'pebble']) export function appPlatformForTarget(ctx, targetOrBoard) { return appPlatformsForTarget(ctx, targetOrBoard)[0] || '' diff --git a/src/rp2350/adapter.mjs b/src/rp2350/adapter.mjs index 6f0fe1d..3097663 100644 --- a/src/rp2350/adapter.mjs +++ b/src/rp2350/adapter.mjs @@ -8,7 +8,7 @@ import { resolvePicotoolSelection } from '../boards/usb.mjs' import { CliError, ExitCode, fail } from '../errors.mjs' import { appCmakeMeta } from '../manifest.mjs' import { formatCommand } from '../run.mjs' -import { findOnPath } from '../toolchain.mjs' +import { findOnPath, globDirs } from '../toolchain.mjs' // Raspberry Pi RP2350 boards through the Pico SDK: cmake configure + build, // then UF2 over the BOOTSEL volume or picotool. @@ -37,28 +37,6 @@ function toolchainHasRuntime(root) { return false } -// Expands `*` in any path segment, one directory level per segment: -// `~/Tools/arm-gnu-toolchain-*/extract/Payload` matches every installed version. -export function globDirs(pattern) { - const segments = pattern.split(path.sep) - let matches = [segments[0] || path.sep] - for (const segment of segments.slice(1)) { - if (!segment.includes('*')) { - matches = matches.map((dir) => path.join(dir, segment)) - continue - } - const re = new RegExp(`^${segment.split('*').map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`) - matches = matches.flatMap((dir) => { - try { - return readdirSync(dir).filter((name) => re.test(name)).sort().map((name) => path.join(dir, name)) - } catch { - return [] - } - }) - } - return matches.filter((dir) => existsSync(dir)) -} - export function prepareArmToolchain(env, stderr = () => {}) { if (env.PICO_TOOLCHAIN_PATH) { return { ...env, PATH: `${path.join(env.PICO_TOOLCHAIN_PATH, 'bin')}${path.delimiter}${env.PATH || ''}` } diff --git a/src/serial-devices.mjs b/src/serial-devices.mjs index b56ce3c..0ef0cbe 100644 --- a/src/serial-devices.mjs +++ b/src/serial-devices.mjs @@ -5,9 +5,11 @@ import path from 'node:path' import { listMacUsbCalloutPorts } from './boards/usb.mjs' // Serial devices with, where the OS can tell, the USB serial that identifies -// the board. A /dev name is never that identity -- `cu.usbmodem1101` is a -// slot number that changes on every enumeration -- so a device whose serial -// the registry does not know reports an empty one and the caller asks. +// the board and the USB vendor/product IDs that say what kind of device it is +// (four lowercase hex digits each). A /dev name is never that identity -- +// `cu.usbmodem1101` is a slot number that changes on every enumeration -- so a +// device whose serial the registry does not know reports an empty one and the +// caller asks. export function detectSerialDevices({ env = process.env, platform = process.platform, ioreg = undefined } = {}) { if (env.GEA_SERIAL_DEVICES) return parseSerialDevices(env.GEA_SERIAL_DEVICES) if (platform === 'win32') return detectWindowsSerialDevices(env) @@ -32,8 +34,9 @@ function parseSerialDevices(value) { .map((entry) => entry.trim()) .filter(Boolean) .map((entry) => { - const [devicePath, label = '', serial = ''] = entry.split('|').map((part) => part.trim()) - return normalizeDevice({ path: devicePath, label, serial }) + const [devicePath, label = '', serial = '', usbId = ''] = entry.split('|').map((part) => part.trim()) + const [vendorId, productId] = usbId.split(':') + return normalizeDevice({ path: devicePath, label, serial, vendorId, productId }) }) .filter((device) => device.path) } @@ -54,7 +57,9 @@ export function mergeRegistryDevices(devices, registry) { devices.set(entry.path, normalizeDevice({ path: entry.path, label: entry.label || known?.label || entry.path, - serial: entry.serial || known?.serial || '' + serial: entry.serial || known?.serial || '', + vendorId: entry.vendorId || known?.vendorId, + productId: entry.productId || known?.productId })) } return devices @@ -68,7 +73,8 @@ function addLinuxByIdDevices(devices) { devices.set(devicePath, normalizeDevice({ path: devicePath, label: name, - serial: serialFromLinuxById(name) + serial: serialFromLinuxById(name), + ...linuxUsbIds(devicePath) })) } } @@ -123,10 +129,13 @@ function detectWindowsSerialDevices(env) { // ID; the serial is the parent device's. export function windowsSerialPortDevice(entry) { const id = entry.PNPDeviceID || '' + const usbIds = id.match(/VID_([0-9A-F]{4})&PID_([0-9A-F]{4})/i) || [] return normalizeDevice({ path: entry.DeviceID || '', label: entry.Name || '', - serial: serialFromDeviceName(id) || (/&MI_[0-9A-F]{2}\\/i.test(id) ? serialFromDeviceName(entry.Parent || '') : '') + serial: serialFromDeviceName(id) || (/&MI_[0-9A-F]{2}\\/i.test(id) ? serialFromDeviceName(entry.Parent || '') : ''), + vendorId: usbIds[1], + productId: usbIds[2] }) } @@ -135,10 +144,29 @@ function normalizeDevice(input) { const devicePath = String(input.path || '').trim() const label = String(input.label || devicePath).trim() const serial = String(input.serial || '').trim() + const vendorId = String(input.vendorId || '').trim().toLowerCase() + const productId = String(input.productId || '').trim().toLowerCase() return { path: devicePath, label, - serial + serial, + vendorId, + productId + } +} + +// A tty's sysfs `device` is its USB interface; the USB device holding the +// vendor and product IDs is the directory above it. +function linuxUsbIds(devicePath) { + try { + const tty = path.basename(fs.realpathSync(devicePath)) + const usbInterface = fs.realpathSync(path.join('/sys/class/tty', tty, 'device')) + const usbDevice = path.dirname(usbInterface) + const vendorId = fs.readFileSync(path.join(usbDevice, 'idVendor'), 'utf8') + const productId = fs.readFileSync(path.join(usbDevice, 'idProduct'), 'utf8') + return { vendorId, productId } + } catch { + return {} } } diff --git a/src/setup-wizard.mjs b/src/setup-wizard.mjs index f8115d5..ec9a3f0 100644 --- a/src/setup-wizard.mjs +++ b/src/setup-wizard.mjs @@ -2,7 +2,7 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' -import { knownBoards } from './board-catalog.mjs' +import { boardByUsbDevice, knownBoards } from './board-catalog.mjs' import { boardConfigWritePath, loadBoardConfig } from './boards/config.mjs' import { discoverBoards, probeSerialDevice } from './commands/boards.mjs' import { discoverApps, findCurrentApp, resolveRequestedApp, targetEnabledForApp } from './manifest.mjs' @@ -15,6 +15,7 @@ import { exists, readJson, writeJson } from './fs-utils.mjs' import { BACK, ask, choose, confirm, createPrompt, ui } from './prompts.mjs' import { runExternal, runQuiet } from './run.mjs' import { detectSerialDevices } from './serial-devices.mjs' +import { stm32ToolChecks } from './stm32/toolchain.mjs' import { commandVersion } from './toolchain.mjs' const espIdfInstallTargets = 'esp32,esp32s3,esp32p4' @@ -86,19 +87,16 @@ export async function runSetupWizard(ctx, parsed, io) { return 0 } - renderStep(io, 'Toolchain', ['Checking ESP-IDF before board initialization.']) - if (boardSetup?.target && espIdfVersionForTarget(boardSetup.target) === s31EspIdf.version) { - await maybeSetupS31EspIdf(ctx, parsed, io, prompt) - } else { - await maybeSetupEspIdf(ctx, parsed, io, prompt, { targetVersion: targetIdfVersion }) - } + const toolsReady = await setupBoardToolchain(ctx, parsed, io, prompt, boardSetup, targetIdfVersion) if (option(parsed, 'install') === true) { await maybeInstallNpmDependencies(ctx, parsed, io, prompt, { force: true }) } - await maybeInitializeBoardTarget(ctx, parsed, io, prompt, boardSetup) + if (toolsReady) await maybeInitializeBoardTarget(ctx, parsed, io, prompt, boardSetup) if (boardSetup?.flashReady) { const appFlag = boardSetup.app && boardSetup.app.root !== findCurrentApp(ctx.cwd)?.root ? ` --app ${boardSetup.app.id}` : '' - ui(io).outro(`Ready: gea flash${boardFlag(ctx, boardSetup.alias)}${appFlag} --monitor`) + const flags = `${boardFlag(ctx, boardSetup.alias)}${appFlag}` + if (boardSetup.adapter === 'stm32-cube') ui(io).message(`Build only: gea build${flags}`) + ui(io).outro(toolsReady ? `Ready: gea flash${flags} --monitor` : `Install the missing tools, then: gea flash${flags} --monitor`) } else { ui(io).outro('Profile saved. Add or select a target backend before flashing this board.') } @@ -109,7 +107,8 @@ export async function runSetupWizard(ctx, parsed, io) { } async function setupKnownBoard(ctx, parsed, io, prompt) { - renderStep(io, 'Board', ['Pick a board GeaStack can already flash.']) + const detected = detectKnownBoard(io) + renderStep(io, 'Board', ['Pick a board GeaStack can already flash.', detected ? `Detected over USB: ${detected.label}` : '']) const boardId = await choose(prompt, { message: 'Which board do you have?', choices: knownBoards.map((board) => ({ @@ -117,7 +116,7 @@ async function setupKnownBoard(ctx, parsed, io, prompt) { label: board.label, description: boardDescription(board) })), - defaultValue: knownBoards[0].id, + defaultValue: detected?.id || knownBoards[0].id, back: true }) if (boardId === BACK) return BACK @@ -133,9 +132,11 @@ async function setupKnownBoard(ctx, parsed, io, prompt) { }) renderStep(io, 'Connection', ['Use a detected serial device, enter one manually, or skip it for now.']) const usbIdentity = await selectUsbIdentity(prompt, io, ctx, { - message: 'The USB serial identifies this board whatever port it lands on.' + message: 'The USB serial identifies this board whatever port it lands on.', + board }) - const otaHost = await ask(prompt, { + // STM32 boards have no network stack to update over. + const otaHost = board.adapter === 'stm32-cube' ? '' : await ask(prompt, { message: 'OTA host/IP (optional)', defaultValue: '', validate: validateOtaHost @@ -159,7 +160,14 @@ async function setupKnownBoard(ctx, parsed, io, prompt) { writeJsonEnsured(configPath, config) ui(io).success(`Wrote board alias '${alias}' to ${configPath}`) ui(io).message(`Target: ${board.target}`) - return { alias, target: board.target, flashReady: true } + return { alias, target: board.target, adapter: board.adapter, flashReady: true } +} + +// The catalog board a connected device enumerates as (an STM32 board's +// ST-LINK), offered as the default answer; null when none is recognised. +function detectKnownBoard(io) { + const devices = detectSerialDevices({ env: io.env || process.env }) + return devices.map(boardByUsbDevice).find(Boolean) || null } async function setupCustomBoard(ctx, parsed, io, prompt) { @@ -371,6 +379,33 @@ function describeObject(value) { return entries.length ? entries.join('; ') : 'not set' } +// STM32 boards check their build and flash tools; every other board checks +// ESP-IDF. True when the board's tools are in place, so its build directory +// can be configured. +async function setupBoardToolchain(ctx, parsed, io, prompt, boardSetup, targetIdfVersion) { + if (boardSetup?.adapter === 'stm32-cube') return checkStm32Tools(io) + + renderStep(io, 'Toolchain', ['Checking ESP-IDF before board initialization.']) + if (boardSetup?.target && espIdfVersionForTarget(boardSetup.target) === s31EspIdf.version) { + await maybeSetupS31EspIdf(ctx, parsed, io, prompt) + } else { + await maybeSetupEspIdf(ctx, parsed, io, prompt, { targetVersion: targetIdfVersion }) + } + return true +} + +// The same checks as `gea doctor`, each missing tool with its install command. +function checkStm32Tools(io) { + renderStep(io, 'Toolchain', ['Checking the STM32 build and flash tools (gea doctor runs the same checks).']) + const checks = stm32ToolChecks(io.env || process.env) + for (const check of checks) { + const line = `${check.name}: ${check.detail}` + if (check.ok) ui(io).success(line) + else ui(io).warn(line) + } + return checks.every((check) => check.ok) +} + async function maybeInstallNpmDependencies(ctx, parsed, io, prompt, { force = false } = {}) { const install = force || option(parsed, 'install') === true || await confirm(prompt, { message: 'Install npm dependencies now if package.json exists?', @@ -604,7 +639,7 @@ function detectEspIdf(env, targetVersion, explicitDir = '') { // across replugging and distinguishes two identical boards, but devices that // publish none -- the ESP32-S3's built-in USB Serial/JTAG among them -- used to // be filtered out here and could not be registered at all. -async function selectUsbIdentity(prompt, io, ctx, { message }) { +async function selectUsbIdentity(prompt, io, ctx, { message, board = null }) { const env = io.env || process.env ui(io).message(message) const connected = await confirm(prompt, { message: 'Is the board connected over USB right now?', defaultValue: true }) @@ -638,6 +673,7 @@ async function selectUsbIdentity(prompt, io, ctx, { message }) { ui(io).success(`Detected ${describe(results[0])}`) return identityOf(results[0]) } + const recognised = results.findIndex((result) => board && result.knownBoard === board.id) const selected = await choose(prompt, { message: 'Several USB devices are connected. Which one is this board?', choices: [ @@ -645,7 +681,7 @@ async function selectUsbIdentity(prompt, io, ctx, { message }) { { value: 'retry', label: 'Unplug the others and detect again' }, { value: 'skip', label: 'Skip for now' } ], - defaultValue: 'device-0' + defaultValue: `device-${Math.max(0, recognised)}` }) if (selected === 'skip') return {} if (selected === 'retry') continue diff --git a/src/stm32/adapter.mjs b/src/stm32/adapter.mjs new file mode 100644 index 0000000..d2402df --- /dev/null +++ b/src/stm32/adapter.mjs @@ -0,0 +1,97 @@ +import { existsSync } from 'node:fs' +import path from 'node:path' + +import { resolveBuildConfig, writeBuildConfig } from '../build-config.mjs' +import { ExitCode, fail } from '../errors.mjs' +import { appCmakeMeta } from '../manifest.mjs' +import { runExternal } from '../run.mjs' +import { onPath } from '../toolchain.mjs' +import { armToolchainRoot, findArmGcc } from './toolchain.mjs' + +// STMicroelectronics STM32 boards: cmake configure + build with the Arm GNU +// toolchain against the target's CMSIS headers, then one openocd `program` +// of the ELF, which writes internal flash and the board's QSPI flash +// (execute-in-place app code) in a single pass. + +const boards = { + 'stm32f746g-disco': { stem: 'gea_stm32f746g_disco', openocdConfig: 'board/stm32f746g-disco.cfg' } +} + +function boardFor(selection) { + const board = boards[selection.target] + if (!board) fail(`No STM32 firmware target is registered for '${selection.target}'.`, ExitCode.usage) + + return board +} + +export function firmwareTarget(selection, app) { + const { stem } = boardFor(selection) + return app ? `${stem}_app` : `${stem}_bringup` +} + +export function stm32BuildDir(ctx, selection) { + return path.join(ctx.buildRoot, selection.target) +} + +// Fails before CMake runs when a build tool is missing. The toolchain found is +// handed to the target's CMake toolchain file as ARM_TOOLCHAIN_PATH, which +// also covers the Arm installer's folders that are not on PATH. +function buildToolsEnv(env) { + const gcc = findArmGcc(env) + if (!gcc) { + fail('STM32 builds need the Arm GNU toolchain: put arm-none-eabi-gcc on PATH or set ARM_TOOLCHAIN_PATH to its root.', ExitCode.missingDependency) + } + + if (!env.CMAKE && !onPath('cmake', env)) { + fail('STM32 builds need CMake 3.20+. Install cmake or set CMAKE=/path/to/cmake.', ExitCode.missingDependency) + } + + return { ...env, ARM_TOOLCHAIN_PATH: armToolchainRoot(gcc) } +} + +export function buildStm32({ ctx, selection, app = null, env, dryRun = false, stdout, configureOnly = false }) { + const toolsEnv = dryRun ? env : buildToolsEnv(env) + const resolvedBuild = resolveBuildConfig({ app, platform: 'stm32', board: selection.target, targetBase: selection.targetBase, targetsRoot: ctx.targetsRoot, env }) + const cmake = env.CMAKE || 'cmake' + const buildDir = stm32BuildDir(ctx, selection) + const buildConfig = writeBuildConfig(buildDir, resolvedBuild) + const configure = [ + '-S', selection.targetDir, + '-B', buildDir, + `-DGEA_BUILD_CONFIG_FILE=${buildConfig.cmake}`, + `-DGEA_BUILD_CONFIG_JSON=${buildConfig.json}`, + `-DGEA_BUILD_CONFIG_HASH=${buildConfig.hash}`, + `-DGEA_EMBEDDED_APP_DEFINES=${resolvedBuild.defines.join(';')}` + ] + if (resolvedBuild.cssDevicePixelRatio) configure.push(`-DGEA_EMBEDDED_CSS_DEVICE_PIXEL_RATIO=${resolvedBuild.cssDevicePixelRatio}`) + + const buildEnv = { ...toolsEnv } + if (app) { + const meta = appCmakeMeta(ctx, app) + configure.push(`-DGEA_EMBEDDED_APP=${app.id}`, `-DGEA_EMBEDDED_APP_META=${meta}`) + buildEnv.GEA_EMBEDDED_APP = app.id + buildEnv.GEA_EMBEDDED_APP_META = meta + // gea.compilerPlugins: the app's own geatsc plugins, as absolute paths. + buildEnv.GEA_EMBEDDED_APP_GEATSC_PLUGINS = app.compilerPlugins.map((plugin) => path.join(app.root, plugin)).join(';') + } + + const step = { cwd: selection.targetDir, env: buildEnv, dryRun, stdout, failureCode: ExitCode.buildFailed } + runExternal(cmake, configure, step) + if (configureOnly) return { buildDir, elf: '' } + + const target = firmwareTarget(selection, app) + runExternal(cmake, ['--build', buildDir, '--target', target, '--parallel'], step) + return { buildDir, elf: path.join(buildDir, `${target}.elf`) } +} + +// The ST-LINK's USB serial number doubles as the openocd adapter selector, so +// an alias registered for `gea monitor` also pins which board gets flashed. +export function flashStm32({ selection, elf, env, dryRun = false, stdout }) { + const { openocdConfig } = boardFor(selection) + if (!dryRun && !existsSync(elf)) fail(`ELF image not found after build: ${elf}`, ExitCode.deployFailed) + if (!dryRun && !onPath('openocd', env)) fail('Flashing STM32 boards needs openocd 0.12+ (brew install open-ocd).', ExitCode.missingDependency) + + const adapterSerial = selection.usbSerial ? ['-c', `adapter serial ${selection.usbSerial}`] : [] + const args = ['-f', openocdConfig, ...adapterSerial, '-c', `program {${elf}} verify reset exit`] + runExternal('openocd', args, { env, dryRun, stdout, failureCode: ExitCode.deployFailed }) +} diff --git a/src/stm32/toolchain.mjs b/src/stm32/toolchain.mjs new file mode 100644 index 0000000..9871639 --- /dev/null +++ b/src/stm32/toolchain.mjs @@ -0,0 +1,84 @@ +import { spawnSync } from 'node:child_process' +import path from 'node:path' + +import { envPath, withEnvPath } from '../env-path.mjs' +import { findOnPath, globDirs } from '../toolchain.mjs' + +// The tools an STM32 board needs: the Arm GNU toolchain and CMake to build it, +// openocd to flash it. `gea doctor`, `gea setup` and the stm32-cube adapter all +// ask here, so they agree on what is installed. + +const stm32Tools = [ + { + name: 'arm-none-eabi-gcc (STM32)', + find: findArmGcc, + versionArgs: ['-dumpversion'], + minimum: '', + install: 'brew install --cask gcc-arm-embedded, or the Arm GNU Toolchain from developer.arm.com (ARM_TOOLCHAIN_PATH names its root)' + }, + { + name: 'cmake 3.20+ (STM32)', + find: (env) => env.CMAKE || findOnPath('cmake', env), + versionArgs: ['--version'], + minimum: '3.20', + install: 'brew install cmake, or set CMAKE=/path/to/cmake' + }, + { + name: 'openocd 0.12+ (STM32 flashing)', + find: (env) => findOnPath('openocd', env), + versionArgs: ['--version'], + minimum: '0.12', + install: 'brew install open-ocd' + } +] + +// arm-none-eabi-gcc where the target's CMake toolchain file looks for it +// (ARM_TOOLCHAIN_PATH, then PATH), then in the folders the Arm installer +// creates on macOS; '' when there is none. +export function findArmGcc(env = process.env) { + const dirs = [ + env.ARM_TOOLCHAIN_PATH ? path.join(env.ARM_TOOLCHAIN_PATH, 'bin') : '', + envPath(env), + ...globDirs('/Applications/ArmGNUToolchain/*/arm-none-eabi/bin') + ] + const searchEnv = withEnvPath(env, dirs.filter(Boolean).join(path.delimiter)) + return findOnPath('arm-none-eabi-gcc', searchEnv) +} + +// The toolchain root a found compiler lives under, as ARM_TOOLCHAIN_PATH spells it. +export function armToolchainRoot(gcc) { + return path.dirname(path.dirname(gcc)) +} + +// One { name, ok, detail } per tool, `detail` naming the install command when +// the tool is missing or too old. +export function stm32ToolChecks(env = process.env) { + return stm32Tools.map((tool) => toolCheck(tool, env)) +} + +function toolCheck(tool, env) { + const file = tool.find(env) + if (!file) return { name: tool.name, ok: false, detail: `not found; ${tool.install}` } + + const version = toolVersion(file, tool.versionArgs, env) + if (!version) return { name: tool.name, ok: false, detail: `${file} reports no version; ${tool.install}` } + + if (tool.minimum && !versionAtLeast(version, tool.minimum)) { + return { name: tool.name, ok: false, detail: `${version} at ${file}, needs ${tool.minimum}+; ${tool.install}` } + } + + return { name: tool.name, ok: true, detail: `${version} at ${file}` } +} + +// The first x.y[.z] a tool prints for its version. openocd prints it on stderr. +function toolVersion(file, args, env) { + const result = spawnSync(file, args, { encoding: 'utf8', env }) + const output = `${result.stdout || ''}${result.stderr || ''}` + return output.match(/\d+\.\d+(\.\d+)?/)?.[0] || '' +} + +function versionAtLeast(version, minimum) { + const [major, minor] = version.split('.').map(Number) + const [minimumMajor, minimumMinor] = minimum.split('.').map(Number) + return major > minimumMajor || (major === minimumMajor && minor >= minimumMinor) +} diff --git a/src/toolchain.mjs b/src/toolchain.mjs index 7e27db3..4b8346d 100644 --- a/src/toolchain.mjs +++ b/src/toolchain.mjs @@ -1,6 +1,6 @@ import path from 'node:path' import { execFileSync } from 'node:child_process' -import { existsSync } from 'node:fs' +import { existsSync, readdirSync } from 'node:fs' import { envPath } from './env-path.mjs' @@ -47,3 +47,25 @@ function executableNames(name, env) { const extensions = String(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean) return [name, ...extensions.map((extension) => name + extension.toLowerCase())] } + +// Expands `*` in any path segment, one directory level per segment: +// `~/Tools/arm-gnu-toolchain-*/extract/Payload` matches every installed version. +export function globDirs(pattern) { + const segments = pattern.split(path.sep) + let matches = [segments[0] || path.sep] + for (const segment of segments.slice(1)) { + if (!segment.includes('*')) { + matches = matches.map((dir) => path.join(dir, segment)) + continue + } + const re = new RegExp(`^${segment.split('*').map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`) + matches = matches.flatMap((dir) => { + try { + return readdirSync(dir).filter((name) => re.test(name)).sort().map((name) => path.join(dir, name)) + } catch { + return [] + } + }) + } + return matches.filter((dir) => existsSync(dir)) +} diff --git a/test/boards.test.mjs b/test/boards.test.mjs index e52f825..51a8b9e 100644 --- a/test/boards.test.mjs +++ b/test/boards.test.mjs @@ -18,6 +18,7 @@ const targets = { 'esp32-p4-waveshare-touch-lcd-7': { adapter: 'esp32-idf', targetDir: '/pkg/targets/esp32-p4-waveshare-touch-lcd-7', flashSize: '32MB', appPlatform: 'esp32', idfTarget: 'esp32p4', esptoolChip: 'esp32p4', ipcTaskStackSize: '4096' }, 'rp2350-waveshare-touch-amoled-2.41': { adapter: 'rp2350-pico', targetDir: '/pkg/targets/rp2350-waveshare-touch-amoled-2.41', flashSize: '16MB', appPlatform: 'rp2350' }, 'rp2350-tufty-2350': { adapter: 'rp2350-pico', targetDir: '/pkg/targets/rp2350-tufty-2350', flashSize: '16MB', appPlatform: 'rp2350', compatibleAppPlatforms: ['esp32'] }, + 'stm32f746g-disco': { adapter: 'stm32-cube', targetDir: '/pkg/targets/stm32f746g-disco', flashSize: '1MB', appPlatform: 'stm32' }, geaos: { adapter: 'geaos-linux', targetDir: '/geaos/targets/geaos', appPlatform: 'geaos' }, 'lokmat-applpmax': { adapter: 'geaos-arm64', targetDir: '/geaos/targets/geaos', appPlatform: 'geaos' } } @@ -52,6 +53,10 @@ test('target metadata replaces one-script-per-board wrappers', () => { assert.equal(tufty.adapter, 'rp2350-pico') assert.deepEqual(tufty.compatibleAppPlatforms, ['esp32']) + const disco = resolveBoardSelection({ targetName: 'stm32f746g-disco', targets, config, usbSerialResolver: neverTouchHardware }) + assert.equal(disco.adapter, 'stm32-cube') + assert.equal(disco.appPlatform, 'stm32') + const lokmat = resolveBoardSelection({ boardName: 'lokmat', targets, config, usbSerialResolver: neverTouchHardware }) assert.equal(lokmat.adapter, 'geaos-arm64') assert.equal(lokmat.mtkWorkdir, '/w') diff --git a/test/context-manifest.test.mjs b/test/context-manifest.test.mjs index 7285a0a..74a2484 100644 --- a/test/context-manifest.test.mjs +++ b/test/context-manifest.test.mjs @@ -134,6 +134,8 @@ test('manifest helpers validate current app and installed target metadata', (t) assert.deepEqual(appPlatformsForTarget(ctx, 'tufty'), ['rp2350', 'esp32']) assert.equal(targetEnabledForApp(ctx, current, 'amoled'), true) assert.equal(targetEnabledForApp(ctx, current, 'tufty'), true) + assert.equal(appPlatformForTarget(ctx, 'disco'), 'stm32') + assert.equal(targetEnabledForApp(ctx, current, 'disco'), true) const bad = findCurrentApp(fixture.badAppDir) assert.deepEqual(validateApp(bad), ['gea.entry does not exist: missing.tsx']) diff --git a/test/gea.test.mjs b/test/gea.test.mjs index db008c6..e7fed2b 100644 --- a/test/gea.test.mjs +++ b/test/gea.test.mjs @@ -69,7 +69,7 @@ test('apps, targets and boards are listed from the current npm project', async ( const targets = capture() await runGea(['targets', 'list'], { ...targets.io, cwd: fixture.root }) - assert.deepEqual(targets.out.map((line) => line.split('\t')[0]), ['esp32-s3', 'esp32-s3-touch-amoled-2.06', 'geaos', 'rp2350-tufty-2350']) + assert.deepEqual(targets.out.map((line) => line.split('\t')[0]), ['esp32-s3', 'esp32-s3-touch-amoled-2.06', 'geaos', 'rp2350-tufty-2350', 'stm32f746g-disco']) assert.match(targets.out[1], new RegExp(`\tesp32-idf\t${escapeRegex(fixture.esp32Target)}$`)) const boards = capture() @@ -634,6 +634,22 @@ test('rp2350 boards build with the Pico SDK toolchain and flash a UF2', async (t assert.match(dry.out, new RegExp(`^${commandText(fixture.cmake, '--build', buildDir, '--target', 'gea_rp2350_tufty_2350_app')}$`, 'm')) }) +test('stm32 boards build with CMake and flash the ELF through openocd', async (t) => { + const fixture = createFixture(t) + const buildDir = escapeRegex(path.join(fixture.appDir, '.gea/build/stm32f746g-disco')) + const build = await gea(['build', '--board', 'disco', '--dry-run'], fixture) + assert.equal(build.code, 0, build.err) + assert.match(build.out, new RegExp(`^cmake -S ${escapeRegex(fixture.installed('targets'))}/targets/stm32f746g-disco -B ${buildDir} -DGEA_BUILD_CONFIG_FILE=${buildDir}/gea-build-config.cmake -DGEA_BUILD_CONFIG_JSON=${buildDir}/gea-build-config.json -DGEA_BUILD_CONFIG_HASH=[a-f0-9]{64} -DGEA_EMBEDDED_APP_DEFINES= -DGEA_EMBEDDED_APP=watch '-DGEA_EMBEDDED_APP_META=${escapeRegex(fixture.appDir)};index.tsx;gea'$`, 'm')) + assert.match(build.out, /^cmake --build .*stm32f746g-disco --target gea_stm32f746g_disco_app --parallel$/m) + const config = JSON.parse(fs.readFileSync(path.join(fixture.appDir, '.gea/build/stm32f746g-disco/gea-build-config.json'))) + assert.equal(config.platform, 'stm32') + assert.equal(config.board, 'stm32f746g-disco') + + const flash = await gea(['flash', '--board', 'disco', '--dry-run'], fixture) + assert.equal(flash.code, 0, flash.err) + assert.match(flash.out, new RegExp(`^openocd -f board/stm32f746g-disco\\.cfg -c 'adapter serial STLINK123' -c 'program \\{${buildDir}/gea_stm32f746g_disco_app\\.elf\\} verify reset exit'$`, 'm')) +}) + test('setup builds a known target in configure-only mode and can write a local board alias', async (t) => { const fixture = createFixture(t) const routed = await gea(['setup', '--board', 'amoled', '--dry-run'], fixture) diff --git a/test/helpers/fixture.mjs b/test/helpers/fixture.mjs index 60f6520..7817b0f 100644 --- a/test/helpers/fixture.mjs +++ b/test/helpers/fixture.mjs @@ -14,7 +14,7 @@ export const venvBinDir = windows ? 'Scripts' : 'bin' export const venvPython = windows ? path.join('Scripts', 'python.exe') : path.join('bin', 'python') // A complete fake project: installed @geastack packages (targets with one -// ESP32 and one RP2350 project, a chips catalog, a compiler whose `analyze` +// ESP32, one RP2350 and one STM32 project, a chips catalog, a compiler whose `analyze` // reports fixed bindings), a fake ESP-IDF install whose python/cmake/ninja // record every invocation, and a boards.json with USB, WiFi and manual-restart // boards. Tests drive the real CLI against it and read the call log. @@ -48,7 +48,7 @@ export function createFixture(t, options = {}) { writeApp(root, 'apps/watch', { id: 'watch', name: 'Watch', - targets: { web: true, esp32: true, rp2350: false, geaos: true, macos: true, ios: true, android: true, windows: true } + targets: { web: true, esp32: true, rp2350: false, stm32: true, geaos: true, macos: true, ios: true, android: true, windows: true } }) writeApp(root, 'apps/web-only', { id: 'web-only', @@ -76,6 +76,9 @@ export function createFixture(t, options = {}) { const rp2350Target = path.join(installed('targets'), 'targets/rp2350-tufty-2350') fs.mkdirSync(rp2350Target, { recursive: true }) fs.writeFileSync(path.join(rp2350Target, 'CMakeLists.txt'), 'project(gea_rp2350)\n') + const stm32Target = path.join(installed('targets'), 'targets/stm32f746g-disco') + fs.mkdirSync(stm32Target, { recursive: true }) + fs.writeFileSync(path.join(stm32Target, 'CMakeLists.txt'), 'project(gea_stm32)\n') writeJson(path.join(installed('targets'), 'targets.json'), { 'esp32-s3': { @@ -101,6 +104,12 @@ export function createFixture(t, options = {}) { appPlatform: 'rp2350', compatibleAppPlatforms: ['esp32'] }, + 'stm32f746g-disco': { + adapter: 'stm32-cube', + targetPath: 'targets/stm32f746g-disco', + flashSize: '1MB', + appPlatform: 'stm32' + }, // A target whose project lives in ANOTHER package states that package and // a path relative to it, so nothing depends on how npm lays the two out. geaos: { @@ -140,6 +149,11 @@ export function createFixture(t, options = {}) { target: 'rp2350-tufty-2350', adapter: 'rp2350-pico' }, + disco: { + target: 'stm32f746g-disco', + adapter: 'stm32-cube', + transports: { usbSerial: { serial: 'STLINK123' } } + }, linux: { target: 'geaos', adapter: 'geaos-linux' diff --git a/test/rp2350-toolchain-glob.test.mjs b/test/rp2350-toolchain-glob.test.mjs index 269043f..dfd06e3 100644 --- a/test/rp2350-toolchain-glob.test.mjs +++ b/test/rp2350-toolchain-glob.test.mjs @@ -8,7 +8,7 @@ import os from 'node:os' import path from 'node:path' import { test } from 'node:test' -import { globDirs } from '../src/rp2350/adapter.mjs' +import { globDirs } from '../src/toolchain.mjs' test('globDirs expands a wildcard in a middle segment', () => { const root = mkdtempSync(path.join(os.tmpdir(), 'gea-glob-')) diff --git a/test/serial-devices.test.mjs b/test/serial-devices.test.mjs index fea572c..1b04d32 100644 --- a/test/serial-devices.test.mjs +++ b/test/serial-devices.test.mjs @@ -15,7 +15,9 @@ test('serial device detection supports deterministic setup wizard fixtures', () assert.deepEqual(devices[0], { path: '/dev/cu.usbmodem101', label: 'ESP32-S3 USB/JTAG', - serial: 'USB123' + serial: 'USB123', + vendorId: '', + productId: '' }) assert.equal(formatSerialDevice(devices[0]), '/dev/cu.usbmodem101 - ESP32-S3 USB/JTAG - serial USB123') assert.equal(devices[1].path, '/dev/ttyACM0') @@ -43,8 +45,8 @@ test('macOS ports take their serial from the USB registry, never from the usbmod ].join('\n') const registry = listMacUsbCalloutPorts(ioreg) assert.deepEqual(registry, [ - { path: '/dev/cu.usbmodem1101', serial: '80:B5:4E:DA:73:88', label: 'USB JTAG_serial debug unit' }, - { path: '/dev/cu.usbmodem1201', serial: 'fa59949adbb4802f', label: 'Tufty 2350' } + { path: '/dev/cu.usbmodem1101', serial: '80:B5:4E:DA:73:88', label: 'USB JTAG_serial debug unit', vendorId: '', productId: '' }, + { path: '/dev/cu.usbmodem1201', serial: 'fa59949adbb4802f', label: 'Tufty 2350', vendorId: '', productId: '' } ]) const devices = new Map([ diff --git a/test/stm32-board-catalog.test.mjs b/test/stm32-board-catalog.test.mjs new file mode 100644 index 0000000..a05d950 --- /dev/null +++ b/test/stm32-board-catalog.test.mjs @@ -0,0 +1,20 @@ +// The board catalog and the firmware-stem mapping are this package's: the gea +// CLI is what resolves an alias to a target and a firmware image. +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { knownBoards } from '../src/board-catalog.mjs' +import { firmwareTarget } from '../src/stm32/adapter.mjs' + +test('the catalog knows the STM32F746G-Discovery', () => { + const disco = knownBoards.find((board) => board.target === 'stm32f746g-disco') + assert.equal(disco.adapter, 'stm32-cube') + assert.equal(disco.mcu, 'STM32F746NG') + assert.match(disco.capabilities.display, /480x272/) +}) + +test('the STM32 adapter knows the Discovery firmware stems', () => { + const selection = { target: 'stm32f746g-disco' } + assert.equal(firmwareTarget(selection, { id: 'watch' }), 'gea_stm32f746g_disco_app') + assert.equal(firmwareTarget(selection, null), 'gea_stm32f746g_disco_bringup') +}) diff --git a/test/stm32-setup.test.mjs b/test/stm32-setup.test.mjs new file mode 100644 index 0000000..9ec73c3 --- /dev/null +++ b/test/stm32-setup.test.mjs @@ -0,0 +1,158 @@ +// A new STM32 user's path: gea doctor names the STM32 tools and how to install +// them, the board's ST-LINK is recognised by its USB IDs, and gea setup +// registers the board under the ST-LINK's serial. +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { boardByUsbDevice } from '../src/board-catalog.mjs' +import { listMacUsbCalloutPorts } from '../src/boards/usb.mjs' +import { ExitCode } from '../src/errors.mjs' +import { runGea } from '../src/gea.mjs' +import { detectSerialDevices, windowsSerialPortDevice } from '../src/serial-devices.mjs' +import { capture, createFixture, readJson, scriptedPrompt, withPathPrefix, writeFakeCommand } from './helpers/fixture.mjs' + +const stlinkSerial = '066AFF565588494867122023' +const stlinkDevice = `/dev/cu.usbmodem1103|STM32 STLink|${stlinkSerial}|0483:374b` + +// Fake STM32 tools in their own toolchain root: bin/arm-none-eabi-gcc, cmake, +// and openocd, which prints its version on stderr as the real one does. +function fakeStm32Tools(t, { openocd = '0.12.0' } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gea-stm32-tools-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const bin = path.join(root, 'bin') + return { + root, + bin, + gcc: fakeTool(bin, 'arm-none-eabi-gcc', '15.2.1', 'stdout'), + cmake: fakeTool(bin, 'cmake', 'cmake version 3.30.0', 'stdout'), + openocd: openocd ? fakeTool(bin, 'openocd', `Open On-Chip Debugger ${openocd}`, 'stderr') : '' + } +} + +function fakeTool(bin, name, output, stream) { + const redirect = stream === 'stderr' ? ' >&2' : '' + return writeFakeCommand(bin, name, { + bash: `#!/usr/bin/env bash\nprintf '%s\\n' ${JSON.stringify(output)}${redirect}\n`, + node: `process.${stream}.write(${JSON.stringify(`${output}\n`)})\n` + }) +} + +// The fixture's environment with only the fake STM32 tools in front, and +// ARM_TOOLCHAIN_PATH naming them so an Arm toolchain installed on this +// machine is never the one found. +function stm32Env(fixture, tools, extra = {}) { + return { ...withPathPrefix(fixture.env, tools.bin), ARM_TOOLCHAIN_PATH: tools.root, ...extra } +} + +async function doctor(fixture, env, args = []) { + const out = capture() + const code = await runGea(['doctor', '--json', ...args], { ...out.io, cwd: fixture.appDir, env }) + const checks = JSON.parse(out.out.join('\n')).checks + return { code, check: (name) => checks.find((entry) => entry.name === name) } +} + +test('doctor reports the STM32 build and flash tools, optional unless --strict', async (t) => { + const fixture = createFixture(t) + const tools = fakeStm32Tools(t) + const found = await doctor(fixture, stm32Env(fixture, tools)) + assert.deepEqual(found.check('arm-none-eabi-gcc (STM32)'), { name: 'arm-none-eabi-gcc (STM32)', ok: true, detail: `15.2.1 at ${tools.gcc}`, required: false }) + assert.equal(found.check('cmake 3.20+ (STM32)').detail, `3.30.0 at ${tools.cmake}`) + assert.equal(found.check('openocd 0.12+ (STM32 flashing)').detail, `0.12.0 at ${tools.openocd}`) + + const old = await doctor(fixture, stm32Env(fixture, fakeStm32Tools(t, { openocd: '0.11.0' }))) + assert.equal(old.code, 0) + assert.equal(old.check('openocd 0.12+ (STM32 flashing)').ok, false) + assert.match(old.check('openocd 0.12+ (STM32 flashing)').detail, /^0\.11\.0 at .*, needs 0\.12\+; brew install open-ocd$/) + + const missing = await doctor(fixture, stm32Env(fixture, fakeStm32Tools(t, { openocd: '' })), ['--strict']) + assert.equal(missing.code, ExitCode.missingDependency) + assert.equal(missing.check('openocd 0.12+ (STM32 flashing)').detail, 'not found; brew install open-ocd') +}) + +test('an ST-LINK is recognised by its USB IDs on every platform', () => { + const ioreg = () => [ + '+-o Root ', + ' +-o STM32 STLink@01100000 ', + ' | "idProduct" = 14155', + ' | "idVendor" = 1155', + ' | "USB Product Name" = "STM32 STLink"', + ` | "USB Serial Number" = "${stlinkSerial}"`, + ' | +-o IOUSBHostInterface@2 ', + ' | +-o AppleUSBACMData ', + ' | +-o IOSerialBSDClient ', + ' | "IOCalloutDevice" = "/dev/cu.usbmodem1103"' + ].join('\n') + const [mac] = listMacUsbCalloutPorts(ioreg) + assert.deepEqual(mac, { path: '/dev/cu.usbmodem1103', serial: stlinkSerial, label: 'STM32 STLink', vendorId: '0483', productId: '374b' }) + + const windows = windowsSerialPortDevice({ + DeviceID: 'COM5', + Name: 'STMicroelectronics STLink Virtual COM Port (COM5)', + PNPDeviceID: 'USB\\VID_0483&PID_374B&MI_02\\6&1A2B3C4D&0&0002', + Parent: `USB\\VID_0483&PID_374B\\${stlinkSerial}` + }) + assert.deepEqual([windows.serial, windows.vendorId, windows.productId], [stlinkSerial, '0483', '374b']) + + const [declared] = detectSerialDevices({ env: { GEA_SERIAL_DEVICES: stlinkDevice } }) + for (const device of [mac, windows, declared]) { + assert.equal(boardByUsbDevice(device)?.target, 'stm32f746g-disco') + } + assert.equal(boardByUsbDevice({ vendorId: '303a', productId: '1001' }), null) + assert.equal(boardByUsbDevice({ vendorId: '', productId: '' }), null) +}) + +test('boards discover names the STM32 board behind an unregistered ST-LINK', async (t) => { + const fixture = createFixture(t) + const out = capture() + const env = { ...fixture.env, GEA_SERIAL_DEVICES: stlinkDevice } + assert.equal(await runGea(['boards', 'discover'], { ...out.io, cwd: fixture.appDir, env }), 0) + assert.match(out.out.join('\n'), new RegExp(`usbmodem1103 not configured, STMicroelectronics STM32F746G-Discovery serial ${stlinkSerial}`)) +}) + +test('setup offers the STM32 board behind a connected ST-LINK and registers its serial', async (t) => { + const fixture = createFixture(t) + const tools = fakeStm32Tools(t) + const out = capture() + // Board: the detected default; alias; connected over USB; save. + const prompt = scriptedPrompt(['1', '', 'bench-disco', 'y', 'y']) + const code = await runGea(['setup', '--dry-run'], { + ...out.io, + prompt, + cwd: fixture.appDir, + env: stm32Env(fixture, tools, { GEA_SERIAL_DEVICES: stlinkDevice }) + }) + assert.equal(code, 0, out.err.join('\n')) + + const board = readJson(path.join(fixture.root, '.geastack/boards.json'))['bench-disco'] + assert.deepEqual(board, { target: 'stm32f746g-disco', adapter: 'stm32-cube', transports: { usbSerial: { serial: stlinkSerial } } }) + assert.doesNotMatch(prompt.questions.join('\n'), /OTA host/, 'an STM32 board has no network to update over') + + const text = out.out.join('\n') + assert.match(text, /Detected over USB: STMicroelectronics STM32F746G-Discovery/) + assert.match(text, /openocd 0\.12\+ \(STM32 flashing\): 0\.12\.0 at /) + assert.match(text, /Initializing board target 'bench-disco' for app 'watch'/) + assert.match(text, /Build only: gea build --board bench-disco/) + assert.match(text, /Ready: gea flash --board bench-disco --monitor/) +}) + +test('setup names the missing STM32 tool and leaves the build unconfigured', async (t) => { + const fixture = createFixture(t) + const tools = fakeStm32Tools(t, { openocd: '' }) + const out = capture() + const prompt = scriptedPrompt(['1', '', 'bench-disco', 'y', 'y']) + const code = await runGea(['setup', '--dry-run'], { + ...out.io, + prompt, + cwd: fixture.appDir, + env: stm32Env(fixture, tools, { GEA_SERIAL_DEVICES: stlinkDevice }) + }) + assert.equal(code, 0, out.err.join('\n')) + + const text = out.out.join('\n') + assert.match(text, /openocd 0\.12\+ \(STM32 flashing\): not found; brew install open-ocd/) + assert.doesNotMatch(text, /Initializing board target/) + assert.match(text, /Install the missing tools, then: gea flash --board bench-disco --monitor/) +}) diff --git a/test/usb-identity.test.mjs b/test/usb-identity.test.mjs index 73eea66..3403fbd 100644 --- a/test/usb-identity.test.mjs +++ b/test/usb-identity.test.mjs @@ -59,8 +59,8 @@ test('macOS inherits USB identity through nested interfaces drawn with spaces', assert.equal(resolveUsbSerialPort({ serial: amoled18 }, { platform: 'darwin', ioreg }), '/dev/cu.usbmodem101') assert.equal(resolveUsbSerialPort({ serial: kitt }, { platform: 'darwin', ioreg }), '/dev/cu.usbmodem201') assert.deepEqual(listMacUsbCalloutPorts(ioreg), [ - { path: '/dev/cu.usbmodem101', serial: amoled18, label: 'AMOLED' }, - { path: '/dev/cu.usbmodem201', serial: kitt, label: 'KITT' } + { path: '/dev/cu.usbmodem101', serial: amoled18, label: 'AMOLED', vendorId: '', productId: '' }, + { path: '/dev/cu.usbmodem201', serial: kitt, label: 'KITT', vendorId: '', productId: '' } ]) }) @@ -124,7 +124,7 @@ test('a Windows composite-device port takes its serial from the parent device', Name: 'USB Serial Device (COM7)', PNPDeviceID: 'USB\\VID_303A&PID_0020&MI_00\\9&148DD47E&0&0000', Parent: 'USB\\VID_303A&PID_0020\\1C:29:04:D0:B5:80' - }), { path: 'COM7', label: 'USB Serial Device (COM7)', serial: '1C:29:04:D0:B5:80' }) + }), { path: 'COM7', label: 'USB Serial Device (COM7)', serial: '1C:29:04:D0:B5:80', vendorId: '303a', productId: '0020' }) // A non-composite port with a synthesised instance ID stays without a // serial: its parent is a hub, not the device. assert.equal(windowsSerialPortDevice({