From 9e1882cf2bbb0144c8c0c9ce5b9adbb61e58cd34 Mon Sep 17 00:00:00 2001 From: Batur <52473505+baturyilmaz@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:13:29 +0300 Subject: [PATCH 1/2] Add the stm32-cube adapter for the STM32F746G-Discovery Builds, flashes and talks to STM32 boards: the stm32 app platform, the board catalog entry, and the app's compiler plugins passed to the build. --- src/board-catalog.mjs | 17 ++++++ src/boards/resolve.mjs | 2 +- src/commands/board.mjs | 15 ++++- src/gea.mjs | 6 +- src/manifest.mjs | 2 +- src/stm32/adapter.mjs | 92 +++++++++++++++++++++++++++++++ test/boards.test.mjs | 5 ++ test/context-manifest.test.mjs | 2 + test/gea.test.mjs | 18 +++++- test/helpers/fixture.mjs | 18 +++++- test/stm32-board-catalog.test.mjs | 20 +++++++ 11 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 src/stm32/adapter.mjs create mode 100644 test/stm32-board-catalog.test.mjs diff --git a/src/board-catalog.mjs b/src/board-catalog.mjs index f49e3ef..02c43b6 100644 --- a/src/board-catalog.mjs +++ b/src/board-catalog.mjs @@ -259,6 +259,23 @@ 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', + 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'] + } } ]) diff --git a/src/boards/resolve.mjs b/src/boards/resolve.mjs index 84cb1f2..6e8fbc6 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/commands/board.mjs b/src/commands/board.mjs index 715a917..47ef699 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' @@ -130,6 +131,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 }) @@ -146,8 +150,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 }) @@ -285,6 +290,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/gea.mjs b/src/gea.mjs index fa981fe..a14f1a2 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])) @@ -205,7 +205,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) @@ -303,7 +303,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 a68b41d..587b31d 100644 --- a/src/manifest.mjs +++ b/src/manifest.mjs @@ -150,7 +150,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', 'macos', 'ios', 'android', 'windows', 'xbox', 'pebble']) +export const knownPlatforms = Object.freeze(['web', 'esp32', 'rp2350', 'stm32', 'geaos', 'macos', 'ios', 'android', 'windows', 'xbox', 'pebble']) export function appPlatformForTarget(ctx, targetOrBoard) { return appPlatformsForTarget(ctx, targetOrBoard)[0] || '' diff --git a/src/stm32/adapter.mjs b/src/stm32/adapter.mjs new file mode 100644 index 0000000..cfa027e --- /dev/null +++ b/src/stm32/adapter.mjs @@ -0,0 +1,92 @@ +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' + +// STMicroelectronics STM32 boards: cmake configure + build with the Arm GNU +// toolchain against STM32CubeF7'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) +} + +// The target's CMake toolchain file finds arm-none-eabi-gcc on PATH or under +// ARM_TOOLCHAIN_PATH; failing here names what is missing before CMake runs. +function requireBuildTools(env) { + if (!env.ARM_TOOLCHAIN_PATH && !onPath('arm-none-eabi-gcc', env)) { + 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) + } +} + +export function buildStm32({ ctx, selection, app = null, env, dryRun = false, stdout, configureOnly = false }) { + if (!dryRun) requireBuildTools(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 = { ...env } + 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/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/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') +}) From 6805979cb0ba61080d2eb1cced9627843791fc59 Mon Sep 17 00:00:00 2001 From: Batur <52473505+baturyilmaz@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:11:12 +0300 Subject: [PATCH 2/2] Detect the ST-LINK and check the STM32 tools in gea doctor and gea setup gea doctor lists arm-none-eabi-gcc, cmake 3.20+ and openocd 0.12+ for STM32 boards, optional like the RP2350 entries, each failing one with its install command. The checks live in src/stm32/toolchain.mjs, shared with the stm32-cube adapter, and also find the Arm toolchain under /Applications/ArmGNUToolchain, which the adapter hands to CMake as ARM_TOOLCHAIN_PATH. globDirs moves to toolchain.mjs to be shared. Serial devices now carry their USB vendor and product IDs (ioreg on macOS, sysfs on Linux, the PnP ID on Windows, an optional fourth field in GEA_SERIAL_DEVICES). The catalog marks the STM32F746G-Discovery by its ST-LINK (0483:3748/374b/3752/374e), so gea boards discover names the board behind an unregistered ST-LINK, and gea setup offers it as the default, registers the ST-LINK serial, skips the OTA question, runs the STM32 checks instead of the ESP-IDF one, and prints the build and flash commands. --- src/board-catalog.mjs | 14 +++ src/boards/usb.mjs | 18 +++- src/commands/boards.mjs | 14 ++- src/commands/doctor.mjs | 2 + src/rp2350/adapter.mjs | 24 +---- src/serial-devices.mjs | 46 ++++++-- src/setup-wizard.mjs | 68 +++++++++--- src/stm32/adapter.mjs | 21 ++-- src/stm32/toolchain.mjs | 84 +++++++++++++++ src/toolchain.mjs | 24 ++++- test/rp2350-toolchain-glob.test.mjs | 2 +- test/serial-devices.test.mjs | 8 +- test/stm32-setup.test.mjs | 158 ++++++++++++++++++++++++++++ test/usb-identity.test.mjs | 6 +- 14 files changed, 417 insertions(+), 72 deletions(-) create mode 100644 src/stm32/toolchain.mjs create mode 100644 test/stm32-setup.test.mjs diff --git a/src/board-catalog.mjs b/src/board-catalog.mjs index 02c43b6..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', @@ -267,6 +272,7 @@ export const knownBoards = Object.freeze([ target: 'stm32f746g-disco', adapter: 'stm32-cube', mcu: 'STM32F746NG', + usb: stlinkUsb, capabilities: { display: '4.3 inch 480x272 RGB LCD (LTDC)', touch: 'FT5336 capacitive touch', @@ -321,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/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/boards.mjs b/src/commands/boards.mjs index e4c4f5e..88734ab 100644 --- a/src/commands/boards.mjs +++ b/src/commands/boards.mjs @@ -8,6 +8,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' @@ -155,8 +156,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) { @@ -172,6 +174,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 || '', @@ -184,7 +187,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) { @@ -196,6 +199,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/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 index cfa027e..d2402df 100644 --- a/src/stm32/adapter.mjs +++ b/src/stm32/adapter.mjs @@ -6,9 +6,10 @@ 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 STM32CubeF7's CMSIS headers, then one openocd `program` +// 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. @@ -32,20 +33,24 @@ export function stm32BuildDir(ctx, selection) { return path.join(ctx.buildRoot, selection.target) } -// The target's CMake toolchain file finds arm-none-eabi-gcc on PATH or under -// ARM_TOOLCHAIN_PATH; failing here names what is missing before CMake runs. -function requireBuildTools(env) { - if (!env.ARM_TOOLCHAIN_PATH && !onPath('arm-none-eabi-gcc', env)) { +// 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 }) { - if (!dryRun) requireBuildTools(env) - + 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) @@ -60,7 +65,7 @@ export function buildStm32({ ctx, selection, app = null, env, dryRun = false, st ] if (resolvedBuild.cssDevicePixelRatio) configure.push(`-DGEA_EMBEDDED_CSS_DEVICE_PIXEL_RATIO=${resolvedBuild.cssDevicePixelRatio}`) - const buildEnv = { ...env } + const buildEnv = { ...toolsEnv } if (app) { const meta = appCmakeMeta(ctx, app) configure.push(`-DGEA_EMBEDDED_APP=${app.id}`, `-DGEA_EMBEDDED_APP_META=${meta}`) 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/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-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({