Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/board-catalog.mjs
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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']
}
}
])

Expand Down Expand Up @@ -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
}
2 changes: 1 addition & 1 deletion src/boards/resolve.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
18 changes: 13 additions & 5 deletions src/boards/usb.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -163,18 +163,26 @@ 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
// (Bluetooth SPP, the debug console); probing those is a wasted timeout.
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 })
}
Expand Down
15 changes: 13 additions & 2 deletions src/commands/board.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 })
Expand All @@ -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 })
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 11 additions & 3 deletions src/commands/boards.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand All @@ -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 || '',
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/commands/doctor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions src/gea.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
Expand Down Expand Up @@ -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 <alias> (gea boards list) or --target <target id> (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)
Expand Down Expand Up @@ -303,7 +303,7 @@ ${heading('Native desktop, mobile and watch apps (scripts ship in @geastack/appl

${heading('Build and deploy (device commands take --board <alias>, see gea boards list; with one registered board it can be left out):')}
gea config --board <alias> [--app <id>] effective build settings and their origins (JSON)
gea build --board <alias> [--app <id>] build firmware (ESP-IDF / Pico SDK / geaos) [--verbose] [--output file.bin]
gea build --board <alias> [--app <id>] build firmware (ESP-IDF / Pico SDK / STM32 / geaos) [--verbose] [--output file.bin]
gea flash --board <alias> [--app <id>] 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
Expand Down
2 changes: 1 addition & 1 deletion src/manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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] || ''
Expand Down
24 changes: 1 addition & 23 deletions src/rp2350/adapter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 || ''}` }
Expand Down
46 changes: 37 additions & 9 deletions src/serial-devices.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
Expand All @@ -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
Expand All @@ -68,7 +73,8 @@ function addLinuxByIdDevices(devices) {
devices.set(devicePath, normalizeDevice({
path: devicePath,
label: name,
serial: serialFromLinuxById(name)
serial: serialFromLinuxById(name),
...linuxUsbIds(devicePath)
}))
}
}
Expand Down Expand Up @@ -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]
})
}

Expand All @@ -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 {}
}
}

Expand Down
Loading
Loading