diff --git a/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts b/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts new file mode 100644 index 000000000..393bd63d6 --- /dev/null +++ b/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts @@ -0,0 +1,128 @@ +import { spawn } from 'node:child_process' +import { EventEmitter } from 'node:events' + +import { CompilerModule } from '../compiler-module' + +// Electron is imported transitively by compiler-module; stub the bits the +// instantiation path actually touches so jest doesn't load the real runtime. +jest.mock('electron', () => ({ + app: { + getPath: jest.fn().mockReturnValue('/tmp/mock-user-data'), + getAppPath: jest.fn().mockReturnValue('/tmp/mock-app-root'), + isPackaged: false, + getVersion: jest.fn().mockReturnValue('0.0.0-test'), + }, + dialog: { showSaveDialog: jest.fn().mockResolvedValue({ filePath: '/tmp/mock-save-path' }) }, +})) +jest.mock('electron/main', () => ({}), { virtual: true }) + +// compiler-module pulls in recipe-exec, which calls promisify(execFile) at +// module load, so the mock must expose exec/execFile (with promisify.custom) +// alongside spawn. handleCoreInstallation reaches spawn only on the install +// path (core absent OR a pinned version is requested); the skip-path tests +// assert spawn is NOT called. +jest.mock('node:child_process', () => { + const { promisify } = jest.requireActual('node:util') as typeof import('node:util') + const noop = async () => ({ stdout: '', stderr: '' }) + const exec = ( + _cmd: string, + _opts: unknown, + cb: (err: Error | null, val?: { stdout: string; stderr: string }) => void, + ) => { + noop().then((v) => cb(null, v)) + return { kill: () => undefined } + } + ;(exec as unknown as { [k: symbol]: unknown })[promisify.custom] = () => noop() + const execFile = ( + _command: string, + _args: ReadonlyArray, + _opts: unknown, + cb: (err: Error | null, val?: { stdout: string; stderr: string }) => void, + ) => { + noop().then((v) => cb(null, v)) + return { kill: () => undefined } + } + ;(execFile as unknown as { [k: symbol]: unknown })[promisify.custom] = () => noop() + return { exec, execFile, spawn: jest.fn() } +}) +;(process as unknown as { resourcesPath: string }).resourcesPath ??= process.cwd() + +type InstalledCores = Awaited> + +// A fake ChildProcess that satisfies handleCoreInstallation's wiring +// (stdout/stderr `.on`, plus a `close` event) and reports the given exit code +// on the next tick so the `.on('close')` handler is registered first. +function fakeChild(exitCode = 0) { + const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter } + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + setImmediate(() => child.emit('close', exitCode)) + return child +} + +describe('handleCoreInstallation (prebuilt core pin = exact manifest version)', () => { + let compilerModule: CompilerModule + + beforeEach(() => { + compilerModule = new CompilerModule() + jest.mocked(spawn).mockReset() + }) + + it('does nothing when boardCore is null', async () => { + const log = jest.fn() + const coresSpy = jest.spyOn(compilerModule, 'getArduinoInstalledCores') + await compilerModule.handleCoreInstallation(null, log) + expect(coresSpy).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() + expect(log).not.toHaveBeenCalled() + }) + + it('installs the EXACT pinned version even when a different version is already present', async () => { + const log = jest.fn() + jest.mocked(spawn).mockReturnValue(fakeChild(0) as unknown as ReturnType) + jest + .spyOn(compilerModule, 'getArduinoInstalledCores') + .mockResolvedValue({ 'FACTS:samd': { version: '1.7.99' } } as unknown as InstalledCores) + + await compilerModule.handleCoreInstallation('FACTS:samd', log, '1.7.13') + + expect(spawn).toHaveBeenCalledTimes(1) + const [, argv] = jest.mocked(spawn).mock.calls[0] + expect(argv).toEqual(expect.arrayContaining(['core', 'install', 'FACTS:samd@1.7.13'])) + }) + + it('installs the pinned version when the core is absent', async () => { + const log = jest.fn() + jest.mocked(spawn).mockReturnValue(fakeChild(0) as unknown as ReturnType) + jest.spyOn(compilerModule, 'getArduinoInstalledCores').mockResolvedValue({} as InstalledCores) + + await compilerModule.handleCoreInstallation('FACTS:samd', log, '1.7.13') + + expect(spawn).toHaveBeenCalledTimes(1) + const [, argv] = jest.mocked(spawn).mock.calls[0] + expect(argv).toEqual(expect.arrayContaining(['core', 'install', 'FACTS:samd@1.7.13'])) + }) + + it('rejects when the pinned version install fails (non-zero exit)', async () => { + const log = jest.fn() + jest.mocked(spawn).mockReturnValue(fakeChild(1) as unknown as ReturnType) + jest.spyOn(compilerModule, 'getArduinoInstalledCores').mockResolvedValue({} as InstalledCores) + + await expect(compilerModule.handleCoreInstallation('FACTS:samd', log, '9.9.9')).rejects.toThrow( + /exited with code 1/, + ) + }) + + it('skips install (no spawn) only when the core is present AND no version is pinned', async () => { + const log = jest.fn() + jest + .spyOn(compilerModule, 'getArduinoInstalledCores') + .mockResolvedValue({ 'arduino:avr': { version: '1.8.6' } } as unknown as InstalledCores) + + await compilerModule.handleCoreInstallation('arduino:avr', log) + + expect(spawn).not.toHaveBeenCalled() + const message = log.mock.calls.map((c) => String(c[0])).join('\n') + expect(message).toMatch(/already installed/) + }) +}) diff --git a/src/backend/editor/compiler/__tests__/handle-vendor-plugin-packaging.test.ts b/src/backend/editor/compiler/__tests__/handle-vendor-plugin-packaging.test.ts new file mode 100644 index 000000000..209eb06f7 --- /dev/null +++ b/src/backend/editor/compiler/__tests__/handle-vendor-plugin-packaging.test.ts @@ -0,0 +1,159 @@ +/** + * `CompilerModule.handleVendorPluginPackaging` — prebuilt vs source + * provisioning branch. + * + * The packager treats `hal.pluginEntry` differently depending on + * `hal.provisioning`: + * - "prebuilt": pluginEntry IS the directory holding the precompiled + * `.o` objects + link-only Makefile — copied verbatim. + * - source (default / absent): pluginEntry is the entry source FILE, + * so the directory to copy is its parent. + * + * We drive the real method against a temp filesystem, mocking only the + * package manager (which board/manifest it sees) and electron (so the + * module import doesn't try to reach the Electron app at load time). + * The method doesn't touch `this`, so we invoke it via the prototype + * and skip the constructor entirely. + */ + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +// The module calls `electronApp.getPath(...)` in its constructor; we never +// construct it here, but the top-level `import electron` still has to resolve. +jest.mock('electron', () => ({ + app: { getPath: () => tmpdir() }, + dialog: {}, + MessageChannelMain: class {}, +})) + +const listInstalled = jest.fn() +const getInstalledPackageManifest = jest.fn() +jest.mock('../../package-manager', () => ({ + PackageManagerModule: jest.fn().mockImplementation(() => ({ + listInstalled, + getInstalledPackageManifest, + })), +})) + +// eslint-disable-next-line import/first +import { CompilerModule } from '../compiler-module' + +type LogEntry = { message: string; level: string } + +const BOARD = 'Raspberry Pi (prebuilt test)' + +const handler = CompilerModule.prototype.handleVendorPluginPackaging + +function makeManifest(hal: Record) { + return { + devices: [ + { + name: BOARD, + target: { type: 'runtime-v4' }, + hal, + moduleSystem: undefined, + }, + ], + } +} + +/** Writes a plugin directory with two payload files + an excluded one. */ +function writePluginDir(pkgDir: string): string { + const pluginDir = join(pkgDir, 'hal', 'runtime-v4', 'plugin') + mkdirSync(pluginDir, { recursive: true }) + writeFileSync(join(pluginDir, 'rpi_plugin.o'), 'OBJECT-BYTES') + writeFileSync(join(pluginDir, 'Makefile'), 'all:\n\techo link\n') + // Excluded by the packager — must not be copied into vpp_plugin/. + writeFileSync(join(pluginDir, 'config_template.json'), JSON.stringify({ plugin_name: 'rpi_gpio', pins: [] })) + return pluginDir +} + +describe('handleVendorPluginPackaging — provisioning branch', () => { + let pkgDir: string + let projectDir: string + let targetDir: string + let logs: LogEntry[] + + const runFor = (hal: Record) => { + listInstalled.mockReturnValue([{ packageId: 'com.openplc.rpi', path: pkgDir }]) + getInstalledPackageManifest.mockReturnValue(makeManifest(hal)) + return handler.call( + {} as CompilerModule, + BOARD, + projectDir, + targetDir, + (message: string | Buffer, level?: string) => { + logs.push({ message: String(message), level: level ?? '' }) + }, + ) + } + + beforeEach(() => { + jest.clearAllMocks() + pkgDir = mkdtempSync(join(tmpdir(), 'vpp-pkg-')) + projectDir = mkdtempSync(join(tmpdir(), 'vpp-proj-')) + targetDir = mkdtempSync(join(tmpdir(), 'vpp-target-')) + logs = [] + writePluginDir(pkgDir) + }) + + afterEach(() => { + for (const dir of [pkgDir, projectDir, targetDir]) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('treats pluginEntry as a directory when provisioning is "prebuilt"', async () => { + await runFor({ + type: 'runtime-v4-plugin', + pluginType: 'native', + provisioning: 'prebuilt', + pluginEntry: 'hal/runtime-v4/plugin', + configTemplate: 'hal/runtime-v4/plugin/config_template.json', + }) + + const dest = join(targetDir, 'vpp_plugin') + expect(existsSync(join(dest, 'rpi_plugin.o'))).toBe(true) + expect(existsSync(join(dest, 'Makefile'))).toBe(true) + // Excluded file is never copied. + expect(existsSync(join(dest, 'config_template.json'))).toBe(false) + // Deterministic integrity checksum is emitted. + expect(existsSync(join(dest, 'checksum.sha256'))).toBe(true) + // The summary log distinguishes the prebuilt path. + expect(logs.some((l) => /prebuilt file\(s\)/.test(l.message))).toBe(true) + }) + + it('treats pluginEntry as a file and copies its parent dir in source mode (provisioning absent)', async () => { + // Source-mode pluginEntry points at the entry FILE; the directory to copy + // is its parent — the same plugin dir, reached via dirname(). + writeFileSync(join(pkgDir, 'hal', 'runtime-v4', 'plugin', 'rpi_plugin.c'), 'int main(){}') + + await runFor({ + type: 'runtime-v4-plugin', + pluginType: 'native', + pluginEntry: 'hal/runtime-v4/plugin/rpi_plugin.c', + configTemplate: 'hal/runtime-v4/plugin/config_template.json', + }) + + const dest = join(targetDir, 'vpp_plugin') + expect(existsSync(join(dest, 'rpi_plugin.c'))).toBe(true) + expect(existsSync(join(dest, 'Makefile'))).toBe(true) + expect(existsSync(join(dest, 'config_template.json'))).toBe(false) + expect(logs.some((l) => /source file\(s\)/.test(l.message))).toBe(true) + }) + + it('copies the payload byte-for-byte (prebuilt object content preserved)', async () => { + await runFor({ + type: 'runtime-v4-plugin', + pluginType: 'native', + provisioning: 'prebuilt', + pluginEntry: 'hal/runtime-v4/plugin', + configTemplate: 'hal/runtime-v4/plugin/config_template.json', + }) + + const copied = readFileSync(join(targetDir, 'vpp_plugin', 'rpi_plugin.o'), 'utf-8') + expect(copied).toBe('OBJECT-BYTES') + }) +}) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 08f376ef1..fe5d59831 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -1036,15 +1036,26 @@ class CompilerModule { async handleCoreInstallation( boardCore: string | null, handleOutputData: (chunk: Buffer | string, logLevel?: 'info' | 'error') => void, + coreVersion?: string, ) { if (boardCore === null) return const isCoreInstalled = Object.keys(await this.getArduinoInstalledCores()).some((core) => core === boardCore) - if (isCoreInstalled) { + // Without a pinned version, any installed version is fine — skip the install. + // With a pinned version (prebuilt arduino libraries are ABI-locked to it), + // always run `core install @`: arduino-cli installs exactly that + // version and fails if it does not exist, pinning the core to the version + // the precompiled library was built against. + if (!coreVersion && isCoreInstalled) { handleOutputData(`Core ${boardCore} is already installed.`, 'info') return } + const coreRef = coreVersion ? `${boardCore}@${coreVersion}` : boardCore + if (coreVersion) { + handleOutputData(`Installing pinned core ${coreRef} (required by a prebuilt library)...`, 'info') + } + let binaryPath = this.arduinoCliBinaryPath if (CompilerModule.HOST_PLATFORM === 'win32') { @@ -1052,7 +1063,7 @@ class CompilerModule { binaryPath += '.exe' } return new Promise>((resolve, reject) => { - const executeCommand = spawn(binaryPath, ['core', 'install', boardCore, ...this.arduinoCliBaseParameters]) + const executeCommand = spawn(binaryPath, ['core', 'install', coreRef, ...this.arduinoCliBaseParameters]) let stderrData = '' @@ -1790,6 +1801,13 @@ class CompilerModule { ...cxxFlagsArg, '--library', precompiledLibDir, + // Prebuilt arduino-hal (mixed): the vendor's precompiled library. The + // open hal.source layer (renamed to arduino.cpp, compiled here alongside + // the sketch — NOT in the precompile pass) does `#include "p1am_vendor.h"`, + // so arduino-cli needs the lib's src/ on the include path. Passing it as a + // 2nd --library both resolves the boundary header and auto-links the + // src//lib*.a archive (the lib ships precompiled=full). + ...(info.precompiledLibraryDir ? ['--library', info.precompiledLibraryDir] : []), '--build-property', `compiler.libraries.ldflags=-L${precompiledArchDir} -lOpenPLCUserLib`, ...this.arduinoCliBaseParameters, @@ -2243,19 +2261,24 @@ class CompilerModule { handleOutputData('VPP board has no HAL configTemplate, skipping plugin config generation', 'info') } - // --- Step 2: Copy plugin source + generate checksum --- + // --- Step 2: Copy plugin payload + generate checksum --- const pluginEntryRelPath = matchingDevice.hal?.pluginEntry if (!pluginEntryRelPath) { handleOutputData('VPP board has no HAL pluginEntry, skipping plugin source upload', 'info') return } - // The plugin source directory is the parent directory of pluginEntry. + // Resolve the plugin directory. In "source" mode (default) pluginEntry is + // the entry source file, so the dir is its parent. In "prebuilt" mode + // (provisioning === 'prebuilt') pluginEntry is the directory itself, + // holding the precompiled .o objects plus the link-only Makefile. // pluginEntryRelPath is supplied by the package manifest; without // containment, an entry like `../../../etc` would resolve outside // matchingPackagePath and the recursive-copy below would slurp // arbitrary host files into the build's vpp_plugin directory. - const pluginSourceDir = join(matchingPackagePath, path.dirname(pluginEntryRelPath)) + const isPrebuilt = matchingDevice.hal?.provisioning === 'prebuilt' + const pluginDirRelPath = isPrebuilt ? pluginEntryRelPath : path.dirname(pluginEntryRelPath) + const pluginSourceDir = join(matchingPackagePath, pluginDirRelPath) try { assertPathContained(matchingPackagePath, pluginSourceDir, 'matchingDevice.hal.pluginEntry') } catch (err) { @@ -2346,7 +2369,7 @@ class CompilerModule { await writeFile(join(destPluginDir, 'checksum.sha256'), combinedHash + '\n', 'utf-8') handleOutputData( - `Copied ${copiedFiles.length} VPP plugin source file(s) to vpp_plugin/ (checksum: ${combinedHash.slice(0, 12)}...)`, + `Copied ${copiedFiles.length} VPP plugin ${isPrebuilt ? 'prebuilt' : 'source'} file(s) to vpp_plugin/ (checksum: ${combinedHash.slice(0, 12)}...)`, 'info', ) } catch (error) { diff --git a/src/backend/editor/compiler/editor-compiler-platform-port.ts b/src/backend/editor/compiler/editor-compiler-platform-port.ts index fe2b39aeb..e5e184d8a 100644 --- a/src/backend/editor/compiler/editor-compiler-platform-port.ts +++ b/src/backend/editor/compiler/editor-compiler-platform-port.ts @@ -239,10 +239,14 @@ export function createEditorCompilerPlatformPort( */ async installArduinoCore(args: InstallArduinoCoreArgs, log: PlatformLog): Promise { try { - await handlers.handleCoreInstallation(args.coreId, (chunk, level) => { - const message = typeof chunk === 'string' ? chunk : chunk.toString() - log(message, level ?? 'info') - }) + await handlers.handleCoreInstallation( + args.coreId, + (chunk, level) => { + const message = typeof chunk === 'string' ? chunk : chunk.toString() + log(message, level ?? 'info') + }, + args.coreVersion, + ) return { ok: true } } catch (error) { const message = error instanceof Error ? error.message : String(error) diff --git a/src/backend/shared/compile/__tests__/resolve-board-selection.test.ts b/src/backend/shared/compile/__tests__/resolve-board-selection.test.ts index 3ed7b0a7b..1998eb024 100644 --- a/src/backend/shared/compile/__tests__/resolve-board-selection.test.ts +++ b/src/backend/shared/compile/__tests__/resolve-board-selection.test.ts @@ -179,4 +179,64 @@ describe('resolveBoardSelection', () => { expect(result.boardRuntime).toBe('arduino-cli') } }) + + it('maps a prebuilt arduino-hal VPP board: coreVersion + resolved precompiledLibraryDir', () => { + // Prebuilt mixed VPP: target.coreVersion (ABI-locked core) and + // hal.precompiledLibrary (vendor lib dir, resolved package-relative) + // must surface on boardEntry so the pipeline pins the core and passes + // the 2nd --library. The open hal.source integration layer stays. + const pkg: InstalledPackage = { + packageId: 'com.automationdirect.p1am-prebuilt-test', + version: '0.1.0', + installedAt: '2026-01-01T00:00:00.000Z', + path: '/fake/packages/p1am', + devices: ['p1am-200'], + } + const manifest: PackageManifest = { + formatVersion: '1.0', + package: { + id: 'com.automationdirect.p1am-prebuilt-test', + name: 'P1AM', + version: '0.1.0', + vendor: { name: 'AutomationDirect', logo: 'l.png' }, + description: 'd', + }, + devices: [ + { + id: 'p1am-200', + name: 'AutomationDirect P1AM-200', + preview: 'p.png', + target: { + type: 'arduino-cli', + core: 'FACTS:samd', + platform: 'FACTS:samd:P1AM-200', + coreVersion: '1.7.13', + }, + hal: { + type: 'arduino-hal', + provisioning: 'prebuilt', + source: 'hal/arduino/p1am.cpp', + precompiledLibrary: 'hal/arduino/lib', + extraArduinoLibraries: ['P1AM'], + }, + }, + ], + } + const packageManager: PackageManagerPort = { + listInstalled: () => [pkg], + getInstalledPackageManifest: (id) => (id === pkg.packageId ? manifest : null), + } + const resolver = makeResolver({}, { packageManager }) + + const result = resolveBoardSelection(resolver, 'AutomationDirect P1AM-200') + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.boardEntry.platform).toBe('FACTS:samd:P1AM-200') + expect(result.boardEntry.core).toBe('FACTS:samd') + expect(result.boardEntry.coreVersion).toBe('1.7.13') + // resolvePackageRelativePath fake joins as `${pkg.path}/${rel}`. + expect(result.boardEntry.precompiledLibraryDir).toBe('/fake/packages/p1am/hal/arduino/lib') + expect(result.boardEntry.extra_libraries).toEqual(['P1AM']) + } + }) }) diff --git a/src/backend/shared/compile/pipeline.ts b/src/backend/shared/compile/pipeline.ts index 10548e971..4e5d3fbec 100644 --- a/src/backend/shared/compile/pipeline.ts +++ b/src/backend/shared/compile/pipeline.ts @@ -104,6 +104,14 @@ export interface BoardHalsBuildEntry extends BoardHalsCompileEntry { * install fires only when that board is selected. Boards that * don't need a specific library never download it. */ extra_libraries?: string[] + /** Prebuilt arduino-hal (provisioning="prebuilt"): the precompiled Arduino + * library dir, linked via a 2nd `--library`. Present only for arduino + * prebuilt boards (the `source` HAL still compiles as the integration layer). + * Sourced from the VPP manifest `device.hal.precompiledLibrary`. */ + precompiledLibraryDir?: string + /** Exact Arduino core version to install/verify before linking a prebuilt + * arduino library (ABI-locked). From the VPP manifest `target.coreVersion`. */ + coreVersion?: string /** Compiler / runtime identifier (`'arduino-cli' | 'openplc-compiler' * | 'simulator'`). Used by `resolveTargetCapabilities`'s * preset lookup — without this the resolver can't pick the right @@ -643,7 +651,11 @@ async function runCompilePipelineInner( // --------------------------------------------------------------------- emit({ stage: 'core-install', message: 'Installing Arduino core...', level: 'info' }) const coreInstall = await port.installArduinoCore( - { coreId: typeof boardEntry.platform === 'string' ? deriveArduinoCoreFromPlatform(boardEntry.platform) : '' }, + { + coreId: typeof boardEntry.platform === 'string' ? deriveArduinoCoreFromPlatform(boardEntry.platform) : '', + // Pin the exact core version for prebuilt arduino libraries (ABI-locked). + ...(boardEntry.coreVersion ? { coreVersion: boardEntry.coreVersion } : {}), + }, makePlatformLog(emit, 'core-install'), ) if (!coreInstall.ok) { @@ -721,6 +733,9 @@ async function runCompilePipelineInner( libraryPath: 'src', avrLibStdCppInclude, parallel: arduinoCliParallel, + // Prebuilt arduino-hal: link the precompiled vendor library alongside the + // source integration layer. arduino-cli accepts a 2nd --library. + ...(boardEntry.precompiledLibraryDir ? { prebuiltLibraryPath: boardEntry.precompiledLibraryDir } : {}), }) // Run arduino-cli compile. Editor: spawns the binary. Web: HTTP diff --git a/src/backend/shared/compile/steps/resolve-board-selection.ts b/src/backend/shared/compile/steps/resolve-board-selection.ts index 0582d7b05..492055486 100644 --- a/src/backend/shared/compile/steps/resolve-board-selection.ts +++ b/src/backend/shared/compile/steps/resolve-board-selection.ts @@ -63,6 +63,11 @@ export function resolveBoardSelection(resolver: BoardInfoResolver, boardTarget: ...(boardInfo.extraArduinoLibraries && boardInfo.extraArduinoLibraries.length > 0 ? { extra_libraries: boardInfo.extraArduinoLibraries } : {}), + // Prebuilt arduino-hal (mixed): the precompiled vendor library to link + // (2nd --library) + the ABI-locked core version to install/verify. Both + // come from the VPP manifest via BoardBuildInfo; absent for source boards. + ...(boardInfo.precompiledLibraryDir ? { precompiledLibraryDir: boardInfo.precompiledLibraryDir } : {}), + ...(boardInfo.coreVersion ? { coreVersion: boardInfo.coreVersion } : {}), // Capability resolution inputs. `resolveTargetCapabilities` // reads `compiler` + `vpp` + `capabilities` on whatever board // shape it's handed — without forwarding all three the diff --git a/src/backend/shared/firmware/__tests__/build-arduino-cli-args.test.ts b/src/backend/shared/firmware/__tests__/build-arduino-cli-args.test.ts index 52e620c16..5613b5c89 100644 --- a/src/backend/shared/firmware/__tests__/build-arduino-cli-args.test.ts +++ b/src/backend/shared/firmware/__tests__/build-arduino-cli-args.test.ts @@ -133,6 +133,37 @@ describe('buildArduinoCliCompileArgs', () => { expect(args.some((a) => a.startsWith('upload.maximum_data_size='))).toBe(false) }) + it('appends a 2nd --library for the prebuilt vendor lib, right after the main one', () => { + const args = buildArduinoCliCompileArgs( + { platform: 'FACTS:samd:P1AM-200', core: 'FACTS:samd' }, + { + sketchPath: '/work/examples/Baremetal/Baremetal.ino', + libraryPath: '/work/src', + prebuiltLibraryPath: '/packages/p1am/hal/arduino/lib', + parallel: false, + }, + ) + const firstLib = args.indexOf('--library') + // Two --library flags: the main src/ then the prebuilt vendor lib. + expect(args.filter((a) => a === '--library')).toHaveLength(2) + expect(args.slice(firstLib, firstLib + 4)).toEqual([ + '--library', + '/work/src', + '--library', + '/packages/p1am/hal/arduino/lib', + ]) + // The vendor lib still precedes --export-binaries / -b / sketch. + expect(args.indexOf('/packages/p1am/hal/arduino/lib')).toBeLessThan(args.indexOf('--export-binaries')) + }) + + it('emits a single --library when prebuiltLibraryPath is absent', () => { + const args = buildArduinoCliCompileArgs( + { platform: 'arduino:avr:mega' }, + { sketchPath: 'a.ino', libraryPath: 'src', parallel: false }, + ) + expect(args.filter((a) => a === '--library')).toHaveLength(1) + }) + it('appends trailingArgs after the sketch path', () => { const args = buildArduinoCliCompileArgs( { platform: 'arduino:avr:mega' }, diff --git a/src/backend/shared/firmware/build-arduino-cli-args.ts b/src/backend/shared/firmware/build-arduino-cli-args.ts index e07b80b9e..f63098f8b 100644 --- a/src/backend/shared/firmware/build-arduino-cli-args.ts +++ b/src/backend/shared/firmware/build-arduino-cli-args.ts @@ -30,6 +30,14 @@ export interface BuildArduinoCliCompileArgsOptions { sketchPath: string /** Directory passed via `--library` (contains generated.cpp, runtime headers, etc.). */ libraryPath: string + /** + * Optional 2nd `--library`: a prebuilt arduino-hal's precompiled Arduino + * library (provisioning="prebuilt"). arduino-cli accepts multiple --library + * and links the precompiled `.a` (library.properties precompiled=true) found + * under it. The source integration layer (hal.source) is compiled from the + * main libraryPath as usual. + */ + prebuiltLibraryPath?: string /** * Filesystem path to the avr-libstdcpp include directory. Appended * as `-I` onto `compiler.cpp.extra_flags` when the board's @@ -93,7 +101,11 @@ export function buildArduinoCliCompileArgs( args.push('--build-property', `upload.maximum_data_size=${entry.max_data_size}`) } - args.push('--library', options.libraryPath, '--export-binaries', '-b', entry.platform, options.sketchPath) + args.push('--library', options.libraryPath) + if (options.prebuiltLibraryPath) { + args.push('--library', options.prebuiltLibraryPath) + } + args.push('--export-binaries', '-b', entry.platform, options.sketchPath) if (options.trailingArgs && options.trailingArgs.length > 0) { args.push(...options.trailingArgs) diff --git a/src/backend/shared/hardware/board-info-resolver.ts b/src/backend/shared/hardware/board-info-resolver.ts index a2f15505c..c241b2f9d 100644 --- a/src/backend/shared/hardware/board-info-resolver.ts +++ b/src/backend/shared/hardware/board-info-resolver.ts @@ -156,6 +156,13 @@ export interface BoardBuildInfo { extraArduinoLibraries?: string[] /** Opaque key for a package-supplied `libraries/` folder. */ localLibrariesDir?: string + /** Prebuilt arduino-hal (provisioning="prebuilt"): the precompiled Arduino + * library dir, linked via a 2nd `--library`. Its presence marks an arduino + * prebuilt board (the source HAL still compiles as the integration layer). */ + precompiledLibraryDir?: string + /** Exact Arduino core version to install/verify before linking a prebuilt + * arduino library (ABI-locked). From `target.coreVersion`. */ + coreVersion?: string /** Per-board capability overrides. Merged by * `resolveTargetCapabilities` on top of the compiler preset. * Sourced from `hals.json` `capabilities` (static boards) or VPP @@ -278,6 +285,7 @@ export class BoardInfoResolver { if (device.target.platformOptions && device.target.platformOptions.length > 0) { info.platformOptions = device.target.platformOptions } + if (device.target.coreVersion) info.coreVersion = device.target.coreVersion const resolveRel = this.config.resolvePackageRelativePath if (device.hal.source) info.halSourceFile = resolveRel(pkg.path, device.hal.source) @@ -285,6 +293,7 @@ export class BoardInfoResolver { if (device.hal.configTemplate) info.configTemplate = resolveRel(pkg.path, device.hal.configTemplate) if (device.hal.requirements) info.requirements = resolveRel(pkg.path, device.hal.requirements) if (device.hal.libraries) info.localLibrariesDir = resolveRel(pkg.path, device.hal.libraries) + if (device.hal.precompiledLibrary) info.precompiledLibraryDir = resolveRel(pkg.path, device.hal.precompiledLibrary) const flags = this.#collectFlags( device.hal.compilerFlags?.c_flags, diff --git a/src/middleware/shared/ports/compiler-platform-port.ts b/src/middleware/shared/ports/compiler-platform-port.ts index cb032097c..1d038ce7a 100644 --- a/src/middleware/shared/ports/compiler-platform-port.ts +++ b/src/middleware/shared/ports/compiler-platform-port.ts @@ -212,6 +212,11 @@ export interface InstallArduinoCoreArgs { /** Core identifier (e.g. `arduino:avr`). Editor invokes * `arduino-cli core install `. */ coreId: string + /** Optional exact core version (e.g. `1.8.8`). When set, the editor runs + * `core install @`, which installs exactly that version and + * fails if it is unavailable — required for prebuilt arduino-hal boards + * whose precompiled `.a` is ABI-locked to that core version. */ + coreVersion?: string } /** Arduino-CLI library install (editor-only. Same no-op diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index f7113c518..bdf246eac 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -738,15 +738,35 @@ export interface PackageManifest { * manifest.schema.json for the canonical field documentation. */ platformOptions?: PlatformOption[] + /** + * Exact Arduino core version a prebuilt arduino-hal library was compiled + * against (arduino-cli targets with hal.provisioning="prebuilt"). The + * editor installs/verifies this version before linking, since the + * precompiled .a is ABI-locked to it. + */ + coreVersion?: string } specs?: Record hal: { type: string pluginType?: string + /** + * Native runtime-v4 plugin provisioning. "source" (default when absent): + * pluginEntry is the entry source file and its directory is compiled on + * the runtime. "prebuilt": pluginEntry is the directory holding the + * precompiled .o objects plus a link-only Makefile; the runtime only links. + */ + provisioning?: string pluginEntry?: string configTemplate?: string requirements?: string source?: string + /** + * Prebuilt arduino-hal (provisioning="prebuilt") precompiled Arduino + * library directory. Linked via --library alongside the source + * integration layer (hal.source). + */ + precompiledLibrary?: string compilerFlags?: { c_flags?: string[] cxx_flags?: string[]