diff --git a/src/backend/editor/compiler/__tests__/editor-compiler-platform-port.test.ts b/src/backend/editor/compiler/__tests__/editor-compiler-platform-port.test.ts new file mode 100644 index 000000000..ebeb776c4 --- /dev/null +++ b/src/backend/editor/compiler/__tests__/editor-compiler-platform-port.test.ts @@ -0,0 +1,349 @@ +/** + * Editor `CompilerPlatformPort` adapter — unit tests. + * + * Pipeline-internal behaviour is covered upstream by + * `pipeline.test.ts`; here we focus on the editor-specific glue: + * + * 1. `assertEditorHttpsContext` discriminator narrow. + * 2. `findHexInCompilationPath` — deterministic FQBN path + walk + * fallback (regression for the multi-board stale-build bug + * that returned the wrong `.hex`). + * 3. Port methods that translate handler results into the canonical + * port shape (uploadArduinoBoard forwards args.port through to + * `handleUploadProgram`, packageVppPlugin error mapping, etc.). + * + * Filesystem is real (per-test temp dir) so we exercise the actual + * arduino-cli build directory shape. The editor handlers themselves + * are stubbed since they spawn subprocesses we can't run in CI. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import type { PlatformDeviceContext } from '@root/middleware/shared/ports/compiler-platform-port' + +import { + assertEditorHttpsContext, + createEditorCompilerPlatformPort, + findHexInCompilationPath, + type EditorCompilerHandlers, + type EditorCompilerPlatformPortContext, +} from '../editor-compiler-platform-port' + +// --------------------------------------------------------------------------- +// assertEditorHttpsContext +// --------------------------------------------------------------------------- + +describe('assertEditorHttpsContext', () => { + it('returns the context unchanged when kind is editor-https', () => { + const ctx: PlatformDeviceContext = { kind: 'editor-https', ip: '192.168.1.10', jwt: 'token' } + const result = assertEditorHttpsContext(ctx) + expect(result).toBe(ctx) + expect(result.ip).toBe('192.168.1.10') + }) + + it('throws when handed a web-orchestrator context (web→editor port misuse guard)', () => { + const ctx = { kind: 'web-orchestrator', deviceId: 'rt' } as unknown as PlatformDeviceContext + expect(() => assertEditorHttpsContext(ctx)).toThrow(/non-editor context/) + expect(() => assertEditorHttpsContext(ctx)).toThrow(/web-orchestrator/) + }) +}) + +// --------------------------------------------------------------------------- +// findHexInCompilationPath +// --------------------------------------------------------------------------- + +describe('findHexInCompilationPath', () => { + let tmp: string + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'find-hex-')) + }) + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) + }) + + function writeHex(fqbnSubDir: string, content = ':00\n'): string { + const dir = join(tmp, 'examples', 'Baremetal', 'build', fqbnSubDir) + mkdirSync(dir, { recursive: true }) + const path = join(dir, 'Baremetal.ino.hex') + writeFileSync(path, content) + return path + } + + it('returns null when the build directory does not exist', async () => { + const result = await findHexInCompilationPath(tmp, 'arduino:avr:mega') + expect(result).toBeNull() + }) + + it('finds the .hex at the canonical fqbn-derived path (`:`→`.`)', async () => { + const expected = writeHex('arduino.avr.mega') + const result = await findHexInCompilationPath(tmp, 'arduino:avr:mega') + expect(result).toBe(expected) + }) + + it('picks the requested FQBN even when stale builds from other boards exist (regression)', async () => { + // Pre-fix bug scenario: user compiled for Mega, switched to Uno, + // then upload triggers a fresh compile. arduino-cli writes the + // new Uno hex; the stale Mega hex is still in the tree. The walk + // fallback returned the Mega hex (alphabetical first). The + // canonical-path lookup must pick the Uno hex deterministically. + writeHex('arduino.avr.mega', ':MEGA\n') + const unoHex = writeHex('arduino.avr.uno', ':UNO\n') + const result = await findHexInCompilationPath(tmp, 'arduino:avr:uno') + expect(result).toBe(unoHex) + }) + + it('falls back to the walk when the canonical path does not exist (FQBN-mangling cores)', async () => { + // Some Arduino cores write to a directory derived differently + // from the FQBN string (board aliases, core-internal mangling). + // When the canonical path is absent, walk and return the first + // matching .hex — preserves pre-fix behaviour as a safety net. + const oddHex = writeHex('vendor.board.custom-name') + const result = await findHexInCompilationPath(tmp, 'arduino:avr:mega') + expect(result).toBe(oddHex) + }) + + it('returns null when no .hex exists anywhere in the build tree', async () => { + mkdirSync(join(tmp, 'examples', 'Baremetal', 'build', 'arduino.avr.mega'), { recursive: true }) + // No .hex file written. + const result = await findHexInCompilationPath(tmp, 'arduino:avr:mega') + expect(result).toBeNull() + }) + + it('returns null and skips the canonical-path lookup when fqbn is empty', async () => { + // The simulator path always passes a non-empty platform string; + // an empty fqbn is a sign the caller's hals entry is malformed. + // We still try the walk so an existing .hex (e.g. from a prior + // session) gets picked up. + const walkHex = writeHex('any-fqbn') + const result = await findHexInCompilationPath(tmp, '') + expect(result).toBe(walkHex) + }) +}) + +// --------------------------------------------------------------------------- +// createEditorCompilerPlatformPort — port method behaviour +// --------------------------------------------------------------------------- + +describe('createEditorCompilerPlatformPort', () => { + function makeHandlers(overrides?: Partial): EditorCompilerHandlers { + return { + handleTranspileXMLtoST: jest.fn(), + handleCompileArduinoProgram: jest.fn(), + handleUploadProgram: jest.fn(), + handleCoreInstallation: jest.fn(), + handleLibraryInstallation: jest.fn(), + handleVendorPluginPackaging: jest.fn(), + ...overrides, + } as unknown as EditorCompilerHandlers + } + + function makeContext(overrides?: Partial): EditorCompilerPlatformPortContext { + return { + normalizedProjectPath: '/tmp/project', + compilationPath: '/tmp/project/build/Arduino Mega', + sourceTargetFolderPath: '/tmp/project/build/Arduino Mega/src', + boardTarget: 'Arduino Mega', + boardCore: 'arduino:avr', + boardHalsContent: { platform: 'arduino:avr:mega' }, + cleanBuild: false, + mainProcessBridge: { + makeRuntimeApiRequest: jest.fn(), + }, + compressSourceFolder: jest.fn(), + sendRuntimeUpload: jest.fn(), + pollTimeoutMs: 1000, + pollIntervalMs: 10, + startTimeoutMs: 1000, + startIntervalMs: 10, + ...overrides, + } + } + + // ---- computeMd5 --------------------------------------------------------- + + it('computeMd5 returns the canonical MD5 hex digest', async () => { + const port = createEditorCompilerPlatformPort(makeHandlers(), makeContext()) + const md5 = await port.computeMd5('hello world') + // crypto.createHash('md5').update('hello world').digest('hex') + expect(md5).toBe('5eb63bbbe01eeed093cb22bb8f5acdc3') + }) + + // ---- installArduinoCore / installArduinoLib ---------------------------- + + it('installArduinoCore forwards to handler and returns ok:true on resolve', async () => { + const handleCoreInstallation = jest.fn(async () => undefined) + const port = createEditorCompilerPlatformPort(makeHandlers({ handleCoreInstallation }), makeContext()) + const result = await port.installArduinoCore({ coreId: 'arduino:avr' }, () => undefined) + expect(handleCoreInstallation).toHaveBeenCalledTimes(1) + expect(result).toEqual({ ok: true }) + }) + + it('installArduinoCore returns ok:false when the handler throws', async () => { + const handleCoreInstallation = jest.fn(async () => { + throw new Error('core install failed') + }) + const log = jest.fn() + const port = createEditorCompilerPlatformPort(makeHandlers({ handleCoreInstallation }), makeContext()) + const result = await port.installArduinoCore({ coreId: 'arduino:avr' }, log) + expect(result.ok).toBe(false) + expect(log).toHaveBeenCalledWith(expect.stringContaining('core install failed'), 'error') + }) + + it('installArduinoLib forwards to handler and returns ok:true', async () => { + const handleLibraryInstallation = jest.fn(async () => undefined) + const port = createEditorCompilerPlatformPort(makeHandlers({ handleLibraryInstallation }), makeContext()) + const result = await port.installArduinoLib({ libId: '' }, () => undefined) + expect(handleLibraryInstallation).toHaveBeenCalledTimes(1) + expect(result).toEqual({ ok: true }) + }) + + it('installArduinoLib returns ok:false when the handler throws', async () => { + const handleLibraryInstallation = jest.fn(async () => { + throw new Error('lib install failed') + }) + const log = jest.fn() + const port = createEditorCompilerPlatformPort(makeHandlers({ handleLibraryInstallation }), makeContext()) + const result = await port.installArduinoLib({ libId: '' }, log) + expect(result.ok).toBe(false) + expect(log).toHaveBeenCalledWith(expect.stringContaining('lib install failed'), 'error') + }) + + // ---- uploadArduinoBoard — port wiring (regression for issue #5) ---- + + it('uploadArduinoBoard forwards args.port to the handler as communicationPort', async () => { + const handleUploadProgram = jest.fn(async () => undefined) + const port = createEditorCompilerPlatformPort(makeHandlers({ handleUploadProgram }), makeContext()) + await port.uploadArduinoBoard( + { compilationPath: '', fqbn: 'arduino:avr:mega', port: '/dev/cu.usbmodem1101' }, + () => undefined, + ) + expect(handleUploadProgram).toHaveBeenCalledWith( + expect.objectContaining({ + communicationPort: '/dev/cu.usbmodem1101', + arduinoPlatform: 'arduino:avr:mega', + }), + ) + }) + + it('uploadArduinoBoard passes communicationPort=undefined to the handler when args.port is empty', async () => { + // Empty string means "no explicit port from the renderer" — the + // handler must fall back to the disk-persisted value rather than + // call arduino-cli with `--port ""`. The undefined sentinel + // signals "fall through" to the handler's legacy code path. + const handleUploadProgram = jest.fn(async () => undefined) + const port = createEditorCompilerPlatformPort(makeHandlers({ handleUploadProgram }), makeContext()) + await port.uploadArduinoBoard({ compilationPath: '', fqbn: 'arduino:avr:mega', port: '' }, () => undefined) + expect(handleUploadProgram).toHaveBeenCalledWith(expect.objectContaining({ communicationPort: undefined })) + }) + + it('uploadArduinoBoard returns ok:false when the upload handler throws', async () => { + const handleUploadProgram = jest.fn(async () => { + throw new Error('serial port busy') + }) + const log = jest.fn() + const port = createEditorCompilerPlatformPort(makeHandlers({ handleUploadProgram }), makeContext()) + const result = await port.uploadArduinoBoard( + { compilationPath: '', fqbn: 'arduino:avr:mega', port: '/dev/ttyACM0' }, + log, + ) + expect(result.ok).toBe(false) + expect(log).toHaveBeenCalledWith(expect.stringContaining('serial port busy'), 'error') + }) + + // ---- packageVppPlugin -------------------------------------------------- + + it('packageVppPlugin forwards to handler and returns empty files map on success', async () => { + const handleVendorPluginPackaging = jest.fn(async () => undefined) + const port = createEditorCompilerPlatformPort(makeHandlers({ handleVendorPluginPackaging }), makeContext()) + const result = await port.packageVppPlugin({ boardTarget: 'SLM-RP4' }, () => undefined) + expect(handleVendorPluginPackaging).toHaveBeenCalledTimes(1) + expect(result).toEqual({ files: {} }) + }) + + it('packageVppPlugin returns an errors[] when the handler throws', async () => { + const handleVendorPluginPackaging = jest.fn(async () => { + throw new Error('VPP read failed') + }) + const port = createEditorCompilerPlatformPort(makeHandlers({ handleVendorPluginPackaging }), makeContext()) + const result = await port.packageVppPlugin({ boardTarget: 'SLM-RP4' }, () => undefined) + expect(result.files).toEqual({}) + expect(result.errors).toHaveLength(1) + expect(result.errors?.[0]?.message).toBe('VPP read failed') + }) + + it('packageVppPlugin forwards the handler log lines through PlatformLog (Buffer → string coercion)', async () => { + const log = jest.fn() + const handleVendorPluginPackaging = jest.fn( + async ( + _boardTarget: string, + _projectPath: string, + _sourceTargetFolderPath: string, + callback: (chunk: Buffer | string, level?: 'info' | 'error') => void, + ) => { + callback('plain string line', 'info') + callback(Buffer.from('buffer line', 'utf-8'), 'error') + callback('default level line') + }, + ) + const port = createEditorCompilerPlatformPort(makeHandlers({ handleVendorPluginPackaging }), makeContext()) + await port.packageVppPlugin({ boardTarget: 'SLM-RP4' }, log) + expect(log).toHaveBeenCalledWith('plain string line', 'info') + expect(log).toHaveBeenCalledWith('buffer line', 'error') + expect(log).toHaveBeenCalledWith('default level line', 'info') + }) + + // ---- checkRuntimeVersion ------------------------------------------------ + + it('checkRuntimeVersion returns the runtime version on a successful probe', async () => { + const makeRuntimeApiRequest = jest.fn(async () => ({ + success: true as const, + data: { version: '4.1.2' }, + })) as unknown as EditorCompilerPlatformPortContext['mainProcessBridge']['makeRuntimeApiRequest'] + const port = createEditorCompilerPlatformPort( + makeHandlers(), + makeContext({ mainProcessBridge: { makeRuntimeApiRequest } }), + ) + const result = await port.checkRuntimeVersion( + { context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } }, + () => undefined, + ) + expect(result).toEqual({ ok: true, version: '4.1.2' }) + }) + + it('checkRuntimeVersion returns version=null and logs a warning on probe failure', async () => { + const makeRuntimeApiRequest = jest.fn(async () => ({ + success: false as const, + error: 'ECONNREFUSED', + })) as unknown as EditorCompilerPlatformPortContext['mainProcessBridge']['makeRuntimeApiRequest'] + const log = jest.fn() + const port = createEditorCompilerPlatformPort( + makeHandlers(), + makeContext({ mainProcessBridge: { makeRuntimeApiRequest } }), + ) + const result = await port.checkRuntimeVersion( + { context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } }, + log, + ) + expect(result).toEqual({ ok: true, version: null }) + expect(log).toHaveBeenCalledWith(expect.stringContaining('Could not reach runtime'), 'warning') + }) + + it('checkRuntimeVersion catches sync throws and returns version=null', async () => { + const makeRuntimeApiRequest = jest.fn(async () => { + throw new Error('probe blew up') + }) as unknown as EditorCompilerPlatformPortContext['mainProcessBridge']['makeRuntimeApiRequest'] + const log = jest.fn() + const port = createEditorCompilerPlatformPort( + makeHandlers(), + makeContext({ mainProcessBridge: { makeRuntimeApiRequest } }), + ) + const result = await port.checkRuntimeVersion( + { context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } }, + log, + ) + expect(result).toEqual({ ok: true, version: null }) + expect(log).toHaveBeenCalledWith(expect.stringContaining('probe blew up'), 'warning') + }) +}) diff --git a/src/backend/editor/compiler/__tests__/load-firmware-skeleton.test.ts b/src/backend/editor/compiler/__tests__/load-firmware-skeleton.test.ts new file mode 100644 index 000000000..1af57d2a8 --- /dev/null +++ b/src/backend/editor/compiler/__tests__/load-firmware-skeleton.test.ts @@ -0,0 +1,177 @@ +/** + * `loadFirmwareSkeletonInMemory` — focused tests. + * + * The method reads `resources/sources/arduino/*` + `resources/sources/Baremetal/*` + * (incl. `Baremetal/modules/*`) off disk and returns a path-keyed + * file map the pipeline's firmware-bundle composer consumes. + * + * Filesystem is real (per-test temp dir) so we exercise the actual + * directory walks; Electron's `app` is mocked because the + * CompilerModule constructor calls `app.getPath('userData')` and we + * don't want that in CI. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +// The CompilerModule constructor reaches for `process.resourcesPath` +// (Electron's packaged-app pointer) and `app.getPath('userData')`. +// Neither exists in Jest's CJS runtime. We stub `resourcesPath` here +// and mock the `electron` module below; the test overrides +// `sourceDirectoryPath` after construction so the stubbed values are +// only used to keep the constructor from throwing. +;(process as unknown as { resourcesPath: string }).resourcesPath = '/tmp/never-used' + +// Mock Electron — the CompilerModule constructor and its private +// path resolvers reach for `app.getPath('userData')`, `app.isPackaged`, +// and `app.getAppPath()` at module load. None of those exist in +// Jest's CJS runtime. The test overrides `sourceDirectoryPath` after +// construction, so the returned mock values just have to keep +// constructor paths from throwing. +jest.mock('electron', () => ({ + app: { + getPath: () => '/tmp/never-used', + isPackaged: false, + getAppPath: () => '/tmp/never-used', + }, +})) + +// Mock strucpp — `loadStrucpp` runs at module load via +// `strucpp-runtime.ts`, and we don't need it for these tests. +jest.mock( + 'strucpp', + () => ({ + compileStlib: jest.fn(), + loadStlibFromString: jest.fn((text: string) => JSON.parse(text)), + }), + { virtual: true }, +) + +import { CompilerModule } from '../compiler-module' + +function makeModule(sourceDir: string): CompilerModule { + const m = new CompilerModule() + // The constructor resolves this from packaged app paths; in tests + // we point it at a temp dir we control. + ;(m as unknown as { sourceDirectoryPath: string }).sourceDirectoryPath = sourceDir + return m +} + +describe('loadFirmwareSkeletonInMemory', () => { + let tmp: string + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'fw-skeleton-')) + }) + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) + }) + + it('returns an empty map for openplc-compiler (runtime v4 path)', async () => { + // v4 builds source strucpp runtime headers via + // `loadStrucppRuntimeHeaders` directly — no Arduino skeleton is + // needed. The method short-circuits before any disk read. + const m = makeModule(tmp) + const result = await m.loadFirmwareSkeletonInMemory('openplc-compiler') + expect(result).toEqual({}) + }) + + it('loads arduino/* into src/ and Baremetal/* into examples/Baremetal/', async () => { + mkdirSync(join(tmp, 'arduino'), { recursive: true }) + writeFileSync(join(tmp, 'arduino', 'openplc.h'), '#pragma once\n') + writeFileSync(join(tmp, 'arduino', 'arduino_runtime_glue.cpp'), '// glue\n') + + mkdirSync(join(tmp, 'Baremetal'), { recursive: true }) + writeFileSync(join(tmp, 'Baremetal', 'Baremetal.ino'), '/* sketch */\n') + writeFileSync(join(tmp, 'Baremetal', 'ModbusSlave.cpp'), '/* mb */\n') + + const m = makeModule(tmp) + const result = await m.loadFirmwareSkeletonInMemory('arduino-cli') + + expect(result['src/openplc.h']).toBe('#pragma once\n') + expect(result['src/arduino_runtime_glue.cpp']).toBe('// glue\n') + expect(result['examples/Baremetal/Baremetal.ino']).toBe('/* sketch */\n') + expect(result['examples/Baremetal/ModbusSlave.cpp']).toBe('/* mb */\n') + }) + + it('loads Baremetal/modules/* into examples/Baremetal/modules/', async () => { + mkdirSync(join(tmp, 'arduino'), { recursive: true }) + mkdirSync(join(tmp, 'Baremetal', 'modules'), { recursive: true }) + writeFileSync(join(tmp, 'Baremetal', 'Baremetal.ino'), '/* sketch */\n') + writeFileSync(join(tmp, 'Baremetal', 'modules', 'modbus_master.cpp'), '/* mm */\n') + writeFileSync(join(tmp, 'Baremetal', 'modules', 'modbus_master.h'), '#pragma once\n') + + const m = makeModule(tmp) + const result = await m.loadFirmwareSkeletonInMemory('simulator') + + expect(result['examples/Baremetal/modules/modbus_master.cpp']).toBe('/* mm */\n') + expect(result['examples/Baremetal/modules/modbus_master.h']).toBe('#pragma once\n') + // Sketch file stays at the top level. + expect(result['examples/Baremetal/Baremetal.ino']).toBe('/* sketch */\n') + }) + + it('skips subdirectories of Baremetal/ that are NOT "modules" (forward compat)', async () => { + mkdirSync(join(tmp, 'arduino'), { recursive: true }) + mkdirSync(join(tmp, 'Baremetal', 'extras'), { recursive: true }) + writeFileSync(join(tmp, 'Baremetal', 'Baremetal.ino'), '/* sketch */\n') + writeFileSync(join(tmp, 'Baremetal', 'extras', 'README.md'), 'should not ship\n') + + const m = makeModule(tmp) + const result = await m.loadFirmwareSkeletonInMemory('simulator') + + expect(result['examples/Baremetal/Baremetal.ino']).toBe('/* sketch */\n') + // `extras/` is not whitelisted, so its contents are skipped. + expect(Object.keys(result).some((k) => k.includes('extras'))).toBe(false) + }) + + it('returns an empty arduino-section when resources/sources/arduino/ is missing', async () => { + // Defensive: a broken / partially-deleted install may have + // Baremetal/ but no arduino/. Method swallows the readdir + // error and returns whatever it DID load — the pipeline / + // composer surfaces the missing-headers failure downstream. + mkdirSync(join(tmp, 'Baremetal'), { recursive: true }) + writeFileSync(join(tmp, 'Baremetal', 'Baremetal.ino'), '/* sketch */\n') + + const m = makeModule(tmp) + const result = await m.loadFirmwareSkeletonInMemory('simulator') + + expect(result['examples/Baremetal/Baremetal.ino']).toBe('/* sketch */\n') + expect(Object.keys(result).some((k) => k.startsWith('src/'))).toBe(false) + }) + + it('returns an empty Baremetal section when resources/sources/Baremetal/ is missing', async () => { + mkdirSync(join(tmp, 'arduino'), { recursive: true }) + writeFileSync(join(tmp, 'arduino', 'openplc.h'), '#pragma once\n') + + const m = makeModule(tmp) + const result = await m.loadFirmwareSkeletonInMemory('simulator') + + expect(result['src/openplc.h']).toBe('#pragma once\n') + expect(Object.keys(result).some((k) => k.startsWith('examples/Baremetal/'))).toBe(false) + }) + + it('returns an empty map when both directories are missing (still resolves, no throw)', async () => { + // Worst case: the entire resources/sources tree is gone (corrupt + // install). Method must not throw — the pipeline's downstream + // arduino-cli compile step will surface a missing-sketch error, + // which is easier to diagnose than an unhandled ENOENT here. + const m = makeModule(tmp) + const result = await m.loadFirmwareSkeletonInMemory('simulator') + expect(result).toEqual({}) + }) + + it('only includes files, not subdirectories, from arduino/', async () => { + mkdirSync(join(tmp, 'arduino', 'subdir'), { recursive: true }) + writeFileSync(join(tmp, 'arduino', 'openplc.h'), '#pragma once\n') + writeFileSync(join(tmp, 'arduino', 'subdir', 'nested.h'), 'should not ship\n') + + const m = makeModule(tmp) + const result = await m.loadFirmwareSkeletonInMemory('simulator') + + expect(result['src/openplc.h']).toBe('#pragma once\n') + // Nested files under subdirs of arduino/ are not whitelisted. + expect(Object.keys(result).some((k) => k.includes('nested'))).toBe(false) + }) +}) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index c2d517512..4155f5f92 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -17,16 +17,11 @@ import { promisify } from 'node:util' type StrucppCompileError = import('strucpp').CompileError import { buildArduinoCliCompileArgs } from '@root/backend/shared/firmware/build-arduino-cli-args' -import { - describeIncompatibleRuntime, - isStrucppCompatibleRuntime, -} from '@root/backend/shared/firmware/runtime-version-gate' import { composeVerificationProject, libraryBuildFromTranspiledSt, prepareXmlForLibraryBuild, } from '@root/backend/shared/library/build-pipeline' -import { deployRuntimeProgram } from '@root/backend/shared/library/deploy-runtime-program' import { buildKnownPous, emitCompileErrorEvents } from '@root/backend/shared/library/program-build-helpers' import { runProgramBuildPipeline } from '@root/backend/shared/library/program-build-pipeline' import { loadStrucpp } from '@root/backend/shared/library/strucpp-runtime' @@ -83,8 +78,11 @@ const POST_BUILD_START_POLL_INTERVAL_MS = 150 import { assertPathContained } from '@root/backend/editor/utils/path-containment' import { getRuntimeHttpsOptions } from '@root/backend/editor/utils/runtime-https-config' -import { generateEthercatConfig } from '@root/backend/shared/ethercat/generate-ethercat-config' -import { validateEthercatConfig } from '@root/backend/shared/ethercat/validate-ethercat-config' +import { runCompilePipeline } from '@root/backend/shared/compile/pipeline' +import { generateDefinesContent } from '@root/backend/shared/compile/steps/generate-defines' +import { mergeStrucppRuntimeIntoSkeleton } from '@root/backend/shared/compile/steps/merge-strucpp-runtime-into-skeleton' +import { resolveBoardSelection } from '@root/backend/shared/compile/steps/resolve-board-selection' +import { readHalsFile } from '@root/backend/shared/firmware/hals-loader' import type { DeviceConfiguration, DevicePin } from '@root/backend/shared/types/PLC/devices' import type { PLCProject, PLCProjectData } from '@root/backend/shared/types/PLC/open-plc' import { @@ -95,17 +93,11 @@ import { type CppPouData as CppPouDataHeader, generateCBlocksHeader, } from '@root/backend/shared/utils/cpp/generateCBlocksHeader' -import { generateModbusMasterConfig } from '@root/backend/shared/utils/modbus/generate-modbus-master-config' import { validatePathId } from '@root/backend/shared/utils/path-safety' import { XmlGenerator } from '@root/backend/shared/utils/PLC/xml-generator' -import { parsePlcStatus } from '@root/backend/shared/utils/plc-status' import { generateVendorPluginConfig } from '@root/backend/shared/utils/vpp/generate-vendor-plugin-config' import { getErrorMessage } from '@root/frontend/utils/get-error-message' -import { generateModbusSlaveConfig } from '@root/frontend/utils/modbus/generate-modbus-slave-config' -import { generateOpcUaConfig, OpcUaConfigError } from '@root/frontend/utils/opcua' -import { generateS7CommConfig } from '@root/frontend/utils/s7comm' import type { CompileLibraryResult } from '@root/middleware/shared/ports/types' -import { composeRuntimeV4Bundle } from '@root/middleware/shared/utils/library/compose-runtime-v4-bundle' import { app as electronApp, dialog, MessageChannelMain } from 'electron' import type { MessagePortMain } from 'electron/main' import JSZip from 'jszip' @@ -113,6 +105,7 @@ import JSZip from 'jszip' import type { PackageManifest } from '../package-manager' import { PackageManagerModule } from '../package-manager' import { CreateXMLFile } from '../utils' +import { createEditorCompilerPlatformPort } from './editor-compiler-platform-port' import type { ArduinoCoreControl, HalsFile } from './types' interface MethodsResult { @@ -160,7 +153,6 @@ type CompileArduinoProgramArgs = { class CompilerModule { binaryDirectoryPath: string sourceDirectoryPath: string - halsFilePath: string arduinoCliBinaryPath: string arduinoCliConfigurationFilePath: string @@ -211,7 +203,6 @@ class CompilerModule { constructor() { this.binaryDirectoryPath = this.#constructBinaryDirectoryPath() this.sourceDirectoryPath = this.#constructSourceDirectoryPath() - this.halsFilePath = this.#constructHalsFilePath() this.arduinoCliBinaryPath = this.#constructArduinoCliBinaryPath() this.arduinoCliConfigurationFilePath = join(electronApp.getPath('userData'), 'User', 'arduino-cli.yaml') @@ -273,16 +264,6 @@ class CompilerModule { ) } - #constructHalsFilePath(): string { - return join( - CompilerModule.DEVELOPMENT_MODE ? process.cwd() : process.resourcesPath, - CompilerModule.DEVELOPMENT_MODE ? 'resources' : '', - 'sources', - 'boards', - 'hals.json', - ) - } - #constructArduinoCliBinaryPath(): string { return join(this.binaryDirectoryPath, 'arduino-cli') } @@ -321,21 +302,22 @@ class CompilerModule { * Resolve a board target to the arduino-cli core ID * (`arduino-cli core install` target — e.g. `arduino:avr`). * - * Single source of truth: reads from `resources/sources/boards/ - * hals.json`, the same file the renderer's - * `bridge.getAvailableBoards()` exposes via `boardInfo.core`. + * Single source of truth: reads from the shared + * `backend/shared/firmware/hals.json` bundle, the same file the + * renderer's `bridge.getAvailableBoards()` exposes via + * `boardInfo.core`. * Used internally by the library-project verification path so a * future hals.json edit (rename, new board, version bump) * propagates to verification automatically — without any code * change here. */ async #getBoardCore(board: string): Promise { - const halsFileContent = await CompilerModule.readJSONFile(this.halsFilePath) + const halsFileContent = await readHalsFile() return halsFileContent[board]?.['core'] ?? null } async #getBoardRuntime(board: string) { - const halsFileContent = await CompilerModule.readJSONFile(this.halsFilePath) + const halsFileContent = await readHalsFile() if (halsFileContent[board]) { return halsFileContent[board]['compiler'] } @@ -584,6 +566,90 @@ class CompilerModule { * Flat directory (no subfolders) per the strucpp release layout — * `readdir(runtimeDir)` is enough; no recursive walk. */ + /** + * Load the firmware skeleton files (`resources/sources/arduino/*` + * + `resources/sources/Baremetal/**`) into an in-memory file map + * keyed by the canonical project-root-relative paths the shared + * `composeFirmwareBundle` expects. + * + * Editor's existing `copyStaticFiles` materialises these to disk + * between pipeline steps; the shared pipeline routes them through + * `composeFirmwareBundle` as a `Record` instead so + * the same composition logic works on web (where there's no + * filesystem). This helper bridges the gap: it walks the on-disk + * skeleton once and returns it in the canonical shape. + * + * Path mapping (matches `copyStaticFiles`'s on-disk layout): + * - `resources/sources/arduino/` → `src/` + * - `resources/sources/Baremetal/` → `examples/Baremetal/` + * - `resources/sources/Baremetal/modules/` → `examples/Baremetal/modules/` + * + * Strucpp runtime headers (`src/.hpp`) come from + * `loadStrucppRuntimeHeaders` separately — they have a different + * source path (`node_modules/strucpp/...`) and the shared + * `composeRuntimeV4Bundle` puts them under + * `strucpp_runtime/include/` instead of `src/`. Callers + * pick the right one for their target. + * + * `boardRuntime === 'openplc-compiler'` (runtime v4) returns an + * empty map — the v4 bundle is composed by `composeRuntimeV4Bundle` + * which sources strucpp runtime headers from + * `loadStrucppRuntimeHeaders` directly, no Arduino skeleton + * needed. + */ + async loadFirmwareSkeletonInMemory(boardRuntime: string): Promise> { + if (boardRuntime === 'openplc-compiler') { + return {} + } + const arduinoDir = join(this.sourceDirectoryPath, 'arduino') + const baremetalDir = join(this.sourceDirectoryPath, 'Baremetal') + const files: Record = {} + + // arduino/* → src/* + try { + const arduinoEntries = await readdir(arduinoDir, { withFileTypes: true }) + await Promise.all( + arduinoEntries + .filter((e) => e.isFile()) + .map(async (e) => { + const content = await readFile(join(arduinoDir, e.name), 'utf-8') + files[`src/${e.name}`] = content + }), + ) + } catch { + // arduino/ may be absent in odd setups — leave the skeleton + // empty so the pipeline / composer surfaces a clear error + // downstream instead of crashing here. + } + + // Baremetal/* → examples/Baremetal/* (plus modules subdir). + try { + const baremetalEntries = await readdir(baremetalDir, { withFileTypes: true }) + await Promise.all( + baremetalEntries.map(async (e) => { + if (e.isFile()) { + const content = await readFile(join(baremetalDir, e.name), 'utf-8') + files[`examples/Baremetal/${e.name}`] = content + } else if (e.isDirectory() && e.name === 'modules') { + const moduleEntries = await readdir(join(baremetalDir, 'modules'), { withFileTypes: true }) + await Promise.all( + moduleEntries + .filter((m) => m.isFile()) + .map(async (m) => { + const content = await readFile(join(baremetalDir, 'modules', m.name), 'utf-8') + files[`examples/Baremetal/modules/${m.name}`] = content + }), + ) + } + }), + ) + } catch { + // Same defensive posture as the arduino/ block above. + } + + return files + } + private async loadStrucppRuntimeHeaders(): Promise> { const runtimeDir = this.strucppRuntimeDir try { @@ -948,6 +1014,24 @@ class CompilerModule { }) } + /** + * Read the disk inputs `generateDefinesContent` needs (hals.json, + * pin-mapping.json, program.st) and write the authored `defines.h` + * to `build//src/defines.h`. + * + * The content-authoring logic lives in the shared + * `backend/shared/compile/steps/generate-defines.ts` so the web's + * pipeline can produce the same byte-for-byte `defines.h` from + * the same inputs. This method is thin glue around the shared + * function — filesystem reads in, write call out. + * + * `defines.h` lives alongside `arduino.cpp` in `src/`. The HAL + * templates include it as plain `"defines.h"` so the file is found + * whether arduino-cli compiles the source in place or moves it + * into its sketch sandbox first — avoids the directory-relative + * include that broke on paths with spaces and on VM shared-folder + * mounts. + */ async handleGenerateDefinitionsFile({ projectPath, buildMD5Hash, @@ -961,159 +1045,25 @@ class CompilerModule { boardRuntime: string _handleOutputData: HandleOutputDataCallback }) { - let DEFINES_CONTENT: string = '' - - // === Directories and files paths === - const devicesDirectoryPath = join(projectPath, 'devices') - const devicesPinMappingFilePath = join(devicesDirectoryPath, 'pin-mapping.json') - + const devicesPinMappingFilePath = join(projectPath, 'devices', 'pin-mapping.json') const buildTargetDirectoryPath = join(projectPath, 'build', boardTarget) - const stProgramFilePath = join(buildTargetDirectoryPath, 'src', 'program.st') - - // defines.h lives alongside arduino.cpp in src/. The HAL templates - // include it as plain "defines.h" so the file is found whether - // arduino-cli compiles the source in place or moves it into its - // sketch sandbox first. Avoids the directory-relative include - // that broke on paths with spaces and on VM shared-folder mounts. const definitionsFilePath = join(buildTargetDirectoryPath, 'src', 'defines.h') - // === Files contents that we need === - const halsFileContent = await CompilerModule.readJSONFile(this.halsFilePath) + const halsFileContent = await readHalsFile() const devicePinMapping = await CompilerModule.readJSONFile(devicesPinMappingFilePath) const stProgramFileContent = await readFile(stProgramFilePath, 'utf-8') - // We extract the board entry from the hals file content to validate if it has the define property. - const boardEntry = halsFileContent[boardTarget] - - // ===== Defines.h content generation ===== - - // 1. We need to verify if the board entry in the hals.json file has the define property. - if (boardEntry && boardEntry.define) { - // 1.2. If it has the defines property, we will write a header and iterate over the defines to create the content for the defines.h file. - DEFINES_CONTENT = '// Board defines\n' - if (Array.isArray(boardEntry.define)) { - // 1.3. If the defines property is an array, we will iterate over it and add each define to the content. - boardEntry.define.forEach((define) => { - DEFINES_CONTENT += `#define ${define}\n` - }) - } else if (typeof boardEntry.define === 'string') { - // 1.4. If the defines property is a string, we will add it directly to the content. - DEFINES_CONTENT += `#define ${boardEntry.define}\n` - } - } - - // 2. If the board entry does not have the define property, we will just write a double line break to the file. - DEFINES_CONTENT += '\n\n' - - // 3. Now we write the information for the defines.h file based on the device configuration and other preferences. - - /** - * TODOS - * 3. In the device configuration we need to verify why the values that should be null are being set to empty strings. - * 4. We need to ensure that the pins are correctly sorted according to their address. - */ - - // 3.1. Program MD5 - DEFINES_CONTENT += '//Program MD5\n' - DEFINES_CONTENT += `#define PROGRAM_MD5 "${buildMD5Hash}"` - DEFINES_CONTENT += `\n\n` + const definesContent = generateDefinesContent({ + boardEntry: halsFileContent[boardTarget], + devicePinMapping, + stProgramFileContent, + buildMD5Hash, + boardRuntime, + }) - // 3.2. Simulator communication defines - // - // Baremetal/Arduino-family targets used to emit a full //Comms - // Configuration block here, read from deviceConfigurationSchema's - // communicationConfiguration field. That schema is gone — Arduino - // targets will return as VPP packages and each package owns its - // own defines emission. The only target still emitting communication - // defines from the core compiler is the built-in simulator. - if (boardRuntime === 'simulator') { - // Simulator forces fixed Modbus RTU settings over emulated USART0. - // On ATmega2560, Serial = USART0. avr8js bridges usart0. - DEFINES_CONTENT += '//Comms Configuration\n' - DEFINES_CONTENT += '#define SIMULATOR_MODE\n' - DEFINES_CONTENT += '#define MBSERIAL_IFACE Serial\n' - DEFINES_CONTENT += '#define MBSERIAL_BAUD 115200\n' - DEFINES_CONTENT += '#define MBSERIAL_SLAVE 1\n' - DEFINES_CONTENT += '#define MBSERIAL\n' - DEFINES_CONTENT += '#define MODBUS_ENABLED\n' - DEFINES_CONTENT += `\n\n` - } - - // INFO: If null, only the define value - // 3.3. IO Config defines - DEFINES_CONTENT += '//IO Config\n' - // INFO: This approach assumes that the pins are sorted. - const digitalInputPins = devicePinMapping.filter((pin) => pin.pinType === 'digitalInput') - const analogInputPins = devicePinMapping.filter((pin) => pin.pinType === 'analogInput') - const digitalOutputPins = devicePinMapping.filter((pin) => pin.pinType === 'digitalOutput') - const analogOutputPins = devicePinMapping.filter((pin) => pin.pinType === 'analogOutput') - - DEFINES_CONTENT += `#define PINMASK_DIN ${digitalInputPins.map(({ pin }) => pin).join(', ')}\n` - DEFINES_CONTENT += `#define PINMASK_AIN ${analogInputPins.map(({ pin }) => pin).join(', ')}\n` - DEFINES_CONTENT += `#define PINMASK_DOUT ${digitalOutputPins.map(({ pin }) => pin).join(', ')}\n` - DEFINES_CONTENT += `#define PINMASK_AOUT ${analogOutputPins.map(({ pin }) => pin).join(', ')}\n` - - DEFINES_CONTENT += `#define NUM_DISCRETE_INPUT ${digitalInputPins.length}\n` - DEFINES_CONTENT += `#define NUM_ANALOG_INPUT ${analogInputPins.length}\n` - DEFINES_CONTENT += `#define NUM_DISCRETE_OUTPUT ${digitalOutputPins.length}\n` - DEFINES_CONTENT += `#define NUM_ANALOG_OUTPUT ${analogOutputPins.length}\n` - DEFINES_CONTENT += `\n\n` - - // 3.4. Arduino libraries defines - DEFINES_CONTENT += '//Arduino libraries\n' - if ( - stProgramFileContent.includes('DS18B20;') || - stProgramFileContent.includes('DS18B20_2_OUT;') || - stProgramFileContent.includes('DS18B20_3_OUT;') || - stProgramFileContent.includes('DS18B20_4_OUT;') || - stProgramFileContent.includes('DS18B20_5_OUT;') - ) { - DEFINES_CONTENT += '#define USE_DS18B20_BLOCK\n' - } - - if (stProgramFileContent.includes('P1AM_INIT;')) DEFINES_CONTENT += '#define USE_P1AM_BLOCKS\n' - - if (stProgramFileContent.includes('CLOUD_BEGIN;')) DEFINES_CONTENT += '#define USE_CLOUD_BLOCKS\n' - - if (stProgramFileContent.includes('MQTT_CONNECT;') || stProgramFileContent.includes('MQTT_CONNECT_AUTH;')) - DEFINES_CONTENT += '#define USE_MQTT_BLOCKS\n' - - if ( - stProgramFileContent.includes('ARDUINOCAN_CONF;') || - stProgramFileContent.includes('ARDUINOCAN_WRITE;') || - stProgramFileContent.includes('ARDUINOCAN_WRITE_WORD;') || - stProgramFileContent.includes('ARDUINOCAN_READ;') - ) { - DEFINES_CONTENT += '#define USE_ARDUINOCAN_BLOCK\n' - } - - if ( - stProgramFileContent.includes('STM32CAN_CONF;') || - stProgramFileContent.includes('STM32CAN_WRITE;') || - stProgramFileContent.includes('STM32CAN_READ;') - ) { - DEFINES_CONTENT += '#define USE_STM32CAN_BLOCK\n' - } - - if ( - stProgramFileContent.includes('SM_8RELAY;') || - stProgramFileContent.includes('SM_16RELAY;') || - stProgramFileContent.includes('SM_8DIN;') || - stProgramFileContent.includes('SM_16DIN;') || - stProgramFileContent.includes('SM_4REL4IN;') || - stProgramFileContent.includes('SM_INDUSTRIAL;') || - stProgramFileContent.includes('SM_RTD;') || - stProgramFileContent.includes('SM_BAS;') || - stProgramFileContent.includes('SM_HOME;') || - stProgramFileContent.includes('SM_8MOSFET;') - ) { - DEFINES_CONTENT += '#define USE_SM_BLOCKS\n' - } - - // 4. Finally, we attempt to write the content to the defines.h file. try { - await writeFile(definitionsFilePath, DEFINES_CONTENT, { encoding: 'utf8' }) + await writeFile(definitionsFilePath, definesContent, { encoding: 'utf8' }) _handleOutputData(`Defines file created at: ${definitionsFilePath}`, 'info') } catch (_error) { _handleOutputData('Error writing defines.h file', 'error') @@ -1127,7 +1077,7 @@ class CompilerModule { async handleGenerateArduinoCppFile(projectPath: string, boardTarget: string) { let result: MethodsResult = { success: false } - const halsFileContent = await CompilerModule.readJSONFile(this.halsFilePath) + const halsFileContent = await readHalsFile() const boardSourceFile = halsFileContent[boardTarget]['source'] @@ -1317,17 +1267,35 @@ class CompilerModule { projectPath, arduinoPlatform, compilationPath, + communicationPort, handleOutputData, }: { projectPath: string arduinoPlatform: string compilationPath: string + /** + * Serial port arduino-cli should target with `--port`. Preferred + * over the disk-persisted value when both are present — captures + * the picker's current selection even when the user hasn't saved + * the project yet. When omitted, the handler falls back to the + * legacy disk read so older invocation paths still work. + */ + communicationPort?: string handleOutputData: HandleOutputDataCallback }) { - const devicesDirectoryPath = join(projectPath, 'devices') - const devicesConfigurationFilePath = join(devicesDirectoryPath, 'configuration.json') - const { communicationPort: port } = - await CompilerModule.readJSONFile(devicesConfigurationFilePath) + let port = communicationPort + if (!port) { + const devicesDirectoryPath = join(projectPath, 'devices') + const devicesConfigurationFilePath = join(devicesDirectoryPath, 'configuration.json') + try { + const { communicationPort: persistedPort } = + await CompilerModule.readJSONFile(devicesConfigurationFilePath) + port = persistedPort + } catch { + // No devices/configuration.json yet — drop into the + // "no port specified" branch below for a clear user message. + } + } const baremetalPath = join(compilationPath, 'examples', 'Baremetal') if (!port) { @@ -1515,171 +1483,6 @@ class CompilerModule { } } - async handleGenerateModbusSlaveConfig( - sourceTargetFolderPath: string, - projectData: PLCProjectData, - handleOutputData: HandleOutputDataCallback, - ): Promise { - const modbusSlaveConfig: string | null = generateModbusSlaveConfig( - projectData.servers as Parameters[0], - ) - - if (modbusSlaveConfig) { - const confFolderPath = join(sourceTargetFolderPath, 'conf') - await mkdir(confFolderPath, { recursive: true }) - const configFilePath = join(confFolderPath, 'modbus_slave.json') - await writeFile(configFilePath, modbusSlaveConfig, 'utf-8') - handleOutputData('Generated conf/modbus_slave.json', 'info') - } else { - handleOutputData('No Modbus TCP server configured, skipping modbus_slave.json generation', 'info') - } - } - - async handleGenerateModbusMasterConfig( - sourceTargetFolderPath: string, - projectData: PLCProjectData, - handleOutputData: HandleOutputDataCallback, - ): Promise { - const modbusMasterConfig: string | null = generateModbusMasterConfig( - projectData.remoteDevices as Parameters[0], - ) - - if (modbusMasterConfig) { - const confFolderPath = join(sourceTargetFolderPath, 'conf') - await mkdir(confFolderPath, { recursive: true }) - const configFilePath = join(confFolderPath, 'modbus_master.json') - await writeFile(configFilePath, modbusMasterConfig, 'utf-8') - handleOutputData('Generated conf/modbus_master.json', 'info') - } else { - handleOutputData('No Modbus TCP remote devices configured, skipping modbus_master.json generation', 'info') - } - } - - async handleGenerateS7CommConfig( - sourceTargetFolderPath: string, - projectData: PLCProjectData, - handleOutputData: HandleOutputDataCallback, - ): Promise { - try { - const s7commConfig: string | null = generateS7CommConfig(projectData.servers) - - if (s7commConfig) { - const confFolderPath = join(sourceTargetFolderPath, 'conf') - await mkdir(confFolderPath, { recursive: true }) - const configFilePath = join(confFolderPath, 's7comm.json') - await writeFile(configFilePath, s7commConfig, 'utf-8') - handleOutputData('Generated conf/s7comm.json', 'info') - } else { - handleOutputData('No S7Comm server configured, skipping s7comm.json generation', 'info') - } - } catch (error) { - const errorMessage = getErrorMessage(error) - handleOutputData(`Failed to generate S7Comm config: ${errorMessage}`, 'error') - throw error - } - } - - /** - * Generate OPC-UA server configuration for Runtime v4. - * Reads debug.c to resolve variable indices and generates opcua.json. - */ - async handleGenerateOpcUaConfig( - sourceTargetFolderPath: string, - projectData: PLCProjectData, - handleOutputData: HandleOutputDataCallback, - ): Promise { - try { - // Check if there's an enabled OPC-UA server - const opcuaServer = projectData.servers?.find( - (s) => s.protocol === 'opcua' && s.opcuaServerConfig?.server.enabled, - ) - - if (!opcuaServer || !opcuaServer.opcuaServerConfig) { - handleOutputData('No OPC-UA server configured, skipping opcua.json generation', 'info') - return - } - - // Read STruC++'s debug-map.json (replaces MatIEC's debug.c). - // Generated by the codegen pipeline at compile time alongside - // generated.cpp / generated.hpp. - const debugMapPath = join(sourceTargetFolderPath, 'debug-map.json') - let debugMapContent: string - - try { - debugMapContent = await readFile(debugMapPath, 'utf-8') - } catch { - handleOutputData( - 'Warning: Could not read debug-map.json. OPC-UA variable addresses may not be resolved.', - 'error', - ) - debugMapContent = '' - } - - // Get instances from Resources configuration for address resolution - const instances = projectData.configuration.resource.instances.map((inst) => ({ - name: inst.name, - task: inst.task, - program: inst.program, - })) - - // Generate the OPC-UA configuration. Field-level resolution - // failures (stale library-FB internals, renamed/deleted vars) - // surface as build warnings instead of aborting; the generator - // drops them and we forward each to the compile log. - const opcuaJson: string | null = generateOpcUaConfig(projectData.servers, debugMapContent, instances, (msg) => - handleOutputData(msg, 'info'), - ) - - if (opcuaJson) { - // Ensure conf directory exists - const confFolderPath = join(sourceTargetFolderPath, 'conf') - await mkdir(confFolderPath, { recursive: true }) - - // Write the configuration file - const configFilePath = join(confFolderPath, 'opcua.json') - await writeFile(configFilePath, opcuaJson, 'utf-8') - handleOutputData('Generated conf/opcua.json', 'info') - - // Log the number of configured nodes - const nodeCount = opcuaServer.opcuaServerConfig.addressSpace.nodes.length - handleOutputData(`OPC-UA Address Space: ${nodeCount} node(s) configured`, 'info') - } else { - handleOutputData('OPC-UA server enabled but no configuration generated', 'info') - } - } catch (error) { - if (error instanceof OpcUaConfigError) { - handleOutputData(`OPC-UA Configuration Error:\n${error.message}`, 'error') - } else { - const errorMessage = getErrorMessage(error) - handleOutputData(`Failed to generate OPC-UA config: ${errorMessage}`, 'error') - } - throw error - } - } - - async handleGenerateEthercatConfig( - sourceTargetFolderPath: string, - projectData: PLCProjectData, - handleOutputData: HandleOutputDataCallback, - ): Promise { - const ethercatConfig = generateEthercatConfig(projectData.remoteDevices) - - const ethercatErrors = validateEthercatConfig(ethercatConfig) - if (ethercatErrors.length > 0) { - throw new Error(`EtherCAT configuration is invalid: ${ethercatErrors.join('; ')}`) - } - - if (ethercatConfig) { - const confFolderPath = join(sourceTargetFolderPath, 'conf') - await mkdir(confFolderPath, { recursive: true }) - const configFilePath = join(confFolderPath, 'ethercat.json') - await writeFile(configFilePath, ethercatConfig, 'utf-8') - handleOutputData('Generated conf/ethercat.json', 'info') - } else { - handleOutputData('No EtherCAT devices configured, skipping ethercat.json generation', 'info') - } - } - async embedCBlocksInProgramSt( sourceTargetFolderPath: string, handleOutputData: HandleOutputDataCallback, @@ -1972,10 +1775,13 @@ class CompilerModule { } /** - * This will be the main entry point for the compiler module. - * It will handle all the compilation process, will orchestrate the various steps involved in compiling a program. + * Main compile entry point. Drives the full Step 0-13 flow + * through the shared `runCompilePipeline` orchestrator + * (`backend/shared/compile/pipeline.ts`); platform-specific bits + * (xml2st spawn, arduino-cli spawn, runtime upload) are abstracted + * behind `EditorCompilerPlatformPort`. Single source of truth + * for compile behaviour shared with openplc-web. */ - // Work in progress - we should specify the arguments and the return type correctly. async compileProgram( args: Array, _mainProcessPort: MessagePortMain, @@ -1996,13 +1802,9 @@ class CompilerModule { loadEnabledArchives: (enabledNames: string[]) => { archives: unknown[]; missing: string[] } }, ): Promise { - // Start the main process port to communicate with the renderer process. - // INFO: This is necessary to send messages back to the renderer process. _mainProcessPort.start() - _mainProcessPort.postMessage({ logLevel: 'info', message: 'Starting compilation process...' }) - // INFO: We assume the first argument is the project path, - // INFO: the second argument is the board target, and the third argument is the project data. + const [ projectPath, boardTarget, @@ -2012,6 +1814,7 @@ class CompilerModule { runtimeIpAddress, runtimeJwtToken, cleanBuild, + communicationPort, ] = args as [ string, string, @@ -2021,53 +1824,94 @@ class CompilerModule { string | null, string | null, boolean | undefined, + string | null | undefined, ] - const boardRuntime = await this.#getBoardRuntime(boardTarget) // Get the board runtime from the hals.json file - - const halsContent = await CompilerModule.readJSONFile(this.halsFilePath) - + const halsContent = await readHalsFile() + const selection = resolveBoardSelection( + halsContent as Record[0][string]>, + boardTarget, + ) + // Resolved fields the rest of compileProgram consumes. Default + // to the shared resolver's output when the board lives in + // hals.json; otherwise (VPP boards installed via `.vpp` packages) + // fall back to the package-manager lookup so the runtime kind + + // flags still reflect the user's selection. + let boardEntry: Parameters[0]['boardEntry'] + let boardRuntime: string + let isSimulator: boolean + let isRuntimeV3: boolean + let isRuntimeV4: boolean + if (selection.ok) { + boardEntry = selection.boardEntry as unknown as Parameters[0]['boardEntry'] + boardRuntime = selection.boardRuntime + isSimulator = selection.isSimulator + isRuntimeV3 = selection.isRuntimeV3 + isRuntimeV4 = selection.isRuntimeV4 + } else { + // VPP fallback — board lives in an installed `.vpp` package + // rather than hals.json. Derive the runtime from the manifest's + // `target.type` (matches the pre-refactor `#getBoardRuntime` + // behaviour that fed all subsequent branching). Web doesn't + // need this fallback — its installed-package surface is empty + // by design — so it stays in the editor-specific branch here. + let vppRuntime: 'openplc-compiler' | 'arduino-cli' | null = null + try { + const packageManager = new PackageManagerModule() + for (const pkg of packageManager.listInstalled()) { + const manifest = packageManager.getInstalledPackageManifest(pkg.packageId) + if (!manifest) continue + const device = manifest.devices.find((d) => d.name === boardTarget) + if (device) { + vppRuntime = device.target.type === 'runtime-v4' ? 'openplc-compiler' : 'arduino-cli' + break + } + } + } catch { + // Package manager errors fall through to the no-match path + // below — same behaviour as `#getBoardRuntime`. + } + if (!vppRuntime) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `Board "${boardTarget}" not found in hals.json or installed VPP packages.`, + }) + _mainProcessPort.postMessage({ logLevel: 'error', message: 'Stopping compilation process.' }) + _mainProcessPort.close() + return + } + // VPP boards don't ship a hals.json entry — feed the pipeline + // an empty placeholder. The runtime-v4 / Arduino branches the + // pipeline picks based on the flags below don't dereference + // `boardEntry.platform` until the arduino-cli compile step, + // which doesn't run for runtime-v4 (VPP boards' canonical + // target). + boardEntry = {} as unknown as Parameters[0]['boardEntry'] + boardRuntime = vppRuntime + isRuntimeV3 = boardTarget === 'OpenPLC Runtime v3' + isRuntimeV4 = vppRuntime === 'openplc-compiler' && !isRuntimeV3 + isSimulator = false + } const normalizedProjectPath = projectPath.replace('project.json', '') + const compilationPath = join(normalizedProjectPath, 'build', boardTarget) + const sourceTargetFolderPath = join(compilationPath, 'src') - const compilationPath = join(normalizedProjectPath, 'build', boardTarget) // Assuming the build folder is named 'build' - - const sourceTargetFolderPath = join(compilationPath, 'src') // Assuming the source folder is named 'src' - - let buildMD5Hash: string | null = null - // Strucpp emit's in-memory file map. Populated by `handleCompileSTtoCpp` - // and threaded into the runtime v4 block so we can compose the upload - // bundle without re-reading every artefact off disk. Stays empty for - // any compile path that doesn't reach the strucpp step. - let strucppEmittedFiles: Record = {} - - // --- Print basic information --- + // --- Editor-specific preamble: project header, host info, VPP warnings, tool check --- _mainProcessPort.postMessage({ logLevel: 'info', message: `Compiling program for project: ${projectPath} and board target: ${boardTarget}`, }) - _mainProcessPort.postMessage({ - logLevel: 'warning', - message: 'Host Hardware Info:', - }) - _mainProcessPort.postMessage({ - message: this.getHostHardwareInfo(), - }) - - // --- Check for unsupported features on non-v4 targets --- - // VPP boards with runtime-v4 target type use openplc-compiler and are also v4-capable - const isRuntimeV3 = boardTarget === 'OpenPLC Runtime v3' - const isRuntimeV4 = boardRuntime === 'openplc-compiler' && !isRuntimeV3 + _mainProcessPort.postMessage({ logLevel: 'warning', message: 'Host Hardware Info:' }) + _mainProcessPort.postMessage({ message: this.getHostHardwareInfo() }) const hasServers = projectData.servers && projectData.servers.length > 0 const hasRemoteDevices = projectData.remoteDevices && projectData.remoteDevices.length > 0 - if (!isRuntimeV4 && hasServers) { _mainProcessPort.postMessage({ logLevel: 'warning', message: `Warning: Your project contains Modbus Server configurations, but the selected target (${boardTarget}) does not support this feature. Modbus Server is only supported on OpenPLC Runtime v4. The server configurations will be ignored during compilation.`, }) } - if (!isRuntimeV4 && hasRemoteDevices) { _mainProcessPort.postMessage({ logLevel: 'warning', @@ -2075,9 +1919,7 @@ class CompilerModule { }) } - // --- Check tools availability --- _mainProcessPort.postMessage({ logLevel: 'info', message: 'Checking tools availability...' }) - try { const [arduinoCliCheckResult, strucppCheckResult] = await Promise.all([ this.checkArduinoCliAvailability(), @@ -2096,7 +1938,9 @@ class CompilerModule { return } - // Step 1: Create basic directories + // Create the build//{src,examples/Baremetal,...} directory tree + // up front so the platform port methods that write to disk (transpile, + // compile) have somewhere to land. try { await this.createBasicDirectories(normalizedProjectPath, boardTarget) _mainProcessPort.postMessage({ @@ -2113,691 +1957,200 @@ class CompilerModule { return } - // Step 2: Generate XML from JSON - let generateXMLResult: MethodsResult<{ xmlPath: string; xmlContent: string }> = { success: false } + // --- Resolve pipeline inputs --- + let firmwareSkeleton: Record + let strucppRuntimeHeaders: Record + let devicePinMapping: DevicePin[] + let libraryArchives: unknown[] + let missingLibraries: string[] + let avrLibStdCppInclude = '' try { - generateXMLResult = await this.handleGenerateXMLfromJSON(sourceTargetFolderPath, projectData) - _mainProcessPort.postMessage({ - logLevel: 'info', - message: `Generated XML from JSON at: ${generateXMLResult.data?.xmlPath as string}`, - }) - } catch (error) { - _mainProcessPort.postMessage({ - logLevel: 'error', - message: `Error generating XML from JSON: ${error as string}\nStopping compilation process.`, - }) - _mainProcessPort.close() - return - } - - // Step 3: Transpile XML to ST - const generatedXMLFilePath = join(sourceTargetFolderPath, 'plc.xml') // Assuming the XML file is named 'plc.xml' - try { - await this.handleTranspileXMLtoST(generatedXMLFilePath, (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) - } catch (error) { - _mainProcessPort.postMessage({ - logLevel: 'error', - message: `Error transpiling XML to ST: ${error as string}\nStopping compilation process.`, - }) - _mainProcessPort.close() - return - } - - // -- Copy static files -- - _mainProcessPort.postMessage({ logLevel: 'info', message: 'Copying static files...' }) - try { - await this.copyStaticFiles(compilationPath, boardRuntime, isRuntimeV4) - _mainProcessPort.postMessage({ logLevel: 'info', message: 'Static files copied successfully.' }) - } catch (error) { - _mainProcessPort.postMessage({ - logLevel: 'error', - message: `Error copying static files: ${error as string}\nStopping compilation process.`, - }) - _mainProcessPort.close() - return - } - - // Step 4: Compile ST to C++ with STruC++ (replaces iec2c + debug + glue generation) - try { - const hasCBlocks = ((projectData as ProjectDataWithCppPous).originalCppPous?.length ?? 0) > 0 - // Hand the POU list to handleCompileSTtoCpp so the splitter can - // segment program.st into per-POU files and surface errors with - // POU-relative location data. - const knownPous = buildKnownPous(projectData.pous) - // Resolve project-enabled libraries to parsed `.stlib` archives. - // Bundled libs are always-on; missing names (enabled but not - // installed) abort the compile early in handleCompileSTtoCpp - // with a clear "open the Library Manager" message. - const enabledLibraryNames = (projectData.libraries ?? []).map((ref) => ref.name) - const { archives: libraries, missing: missingLibraries } = - mainProcessBridge.loadEnabledArchives(enabledLibraryNames) - const { md5Hash, strucppFiles } = await this.handleCompileSTtoCpp( - sourceTargetFolderPath, - (data, logLevel, compileError) => { - _mainProcessPort.postMessage({ - logLevel, - message: data, - ...(compileError ? { compileError } : {}), - }) - }, - { hasCBlocks, pous: knownPous, libraries, missingLibraries }, - ) - buildMD5Hash = md5Hash - strucppEmittedFiles = strucppFiles - } catch (error) { - _mainProcessPort.postMessage({ - logLevel: 'error', - message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), - }) - _mainProcessPort.postMessage({ - logLevel: 'error', - message: 'Stopping compilation process.', - }) - _mainProcessPort.close() - return - } - - // Step 7 / 8: Generate C/C++ blocks header + code. Skipped for - // runtime v4 — the runtime v4 block below routes c_blocks.h / - // c_blocks_code.cpp through `composeRuntimeV4Bundle` so the upload - // bundle has a single canonical producer. Arduino and v3 still - // need the disk writes here (Arduino: arduino-cli consumes them; - // v3: `embedCBlocksInProgramSt` reads c_blocks.h off disk). - if (!isRuntimeV4) { - try { - await this.handleGenerateCBlocksHeader(projectData, sourceTargetFolderPath, (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) - } catch (error) { - _mainProcessPort.postMessage({ - logLevel: 'error', - message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), - }) - _mainProcessPort.postMessage({ - logLevel: 'error', - message: 'Stopping compilation process.', - }) - _mainProcessPort.close() - return - } - - try { - await this.handleGenerateCBlocksCode(projectData, compilationPath, boardRuntime, (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) - } catch (error) { - _mainProcessPort.postMessage({ - logLevel: 'error', - message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), - }) - _mainProcessPort.postMessage({ - logLevel: 'error', - message: 'Stopping compilation process.', - }) - _mainProcessPort.close() - return - } - } - - // Step 9: Embed C/C++ blocks in program.st for Runtime v3 - if (boardRuntime === 'openplc-compiler' && boardTarget === 'OpenPLC Runtime v3') { - try { - await this.embedCBlocksInProgramSt(sourceTargetFolderPath, (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) - } catch (error) { - _mainProcessPort.postMessage({ - logLevel: 'error', - message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), - }) - _mainProcessPort.postMessage({ - logLevel: 'error', - message: 'Stopping compilation process.', - }) - _mainProcessPort.close() - return - } - } - - // -- Verify if the runtime target is Arduino or OpenPLC -- - // INFO: If the runtime target is Arduino, we will continue the compilation process. - // INFO: If the runtime target is OpenPLC we will finish the process here. - if (boardRuntime === 'openplc-compiler') { - _mainProcessPort.postMessage({ - logLevel: 'info', - message: 'OpenPLC runtime detected.', - }) - _mainProcessPort.postMessage({ - logLevel: 'info', - message: 'Source files generated successfully at: ' + sourceTargetFolderPath, - }) - - // Build the runtime v4 upload bundle through the shared composer - // (`composeRuntimeV4Bundle`). Web routes through the same code - // path — single source of truth for the upload zip contract. All - // pre-v4 scatter writes (c_blocks.h, c_blocks_code.cpp, - // strucpp_runtime/include/*, defines.h, conf/*) are skipped for v4 - // boards above and emitted here in one go so the file list is - // structurally identical to web's. - // - // Idempotent for compile-only: the composer's output is written - // to disk under `sourceTargetFolderPath`, exactly where the - // pre-refactor scatter writes used to land — diffing build//src - // pre- vs post-refactor should produce no changes for any test - // project. - if (isRuntimeV4) { - try { - await this.cleanConfFolder(sourceTargetFolderPath, (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) - - // Two POU shapes: the header generator wants - // `{ name, variables }`, the code generator needs the full - // `{ name, code, variables }`. Build both views once so - // the composer inputs read cleanly. - const originalCppPous = (projectData as ProjectDataWithCppPous).originalCppPous ?? [] - const hasCppCode = originalCppPous.length > 0 - const cppPousHeader = originalCppPous.map((pou) => ({ - name: pou.name, - variables: pou.variables, - })) as CppPouDataHeader[] - - // Modbus slave / master / S7Comm: pure helpers, no I/O — - // call them directly here and hand the strings to the - // composer. `null` from any of them means the project has - // no config of that type, which the composer skips. - const modbusSlaveJson = generateModbusSlaveConfig( - projectData.servers as Parameters[0], - ) - const modbusMasterJson = generateModbusMasterConfig( - projectData.remoteDevices as Parameters[0], - ) - const s7CommJson = generateS7CommConfig(projectData.servers) - - // OPC-UA needs strucpp's `debug-map.json` (NOT - // `generated_debug.cpp`) to resolve `%I/%Q/%M` addresses — - // `parseDebugMap` in `frontend/utils/opcua/` expects the - // JSON shape strucpp emits at that filename. Pull it from - // the in-memory strucpp file map so the composer doesn't - // have to re-read every artefact off disk. - const debugMapContent = strucppEmittedFiles['debug-map.json'] ?? '' - const instances = projectData.configuration.resource.instances.map((inst) => ({ - name: inst.name, - task: inst.task, - program: inst.program, - })) - let opcUaJson: string | null = null + firmwareSkeleton = await this.loadFirmwareSkeletonInMemory(boardRuntime) + // Strucpp runtime headers (`debug_dispatch.hpp`, + // `iec_std_lib.hpp`, etc.) live in two different layouts on + // disk depending on the target. For runtime v4 they go under + // `strucpp_runtime/include/` (the canonical key + // `composeRuntimeV4Bundle` expects); for Arduino / simulator + // builds they go flat at `src/` next to the + // strucpp-generated artefacts — matching what editor's old + // `copyStrucppRuntimeHeaders(sourceTargetFolderPath)` did. + // For Arduino targets we merge them into the firmware skeleton + // so arduino-cli's `--library src` pass resolves + // `#include "debug_dispatch.hpp"` from `ModbusSlave.cpp`. + strucppRuntimeHeaders = isRuntimeV4 ? await this.loadStrucppRuntimeHeaders() : {} + if (!isRuntimeV4) { + const v4Layout = await this.loadStrucppRuntimeHeaders() + // Board-specific HAL adapter — defines `hardwareInit`, + // `updateInputBuffers`, `updateOutputBuffers` that the + // strucpp-generated `Baremetal.ino` + `arduino_runtime_glue.cpp` + // call into. Editor's pre-refactor `handleGenerateArduinoCppFile` + // copied `resources/sources/hal/` to + // `src/arduino.cpp`. Read it here so the shared merge step + // can drop it into the firmware skeleton at the canonical + // path; without it, the link fails with `undefined reference + // to hardwareInit` etc. + let boardHalContent: string | undefined + const boardSource = (boardEntry as { source?: string } | undefined)?.source + if (typeof boardSource === 'string' && boardSource.length > 0) { + const halPath = join(this.sourceDirectoryPath, 'hal', boardSource) try { - opcUaJson = generateOpcUaConfig(projectData.servers, debugMapContent, instances, (msg) => - _mainProcessPort.postMessage({ logLevel: 'info', message: msg }), - ) - } catch (error) { - if (error instanceof OpcUaConfigError) { - _mainProcessPort.postMessage({ - logLevel: 'error', - message: `OPC-UA Configuration Error:\n${error.message}`, - }) - } else { - _mainProcessPort.postMessage({ - logLevel: 'error', - message: `Failed to generate OPC-UA config: ${getErrorMessage(error)}`, - }) - } - throw error - } - - // EtherCAT: validate up-front so a bad config aborts the - // compile before the composer runs — same gate web has. - const ethercatJson = generateEthercatConfig(projectData.remoteDevices) - const ethercatErrors = validateEthercatConfig(ethercatJson) - if (ethercatErrors.length > 0) { - throw new Error(`EtherCAT configuration is invalid: ${ethercatErrors.join('; ')}`) + boardHalContent = await readFile(halPath, 'utf-8') + } catch (halErr) { + _mainProcessPort.postMessage({ + logLevel: 'warning', + message: `Could not read board HAL file at ${halPath}: ${getErrorMessage(halErr)}`, + }) } - - // ST source — read from disk since handleGenerateXMLfromJSON - // + xml2st wrote it earlier in the pipeline. - const programStContent = await readFile(join(sourceTargetFolderPath, 'program.st'), 'utf-8') - - const bundleFiles = composeRuntimeV4Bundle({ - programSt: programStContent, - md5: buildMD5Hash ?? '', - strucppFiles: strucppEmittedFiles, - cBlocks: { - header: hasCppCode ? generateCBlocksHeader(cppPousHeader) : '// Empty file\n', - code: hasCppCode ? generateCBlocksCode(originalCppPous) : null, - }, - strucppRuntimeHeaders: await this.loadStrucppRuntimeHeaders(), - confs: { - modbusSlave: modbusSlaveJson, - modbusMaster: modbusMasterJson, - s7Comm: s7CommJson, - opcUa: opcUaJson, - // `validateEthercatConfig` above guarantees a non-null - // payload by here; coerce for the composer's required - // `string` shape. - ethercat: ethercatJson ?? '', - }, - }) - - // Write each composer-emitted file to disk under - // `sourceTargetFolderPath`. Nested paths (e.g. - // `strucpp_runtime/include/iec_std_lib.hpp`, - // `conf/modbus_slave.json`) need their parent directories - // created first — mkdir recursive is idempotent. - await Promise.all( - Object.entries(bundleFiles).map(async ([relativePath, content]) => { - const absolutePath = join(sourceTargetFolderPath, relativePath) - await mkdir(path.dirname(absolutePath), { recursive: true }) - await writeFile(absolutePath, content, { encoding: 'utf8' }) - }), - ) - _mainProcessPort.postMessage({ - logLevel: 'info', - message: `Runtime v4 bundle composed: ${Object.keys(bundleFiles).length} files written under ${sourceTargetFolderPath}`, - }) - - // VPP plugin config + source copy for boards whose target is runtime-v4. - // Runs after the composer so VPP-provided files land on top of - // the composer's writes (today no overlap; if VPP ever ships a - // file the composer also emits, ordering preserves VPP). - await this.handleVendorPluginPackaging( - boardTarget, - normalizedProjectPath, - sourceTargetFolderPath, - (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }, - ) - } catch (error) { - _mainProcessPort.postMessage({ - logLevel: 'error', - message: `Error generating Runtime v4 configs: ${error instanceof Error ? error.message : String(error)}`, - }) - _mainProcessPort.postMessage({ - logLevel: 'error', - message: 'Stopping compilation process.', - }) - _mainProcessPort.close() - return } - } - - if (compileOnly) { - _mainProcessPort.postMessage({ - logLevel: 'info', - message: 'Compile only mode - skipping upload to runtime.', - }) - _mainProcessPort.postMessage({ - message: - '-------------------------------------------------------------------------------------------------------------\n', + // Re-key strucpp runtime headers from + // `strucpp_runtime/include/X` into `src/X` so arduino-cli's + // `--library src` pass finds them; also drop the board HAL + // (if loaded) at `src/arduino.cpp`. Both repos call the + // same shared helper so a future header-set tweak lands on + // both platforms in lockstep. + firmwareSkeleton = mergeStrucppRuntimeIntoSkeleton({ + firmwareSkeleton, + strucppRuntimeHeaders: v4Layout, + boardHalContent, }) - _mainProcessPort.close() - return - } - - if (!runtimeIpAddress || !runtimeJwtToken) { - _mainProcessPort.postMessage({ - logLevel: 'warning', - message: 'Runtime not configured or not logged in. Skipping upload to runtime.', - }) - _mainProcessPort.postMessage({ - logLevel: 'info', - message: 'To upload the program, configure the runtime IP address and login in the device configuration.', - }) - _mainProcessPort.postMessage({ - message: - '-------------------------------------------------------------------------------------------------------------\n', - }) - _mainProcessPort.close() - return - } - - // Runtime v4 ships the STruC++ pipeline starting at v4.1.0; - // 4.0.x runtimes still speak the MatIEC wire format and can't - // load the strucpp artefacts we'd upload here. Probe - // /api/version (unauthenticated) before sending the zip so the - // user gets a clear "upgrade your runtime" message instead of - // a cryptic 500 on the device side. - // - // Runtime v3 is on a separate upload path (raw program.st), so - // the gate is v4-only. - if (!isRuntimeV3) { - const versionResult = await this.fetchRuntimeVersion(runtimeIpAddress) - if (!isStrucppCompatibleRuntime(versionResult.version)) { - _mainProcessPort.postMessage({ - logLevel: 'error', - message: describeIncompatibleRuntime(versionResult.version), - }) - _mainProcessPort.postMessage({ - message: - '-------------------------------------------------------------------------------------------------------------\n', - }) - _mainProcessPort.close() - return - } } - try { - let fileBuffer: Buffer - let filename: string - let contentType: string - - if (isRuntimeV3) { - _mainProcessPort.postMessage({ - logLevel: 'info', - message: 'Preparing program.st file for OpenPLC Runtime v3...', - }) - const programStPath = join(sourceTargetFolderPath, 'program.st') - - try { - await fs.access(programStPath) - } catch { - throw new Error(`Required file not found: ${programStPath}. Cannot upload to OpenPLC Runtime v3.`) - } - - fileBuffer = await fs.readFile(programStPath) - filename = 'program.st' - contentType = 'text/plain' - } else { - // Runtime v4 conf/* files were already generated above, before the - // compile-only early return, so compile-only flows also get them. - _mainProcessPort.postMessage({ - logLevel: 'info', - message: 'Compressing source files for OpenPLC Runtime v4...', - }) - fileBuffer = await this.compressSourceFolder(sourceTargetFolderPath) - filename = 'program.zip' - contentType = 'application/zip' - } - - _mainProcessPort.postMessage({ - logLevel: 'info', - message: `Uploading program to runtime at ${runtimeIpAddress}...`, - }) - - // The full deploy sequence (upload → poll runtime build → - // start PLC with BUSY retry) lives in the shared - // `deployRuntimeProgram` so openplc-web's `compileProgram` - // can drive the exact same flow. Only the three - // round-trips are platform-specific — the orchestration, - // log fan-out, deadlines, and retry policy are not. - const deployOutcome = await deployRuntimeProgram({ - uploadProgram: () => - this.sendRuntimeUpload({ - hostname: runtimeIpAddress, - jwtToken: runtimeJwtToken, - filename, - contentType, - fileBuffer, - cleanBuild: cleanBuild ?? false, - onUploadAccepted: (responseBody) => { - // Runtime returns the initial `CompilationStatus` - // field in the upload response (typically - // "COMPILING"). Surface it so the user sees the - // build kick off before the poller's first tick. - try { - const response = JSON.parse(responseBody) as { CompilationStatus?: string } - _mainProcessPort.postMessage({ - logLevel: 'info', - message: `Runtime compilation started: ${response.CompilationStatus || 'COMPILING'}`, - }) - } catch { - _mainProcessPort.postMessage({ - logLevel: 'warning', - message: 'Could not parse runtime response', - }) - } - }, - }), - fetchCompilationStatus: async () => { - try { - const result = await mainProcessBridge.makeRuntimeApiRequest<{ - status: string - logs: string[] - exit_code: number | null - }>(runtimeIpAddress, runtimeJwtToken, '/api/compilation-status', (data: string) => { - return JSON.parse(data) as { status: string; logs: string[]; exit_code: number | null } - }) - if (!result.success) return { success: false, error: result.error } - return { success: true, data: result.data! } - } catch (pollError) { - return { - success: false, - error: pollError instanceof Error ? pollError.message : String(pollError), - } - } - }, - fetchStartResponse: async () => { - const result = await mainProcessBridge.makeRuntimeApiRequest( - runtimeIpAddress, - runtimeJwtToken, - '/api/start-plc', - (data: string) => { - const parsed = JSON.parse(data) as { status?: string } - return (parsed.status ?? '').trim() - }, - ) - if (!result.success) return { success: false, error: result.error } - return { success: true, status: result.data ?? '' } - }, - onLog: (level, message) => { - _mainProcessPort.postMessage({ logLevel: level, message }) - }, - pollTimeoutMs: CompilerModule.COMPILATION_STATUS_TIMEOUT_MS, - pollIntervalMs: CompilerModule.COMPILATION_STATUS_POLL_INTERVAL_MS, - startTimeoutMs: POST_BUILD_START_TIMEOUT_MS, - startIntervalMs: POST_BUILD_START_POLL_INTERVAL_MS, - }) - - // Editor-only follow-up: fetch the current PLC status and - // forward it through the IPC channel so the UI can update - // its run/stop indicator. Best-effort — silently skipped - // when the deploy succeeded but the device drops the - // status request, or when the deploy itself fell short of - // STARTED. - if (deployOutcome === 'STARTED' && runtimeIpAddress && runtimeJwtToken) { - try { - const statusResult = await mainProcessBridge.makeRuntimeApiRequest( - runtimeIpAddress, - runtimeJwtToken, - '/api/status', - (data: string) => { - const response = JSON.parse(data) as { status: string } - return response.status - }, - ) - if (statusResult.success && statusResult.data) { - const status = parsePlcStatus(statusResult.data) - if (status) { - _mainProcessPort.postMessage({ plcStatus: status }) - } - } - } catch (_statusError) { - // Best-effort — silently ignore. - } - } - - _mainProcessPort.postMessage({ - message: - '-------------------------------------------------------------------------------------------------------------\n', - }) - _mainProcessPort.close() - return - } catch (error) { - _mainProcessPort.postMessage({ - logLevel: 'error', - message: `Failed to upload to runtime: ${getErrorMessage(error)}`, - }) - _mainProcessPort.postMessage({ - message: - '-------------------------------------------------------------------------------------------------------------\n', - }) - _mainProcessPort.close() + devicePinMapping = await CompilerModule.readJSONFile( + join(normalizedProjectPath, 'devices', 'pin-mapping.json'), + ) + } catch { + // Projects with no devices/pin-mapping.json (libraries, fresh + // projects) get an empty array — generateDefinesContent emits + // empty PINMASK_* entries in that case. + devicePinMapping = [] } - return - } - - // Step 5: Handle core installation - _mainProcessPort.postMessage({ logLevel: 'info', message: 'Handling core installation...' }) - try { - await this.handleCoreInstallation(boardCore, (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) - } catch (error) { - _mainProcessPort.postMessage({ - logLevel: 'error', - message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), - }) - _mainProcessPort.postMessage({ - logLevel: 'error', - message: 'Stopping compilation process.', - }) - _mainProcessPort.close() - return - } - // Step 9: Handle library installation - _mainProcessPort.postMessage({ logLevel: 'info', message: 'Handling library installation...' }) - try { - await this.handleLibraryInstallation((data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }) - } catch (error) { - _mainProcessPort.postMessage({ - logLevel: 'error', - message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), - }) - _mainProcessPort.postMessage({ - logLevel: 'error', - message: 'Stopping compilation process.', - }) - _mainProcessPort.close() - return - } - - // Step 10: Handle defines.h file generation - try { - if (buildMD5Hash === null) { - _mainProcessPort.postMessage({ - logLevel: 'error', - message: 'Build MD5 hash is null, cannot generate defines.h file.', - }) - _mainProcessPort.close() - return + const enabledLibraryNames = (projectData.libraries ?? []).map((ref) => ref.name) + const archives = mainProcessBridge.loadEnabledArchives(enabledLibraryNames) + libraryArchives = archives.archives + missingLibraries = archives.missing + const coreId = typeof boardEntry?.core === 'string' ? boardEntry.core : '' + if (coreId.startsWith('arduino:avr')) { + avrLibStdCppInclude = await this.ensureAvrLibStdCppCache() } - await this.handleGenerateDefinitionsFile({ - projectPath: normalizedProjectPath, - boardTarget, - buildMD5Hash, - boardRuntime, - _handleOutputData: (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }, - }) } catch (error) { _mainProcessPort.postMessage({ logLevel: 'error', - message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), - }) - } - - // Step 11: Generate Arduino CPP file - _mainProcessPort.postMessage({ logLevel: 'info', message: 'Generating Arduino CPP file...' }) - try { - await this.handleGenerateArduinoCppFile(normalizedProjectPath, boardTarget) - _mainProcessPort.postMessage({ logLevel: 'info', message: 'Arduino CPP file generated successfully.' }) - } catch (error) { - _mainProcessPort.postMessage({ - logLevel: 'error', - message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), + message: `Error resolving build inputs: ${getErrorMessage(error)}\nStopping compilation process.`, }) _mainProcessPort.close() return } - // Step 12: Compile Arduino Program - _mainProcessPort.postMessage({ logLevel: 'info', message: 'Compiling Arduino program...' }) - try { - await this.handleCompileArduinoProgram({ + // --- Build the editor's CompilerPlatformPort implementation --- + const platformPort = createEditorCompilerPlatformPort( + { + handleTranspileXMLtoST: this.handleTranspileXMLtoST.bind(this), + handleCompileArduinoProgram: this.handleCompileArduinoProgram.bind(this), + handleUploadProgram: this.handleUploadProgram.bind(this), + handleCoreInstallation: this.handleCoreInstallation.bind(this), + handleLibraryInstallation: this.handleLibraryInstallation.bind(this), + handleVendorPluginPackaging: this.handleVendorPluginPackaging.bind(this), + }, + { + normalizedProjectPath, + compilationPath, + sourceTargetFolderPath, boardTarget, + boardCore, boardHalsContent: halsContent[boardTarget], - compilationPath, cleanBuild: cleanBuild ?? false, - handleOutputData: (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }, - }) - _mainProcessPort.postMessage({ logLevel: 'info', message: 'Arduino program compiled successfully.' }) - } catch (error) { - _mainProcessPort.postMessage({ - logLevel: 'error', - message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), - }) - _mainProcessPort.close() - return - } + mainProcessBridge, + compressSourceFolder: (folderPath: string) => this.compressSourceFolder(folderPath), + sendRuntimeUpload: (opts) => this.sendRuntimeUpload(opts), + pollTimeoutMs: CompilerModule.COMPILATION_STATUS_TIMEOUT_MS, + pollIntervalMs: CompilerModule.COMPILATION_STATUS_POLL_INTERVAL_MS, + startTimeoutMs: POST_BUILD_START_TIMEOUT_MS, + startIntervalMs: POST_BUILD_START_POLL_INTERVAL_MS, + }, + ) - // Step 13: Upload program to board or load into simulator - if (boardRuntime === 'simulator') { - // `compileOnly: true` callers (the library-project verification - // step today; a future "Build only" on simulator) want to - // confirm the compile succeeded without any side effect on the - // simulator process. Emitting the firmware path makes the - // renderer load the .hex into the running simulator; emitting - // "Loading firmware into simulator..." advertises an action - // that isn't happening. Skip both for compile-only callers. + // Device context for the runtime-upload step. Absent when the + // user hasn't logged in to a runtime — pipeline will skip the + // upload phase and emit a warning instead. + const deviceContext = + runtimeIpAddress && runtimeJwtToken + ? { kind: 'editor-https' as const, ip: runtimeIpAddress, jwt: runtimeJwtToken } + : undefined + + // --- Run the shared pipeline --- + const result = await runCompilePipeline( + { + projectData, + boardTarget, + boardRuntime, + boardEntry, + devicePinMapping, + isSimulator, + isRuntimeV4, + isRuntimeV3, + compileOnly: compileOnly ?? false, + libraryArchives, + missingLibraries, + firmwareSkeleton, + strucppRuntimeHeaders, + avrLibStdCppInclude, + // Editor saturates every core on local arduino-cli (matches + // pre-refactor behaviour); web's adapter sets this to false + // because the centralised compile-service backend runs many + // clients in a sandbox. + arduinoCliParallel: true, + deviceContext, + communicationPort: communicationPort ?? undefined, + }, + platformPort, + (event) => { + _mainProcessPort.postMessage({ + logLevel: event.level, + message: event.message, + ...(event.compileError ? { compileError: event.compileError } : {}), + }) + }, + ) + + // --- Editor-specific epilogue: simulator firmware path + closePort --- + if (isSimulator) { if (compileOnly) { _mainProcessPort.postMessage({ logLevel: 'info', message: 'Compilation successful.' }) _mainProcessPort.postMessage({ closePort: true }) _mainProcessPort.close() return } - // For simulator targets, send the HEX firmware path back to the renderer. - // Derive the build sub-directory from the platform FQBN (e.g. "arduino:avr:mega" → "arduino.avr.mega") - // so it stays in sync with the hals.json entry. - const fqbnSubDir = halsContent[boardTarget]['platform'].replaceAll(':', '.') - const hexPath = join(compilationPath, 'examples', 'Baremetal', 'build', fqbnSubDir, 'Baremetal.ino.hex') - _mainProcessPort.postMessage({ - logLevel: 'info', - message: 'Compilation successful. Loading firmware into simulator...', - }) - _mainProcessPort.postMessage({ - simulatorFirmwarePath: hexPath, - closePort: true, - }) - _mainProcessPort.close() - return - } - - if (!compileOnly) { - _mainProcessPort.postMessage({ logLevel: 'info', message: 'Uploading program to board...' }) - try { - await this.handleUploadProgram({ - projectPath: normalizedProjectPath, - arduinoPlatform: halsContent[boardTarget]['platform'], - compilationPath, - handleOutputData: (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }, - }) - } catch (error) { + if (result.success) { + // Resolve the per-FQBN sub-directory arduino-cli wrote the + // .hex into. Matches the layout the renderer's simulator + // loader expects. + const platform = typeof boardEntry?.platform === 'string' ? boardEntry.platform : '' + const fqbnSubDir = platform.replaceAll(':', '.') + const hexPath = join(compilationPath, 'examples', 'Baremetal', 'build', fqbnSubDir, 'Baremetal.ino.hex') _mainProcessPort.postMessage({ - logLevel: 'error', - message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), + logLevel: 'info', + message: 'Compilation successful. Loading firmware into simulator...', }) + _mainProcessPort.postMessage({ simulatorFirmwarePath: hexPath, closePort: true }) _mainProcessPort.close() return } + // Failure path on simulator — separator + close. + _mainProcessPort.postMessage({ + message: + '-------------------------------------------------------------------------------------------------------------\n', + }) + _mainProcessPort.close() + return } - // -- Final message -- + // Runtime v4 / v3 / Arduino-direct paths all converge here. If + // an upload happened (or was skipped on purpose), trail the + // separator and let the renderer pulse-check the deferred close. _mainProcessPort.postMessage({ message: '-------------------------------------------------------------------------------------------------------------\n', }) - - // INFO: This step is under development. setTimeout(() => { _mainProcessPort.close() }, 25) diff --git a/src/backend/editor/compiler/editor-compiler-platform-port.ts b/src/backend/editor/compiler/editor-compiler-platform-port.ts new file mode 100644 index 000000000..0c5a88369 --- /dev/null +++ b/src/backend/editor/compiler/editor-compiler-platform-port.ts @@ -0,0 +1,618 @@ +/** + * Editor-side implementation of `CompilerPlatformPort`. + * + * Wraps the existing editor handlers (`handleTranspileXMLtoST`, + * `handleCompileArduinoProgram`, etc.) so the shared compile pipeline + * (`backend/shared/compile/pipeline.ts`) can drive editor's compile + * flow through the canonical platform-port contract. + * + * Each port method is a thin shim: + * - Receives the port's canonical args (an in-memory file map, + * pre-rendered argv, etc.) + * - Resolves whatever filesystem paths the editor's handler + * expects (the handlers were written before the port abstraction + * existed and assume on-disk layouts) + * - Calls the existing handler + * - Translates the handler's return value back into the port's + * canonical result shape + * + * No new pipeline logic lives here — only the platform-specific glue + * the editor needs to materialise the in-memory inputs to disk so + * `xml2st` / `arduino-cli` subprocesses can consume them, and to + * read the resulting artefacts back into memory for the pipeline. + * + * This module is editor-only (lives under `backend/editor/`); the + * web platform implements the same port interface separately under + * `middleware/adapters/web/`. + */ + +import { deployRuntimeProgram } from '@root/backend/shared/library/deploy-runtime-program' +import { probeRuntimeVersion } from '@root/backend/shared/library/probe-runtime-version' +import type { + CheckRuntimeVersionArgs, + CheckRuntimeVersionResult, + CompileArduinoArgs, + CompileArduinoResult, + CompilerPlatformPort, + InstallArduinoCoreArgs, + InstallArduinoLibArgs, + PackageVppPluginArgs, + PackageVppPluginResult, + PlatformDeviceContext, + PlatformLog, + TranspileXmlToStArgs, + TranspileXmlToStResult, + UploadArduinoBoardArgs, + UploadResult, + UploadRuntimeV3Args, + UploadRuntimeV4Args, +} from '@root/middleware/shared/ports/compiler-platform-port' +import { createHash } from 'crypto' +import { promises as fs } from 'fs' +import { dirname, join } from 'path' + +import type { CompilerModule } from './compiler-module' + +/** + * Subset of `CompilerModule` the port adapter calls into. Declared + * explicitly rather than typing as `CompilerModule` directly so the + * port stays free of the wider class surface (logging internals, + * file-watching, etc.). + */ +export interface EditorCompilerHandlers { + handleTranspileXMLtoST: CompilerModule['handleTranspileXMLtoST'] + handleCompileArduinoProgram: CompilerModule['handleCompileArduinoProgram'] + handleUploadProgram: CompilerModule['handleUploadProgram'] + handleCoreInstallation: CompilerModule['handleCoreInstallation'] + handleLibraryInstallation: CompilerModule['handleLibraryInstallation'] + handleVendorPluginPackaging: CompilerModule['handleVendorPluginPackaging'] +} + +/** + * Editor-specific context the port methods need: the project's + * resolved paths, the JWT bridge for runtime API calls, and timing + * constants for the deploy poller. + */ +export interface EditorCompilerPlatformPortContext { + /** Project path on disk (without the trailing `project.json`). */ + normalizedProjectPath: string + /** `/build//`. */ + compilationPath: string + /** `/src/`. */ + sourceTargetFolderPath: string + /** Resolved `boardTarget` (e.g. `'OpenPLC Simulator'`). */ + boardTarget: string + /** Resolved `boardCore` from hals.json (e.g. `'arduino:avr'`). */ + boardCore: string | null + /** Per-board entry from `hals.json` for the current target. Passed + * through to `handleCompileArduinoProgram` so the existing handler + * can pull out `platform` / `c_flags` / `max_data_size` / etc. */ + boardHalsContent: unknown + /** Whether the user requested a clean rebuild (drives arduino-cli's + * `--clean` flag). */ + cleanBuild: boolean + /** Bridge methods for runtime API calls (compile-status poll, etc.). */ + mainProcessBridge: { + makeRuntimeApiRequest: ( + ipAddress: string, + jwtToken: string, + endpoint: string, + responseParser?: (data: string) => T, + ) => Promise<{ success: true; data?: T } | { success: false; error: string }> + } + /** Compress the source folder into the runtime v4 upload zip. + * Delegated through context so the port adapter doesn't pull + * in the `archiver`-dependent compressSourceFolder method (which + * has its own private state on CompilerModule). */ + compressSourceFolder: (folderPath: string) => Promise + /** Send the upload request to a runtime device. Wraps + * CompilerModule.sendRuntimeUpload with the right multipart + * payload structure. */ + sendRuntimeUpload: (opts: { + hostname: string + jwtToken: string + filename: string + contentType: string + fileBuffer: Buffer + cleanBuild: boolean + onUploadAccepted?: (responseBody: string) => void + }) => Promise<{ success: boolean; error?: string }> + /** Timeout for the post-upload compile-status poll. */ + pollTimeoutMs: number + /** Interval for the post-upload compile-status poll. */ + pollIntervalMs: number + /** Timeout for the post-build PLC-start poll. */ + startTimeoutMs: number + /** Interval for the post-build PLC-start poll. */ + startIntervalMs: number +} + +/** + * Build the editor's `CompilerPlatformPort` from existing handlers. + * + * Returns a port object the shared pipeline can drive without + * knowing it's running in Electron's main process. Each method + * receives the pipeline's canonical inputs (file maps, byte + * arrays, device context) and shims them onto the existing + * handlers' filesystem-and-subprocess shape. + */ +export function createEditorCompilerPlatformPort( + handlers: EditorCompilerHandlers, + context: EditorCompilerPlatformPortContext, +): CompilerPlatformPort { + return { + /** + * Node's `crypto.createHash('md5')` produces the canonical MD5 + * hex digest that defines.h embeds as `PROGRAM_MD5`. Web's + * adapter computes the same hash via `spark-md5`; both outputs + * are byte-identical. + */ + async computeMd5(input: string): Promise { + return createHash('md5').update(input).digest('hex') + }, + + /** + * Spawn the bundled `xml2st` binary to transpile IEC 61131-3 + * XML to ST. The existing `handleTranspileXMLtoST` expects a + * file path (it opens the file via the subprocess's stdin), so + * we materialise the in-memory XML to a temp file first and + * read the produced `program.st` back from disk. + */ + async transpileXmlToSt(args: TranspileXmlToStArgs, log: PlatformLog): Promise { + const xmlPath = join(context.sourceTargetFolderPath, 'plc.xml') + try { + await fs.mkdir(dirname(xmlPath), { recursive: true }) + await fs.writeFile(xmlPath, args.xml, 'utf-8') + + await handlers.handleTranspileXMLtoST(xmlPath, (chunk, level) => { + const message = typeof chunk === 'string' ? chunk : chunk.toString() + log(message, level ?? 'info') + }) + + const programStPath = join(context.sourceTargetFolderPath, 'program.st') + const programSt = await fs.readFile(programStPath, 'utf-8') + return { ok: true, programSt } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log(`xml2st failed: ${message}`, 'error') + return { ok: false, errors: [{ message, line: 0, column: 0, severity: 'error' }] } + } + }, + + /** + * Arduino-cli core install. The existing + * `handleCoreInstallation` already takes a core id and a log + * callback — direct passthrough modulo the log-shape + * translation. + */ + 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') + }) + return { ok: true } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log(`Arduino core install failed: ${message}`, 'error') + return { ok: false } + } + }, + + /** + * Arduino-cli library install. Existing + * `handleLibraryInstallation` installs the full set of libs + * configured in hals.json — args.libId is currently a no-op + * for backward compat with the existing handler. + */ + async installArduinoLib(_args: InstallArduinoLibArgs, log: PlatformLog): Promise { + try { + await handlers.handleLibraryInstallation((chunk, level) => { + const message = typeof chunk === 'string' ? chunk : chunk.toString() + log(message, level ?? 'info') + }) + return { ok: true } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log(`Arduino library install failed: ${message}`, 'error') + return { ok: false } + } + }, + + /** + * Materialise the in-memory file map to disk under the project's + * build directory, then spawn `arduino-cli compile` via the + * existing `handleCompileArduinoProgram`. Read the produced + * `.hex` back into memory for the pipeline's return. + */ + async compileArduino(args: CompileArduinoArgs, log: PlatformLog): Promise { + try { + // Materialise every entry in the in-memory file map under + // the project's build directory. Editor's existing flow + // wrote these files in scattered places throughout the + // compile pipeline; doing it here once preserves the same + // on-disk layout arduino-cli expects. + await Promise.all( + Object.entries(args.files).map(async ([relPath, content]) => { + const absPath = join(context.compilationPath, relPath) + await fs.mkdir(dirname(absPath), { recursive: true }) + await fs.writeFile(absPath, content, 'utf-8') + }), + ) + + // Invoke the existing handler — it spawns arduino-cli compile + // with the per-board hals entry's flags. The canonical + // `args.argv` from the shared `buildArduinoCliCompileArgs` is + // available for a future cleanup that inlines the spawn here + // and consumes it directly; for now the legacy handler builds + // its own argv from `boardHalsContent`. + await handlers.handleCompileArduinoProgram({ + boardTarget: context.boardTarget, + boardHalsContent: context.boardHalsContent as never, + compilationPath: context.compilationPath, + cleanBuild: context.cleanBuild, + handleOutputData: (chunk, level) => { + const message = typeof chunk === 'string' ? chunk : chunk.toString() + log(message, level ?? 'info') + }, + }) + + // Derive the FQBN from the board's hals.json entry — the + // simulator branch in compileProgram uses the exact same + // derivation (`platform.replaceAll(':', '.')`), so the two + // paths agree on which `.hex` belongs to the current build. + const boardPlatform = + context.boardHalsContent !== null && + typeof context.boardHalsContent === 'object' && + 'platform' in (context.boardHalsContent as Record) && + typeof (context.boardHalsContent as { platform?: unknown }).platform === 'string' + ? (context.boardHalsContent as { platform: string }).platform + : '' + const hexPath = await findHexInCompilationPath(context.compilationPath, boardPlatform) + if (!hexPath) { + throw new Error('Compiled .hex not found after arduino-cli compile.') + } + const binary = await fs.readFile(hexPath) + return { ok: true, binary: new Uint8Array(binary) } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log(`Arduino compile failed: ${message}`, 'error') + return { ok: false, errors: [{ message, line: 0, column: 0, severity: 'error' }] } + } + }, + + /** + * Compress the source folder and POST it to the runtime via the + * editor's HTTPS upload helper. Delegates the full upload → + * poll → start sequence to the shared `deployRuntimeProgram`. + */ + async uploadRuntimeV4(args: UploadRuntimeV4Args, log: PlatformLog): Promise { + const deviceContext = assertEditorHttpsContext(args.context) + try { + // Materialise the bundle to disk under sourceTargetFolderPath + // so the existing `compressSourceFolder` can zip it. + await Promise.all( + Object.entries(args.bundle).map(async ([relPath, content]) => { + const absPath = join(context.sourceTargetFolderPath, relPath) + await fs.mkdir(dirname(absPath), { recursive: true }) + await fs.writeFile(absPath, content, 'utf-8') + }), + ) + const fileBuffer = await context.compressSourceFolder(context.sourceTargetFolderPath) + + const deployOutcome = await deployRuntimeProgram({ + uploadProgram: () => + context.sendRuntimeUpload({ + hostname: deviceContext.ip, + jwtToken: deviceContext.jwt, + filename: 'program.zip', + contentType: 'application/zip', + fileBuffer, + cleanBuild: context.cleanBuild, + onUploadAccepted: (responseBody) => { + try { + const response = JSON.parse(responseBody) as { CompilationStatus?: string } + log(`Runtime compilation started: ${response.CompilationStatus || 'COMPILING'}`, 'info') + } catch { + log('Could not parse runtime response', 'warning') + } + }, + }), + fetchCompilationStatus: async () => { + const result = await context.mainProcessBridge.makeRuntimeApiRequest<{ + status: string + logs: string[] + exit_code: number | null + }>(deviceContext.ip, deviceContext.jwt, '/api/compilation-status', (data: string) => { + return JSON.parse(data) as { status: string; logs: string[]; exit_code: number | null } + }) + if (!result.success) return { success: false, error: result.error } + return { success: true, data: result.data! } + }, + fetchStartResponse: async () => { + const result = await context.mainProcessBridge.makeRuntimeApiRequest( + deviceContext.ip, + deviceContext.jwt, + '/api/start-plc', + (data: string) => { + const parsed = JSON.parse(data) as { status?: string } + return (parsed.status ?? '').trim() + }, + ) + if (!result.success) return { success: false, error: result.error } + return { success: true, status: result.data ?? '' } + }, + onLog: (level, message) => + log(message, level === 'error' ? 'error' : level === 'warning' ? 'warning' : 'info'), + pollTimeoutMs: context.pollTimeoutMs, + pollIntervalMs: context.pollIntervalMs, + startTimeoutMs: context.startTimeoutMs, + startIntervalMs: context.startIntervalMs, + }) + + return { ok: deployOutcome === 'STARTED' } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log(`Runtime v4 upload failed: ${message}`, 'error') + return { ok: false } + } + }, + + /** + * arduino-cli upload to a physical Arduino board. Delegates to + * the existing `handleUploadProgram` handler. + * + * The serial port comes from the renderer's device-board picker + * via the pipeline's `communicationPort` arg → `args.port`. We + * forward it explicitly so a port change made in the UI takes + * effect on this build without waiting for a project save — + * `handleUploadProgram` historically read the value from + * `devices/configuration.json` on disk, which lags the live store + * by a save round-trip. When `args.port` is empty (callers that + * predate the explicit-port plumbing), the handler still falls + * back to its disk read. + */ + async uploadArduinoBoard(args: UploadArduinoBoardArgs, log: PlatformLog): Promise { + try { + await handlers.handleUploadProgram({ + projectPath: context.normalizedProjectPath, + arduinoPlatform: args.fqbn, + compilationPath: context.compilationPath, + communicationPort: args.port || undefined, + handleOutputData: (chunk, level) => { + const message = typeof chunk === 'string' ? chunk : chunk.toString() + log(message, level ?? 'info') + }, + }) + return { ok: true } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log(`Arduino upload failed: ${message}`, 'error') + return { ok: false } + } + }, + + /** + * Runtime v3 upload — sends the raw `program.st` (with embedded + * c_blocks markers) to the device's v3 endpoint. v3 is end-of- + * life; web's adapter no-ops this (web doesn't expose v3 as a + * frontend option). + */ + async uploadRuntimeV3(args: UploadRuntimeV3Args, log: PlatformLog): Promise { + const deviceContext = assertEditorHttpsContext(args.context) + try { + const fileBuffer = Buffer.from(args.programSt, 'utf-8') + const deployOutcome = await deployRuntimeProgram({ + uploadProgram: () => + context.sendRuntimeUpload({ + hostname: deviceContext.ip, + jwtToken: deviceContext.jwt, + filename: 'program.st', + contentType: 'text/plain', + fileBuffer, + cleanBuild: context.cleanBuild, + onUploadAccepted: (responseBody) => { + try { + const response = JSON.parse(responseBody) as { CompilationStatus?: string } + log(`Runtime compilation started: ${response.CompilationStatus || 'COMPILING'}`, 'info') + } catch { + log('Could not parse runtime response', 'warning') + } + }, + }), + fetchCompilationStatus: async () => { + const result = await context.mainProcessBridge.makeRuntimeApiRequest<{ + status: string + logs: string[] + exit_code: number | null + }>(deviceContext.ip, deviceContext.jwt, '/api/compilation-status', (data: string) => { + return JSON.parse(data) as { status: string; logs: string[]; exit_code: number | null } + }) + if (!result.success) return { success: false, error: result.error } + return { success: true, data: result.data! } + }, + fetchStartResponse: async () => { + const result = await context.mainProcessBridge.makeRuntimeApiRequest( + deviceContext.ip, + deviceContext.jwt, + '/api/start-plc', + (data: string) => { + const parsed = JSON.parse(data) as { status?: string } + return (parsed.status ?? '').trim() + }, + ) + if (!result.success) return { success: false, error: result.error } + return { success: true, status: result.data ?? '' } + }, + onLog: (level, message) => + log(message, level === 'error' ? 'error' : level === 'warning' ? 'warning' : 'info'), + pollTimeoutMs: context.pollTimeoutMs, + pollIntervalMs: context.pollIntervalMs, + startTimeoutMs: context.startTimeoutMs, + startIntervalMs: context.startIntervalMs, + }) + return { ok: deployOutcome === 'STARTED' } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log(`Runtime v3 upload failed: ${message}`, 'error') + return { ok: false } + } + }, + + /** + * Probe the device's `/api/version` (unauthenticated) so the + * pipeline can short-circuit uploads to pre-4.1.0 runtimes. + * + * Transport: Electron's HTTPS bridge → device IP. + * Response parsing + null-fallback live in the shared + * `probeRuntimeVersion` helper so editor and web give the gate + * an equivalent answer against the same runtime container. + */ + async checkRuntimeVersion(args: CheckRuntimeVersionArgs, log: PlatformLog): Promise { + const deviceContext = assertEditorHttpsContext(args.context) + const { version } = await probeRuntimeVersion({ + fetchVersion: async () => { + const result = await context.mainProcessBridge.makeRuntimeApiRequest<{ version: string }>( + deviceContext.ip, + '', // unauthenticated probe + '/api/version', + (data: string) => JSON.parse(data) as { version: string }, + ) + if (!result.success) return { success: false, error: result.error } + return { success: true, body: result.data } + }, + log, + }) + return { ok: true, version } + }, + + /** + * VPP runtime-v4 packaging. Delegates to the existing + * `handleVendorPluginPackaging` handler, which: + * - Self-gates: emits `Board "" is not from a VPP package, + * skipping VPP packaging` and returns early for plain + * runtime-v4 boards (Runtime v4, SLM-RP4 was the original + * test target). This matches the pre-refactor invariant + * that the orchestrator calls the handler unconditionally + * for runtime-v4 and the handler decides whether to act. + * - For VPP boards: generates `conf/.json` + emits + * `vpp_plugins.conf` to enable the driver + copies the + * plugin source under `vpp_plugin/` with a SHA-256 checksum + * so the runtime's `compile.sh` can skip a rebuild when the + * driver source hasn't changed. + * + * All writes go directly to `sourceTargetFolderPath` on disk — + * the editor's `uploadRuntimeV4` zips that directory in full, so + * VPP files end up in the upload alongside the in-memory bundle's + * materialised entries. We return `{ files: {} }` because the + * disk layer is already the source of truth for the editor; web's + * adapter will need to surface them in the returned map instead. + */ + async packageVppPlugin(args: PackageVppPluginArgs, log: PlatformLog): Promise { + try { + await handlers.handleVendorPluginPackaging( + args.boardTarget, + context.normalizedProjectPath, + context.sourceTargetFolderPath, + // The handler's callback signature accepts `Buffer | string` + // for the chunk and `'info' | 'error' | undefined` for the + // log level; PlatformLog wants `string` + `'info' | 'warning' + // | 'error'`. Coerce both — the handler never emits + // Buffers for VPP packaging (only string log lines), and an + // undefined level falls back to 'info' to match the + // pre-refactor postMessage default. + (data, logLevel) => { + const message = typeof data === 'string' ? data : data.toString('utf-8') + log(message, logLevel ?? 'info') + }, + ) + return { files: {} } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + files: {}, + errors: [{ message, line: 0, column: 0, severity: 'error' }], + } + } + }, + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Discriminator narrow: the editor adapter only handles + * `editor-https` contexts. Throws on `web-orchestrator` (which + * should never be passed to the editor port). + */ +export function assertEditorHttpsContext( + context: PlatformDeviceContext, +): Extract { + if (context.kind !== 'editor-https') { + throw new Error(`Editor compiler platform port received non-editor context: ${context.kind}`) + } + return context +} + +/** + * Find the arduino-cli-produced `Baremetal.ino.hex` under the build + * directory. arduino-cli writes it to a board-FQBN-specific + * sub-directory (e.g. `examples/Baremetal/build/arduino.avr.mega/`) + * derived from the hals.json `platform` field with `:` replaced by + * `.` — same derivation `compileProgram`'s simulator branch uses to + * locate the `.hex` it hands to avr8js. + * + * `fqbn` MUST be the canonical platform string (e.g. + * `arduino:avr:mega`); the helper does the `:`→`.` translation + * itself. When the canonical path doesn't exist (a stale build with + * a different FQBN layout, manual fiddling with the build dir), the + * helper falls back to walking the build directory and returning + * the first match — preserves the pre-fix behaviour as a safety + * net rather than failing outright. + * + * Without the deterministic path, a stale FQBN sub-directory left + * over from a prior board target (e.g. user compiled for Mega, then + * switched to Uno without cleaning) would cause the walk to return + * the wrong binary alphabetically. Real scenario: compile for Mega, + * then Uno, then upload → arduino-cli ends up flashing the Mega hex + * to the Uno because `arduino.avr.mega` comes before `arduino.avr.uno` + * in `readdir`. + */ +export async function findHexInCompilationPath(compilationPath: string, fqbn: string): Promise { + const buildDir = join(compilationPath, 'examples', 'Baremetal', 'build') + + // Deterministic path first — the canonical layout for the + // current build's FQBN. + if (fqbn.length > 0) { + const fqbnSubDir = fqbn.replaceAll(':', '.') + const canonicalPath = join(buildDir, fqbnSubDir, 'Baremetal.ino.hex') + try { + await fs.access(canonicalPath) + return canonicalPath + } catch { + // Fall through to the walk-fallback below. + } + } + + // Safety net: walk the build dir for the first matching .hex. + // Reached when the canonical path is absent (FQBN string differs + // from the directory arduino-cli produced — observed historically + // with cores that mangle the FQBN through aliases). + try { + const fqbnDirs = await fs.readdir(buildDir) + for (const fqbnDir of fqbnDirs) { + const hexPath = join(buildDir, fqbnDir, 'Baremetal.ino.hex') + try { + await fs.access(hexPath) + return hexPath + } catch { + // Try next fqbn dir. + } + } + } catch { + // No build/ dir — compile didn't run. + } + return null +} diff --git a/src/backend/editor/hardware/hardware-module.ts b/src/backend/editor/hardware/hardware-module.ts index 853dc4fe0..680580ca6 100644 --- a/src/backend/editor/hardware/hardware-module.ts +++ b/src/backend/editor/hardware/hardware-module.ts @@ -7,6 +7,7 @@ import { promisify } from 'node:util' import { app as electronApp } from 'electron' import { produce } from 'immer' +import { readHalsFile } from '../../shared/firmware/hals-loader' import { PackageManagerModule } from '../package-manager' import { logger } from '../services/logger-service' import { assertPathContained } from '../utils/path-containment' @@ -137,11 +138,11 @@ class HardwareModule { } async getAvailableBoards(): Promise { - // Construct the path to the hals.json file - const halsFilePath = join(this.sourcesDirectoryPath, 'boards', 'hals.json') - - // Read the content of the necessary files - hals.json and arduino-core-control.json - const halsFileContent = await HardwareModule.readJSONFile(halsFilePath) + // hals.json is now bundled at `src/backend/shared/firmware/hals.json` + // (the canonical shared board catalogue editor and web both consume). + // `readHalsFile` resolves synchronously off the bundled JSON — keeps + // the async shape so this call site stays unchanged. + const halsFileContent = await readHalsFile() const arduinoCoreFileContent = await HardwareModule.readJSONFile<{ [core: string]: string }[]>( this.arduinoCoreFilePath, ) diff --git a/src/backend/shared/compile/__tests__/compose-firmware-bundle.test.ts b/src/backend/shared/compile/__tests__/compose-firmware-bundle.test.ts new file mode 100644 index 000000000..97e5fdd96 --- /dev/null +++ b/src/backend/shared/compile/__tests__/compose-firmware-bundle.test.ts @@ -0,0 +1,219 @@ +/** + * Tests for the shared firmware-bundle composer. + * + * This composer is symmetric to `composeRuntimeV4Bundle` but for + * the simulator/Arduino firmware compile path. The byte-identical + * assembly is what the recent C/C++ POU bug was missing — pinning + * the contract here means a future change to file paths or overwrite + * semantics surfaces as a test failure rather than as a cryptic + * arduino-cli link error. + */ + +import { buildCBlocksFromPous, composeFirmwareBundle } from '../steps/compose-firmware-bundle' + +const baseInput = { + strucppFiles: {}, + cBlocks: { header: '// Empty file\n', code: null as string | null }, + definesH: '#define PROGRAM_MD5 ""\n', + firmwareSkeleton: {}, +} + +describe('composeFirmwareBundle — skeleton passthrough', () => { + it('passes every skeleton entry through verbatim when no other inputs are present', () => { + const skeleton = { + 'examples/Baremetal/Baremetal.ino': 'void setup() {}\nvoid loop() {}\n', + 'src/arduino.cpp': '// HAL adapter\n', + 'examples/Baremetal/modules/Modbus.cpp': '// Modbus helper\n', + } + const out = composeFirmwareBundle({ ...baseInput, firmwareSkeleton: skeleton }) + expect(out['examples/Baremetal/Baremetal.ino']).toBe('void setup() {}\nvoid loop() {}\n') + expect(out['src/arduino.cpp']).toBe('// HAL adapter\n') + expect(out['examples/Baremetal/modules/Modbus.cpp']).toBe('// Modbus helper\n') + }) + + it('overwrites src/c_blocks.h skeleton entry with the cBlocks header input', () => { + const skeleton = { 'src/c_blocks.h': '// stub from skeleton\n' } + const out = composeFirmwareBundle({ + ...baseInput, + firmwareSkeleton: skeleton, + cBlocks: { header: 'extern "C" void blink_setup(void *);\n', code: null }, + }) + expect(out['src/c_blocks.h']).toBe('extern "C" void blink_setup(void *);\n') + }) + + it('overwrites src/defines.h skeleton entry with the definesH input', () => { + const skeleton = { 'src/defines.h': '// stub defines\n' } + const out = composeFirmwareBundle({ + ...baseInput, + firmwareSkeleton: skeleton, + definesH: '#define PROGRAM_MD5 "abc"\n', + }) + expect(out['src/defines.h']).toBe('#define PROGRAM_MD5 "abc"\n') + }) +}) + +describe('composeFirmwareBundle — strucpp output', () => { + it('drops every strucppFiles entry under src/', () => { + const out = composeFirmwareBundle({ + ...baseInput, + strucppFiles: { + 'generated.cpp': 'gen_cpp', + 'generated.hpp': 'gen_hpp', + 'generated_debug.cpp': 'gen_dbg', + 'debug-map.json': '{"vars":[]}', + 'pou_BLINK.cpp': 'pou_content', + }, + }) + expect(out['src/generated.cpp']).toBe('gen_cpp') + expect(out['src/generated.hpp']).toBe('gen_hpp') + expect(out['src/generated_debug.cpp']).toBe('gen_dbg') + expect(out['src/debug-map.json']).toBe('{"vars":[]}') + expect(out['src/pou_BLINK.cpp']).toBe('pou_content') + }) + + it('strucpp output overwrites same-named skeleton entries', () => { + const out = composeFirmwareBundle({ + ...baseInput, + firmwareSkeleton: { 'src/generated.cpp': '// stub\n' }, + strucppFiles: { 'generated.cpp': 'real strucpp output' }, + }) + expect(out['src/generated.cpp']).toBe('real strucpp output') + }) +}) + +describe('composeFirmwareBundle — c_blocks_code.cpp overwrite semantics', () => { + it('OVERWRITES examples/Baremetal/c_blocks_code.cpp when cBlocks.code is non-null', () => { + const skeleton = { 'examples/Baremetal/c_blocks_code.cpp': '// static baseline\n' } + const out = composeFirmwareBundle({ + ...baseInput, + firmwareSkeleton: skeleton, + cBlocks: { header: 'h', code: 'void blink_setup(void *) {}\n' }, + }) + expect(out['examples/Baremetal/c_blocks_code.cpp']).toBe('void blink_setup(void *) {}\n') + }) + + it('LEAVES examples/Baremetal/c_blocks_code.cpp untouched when cBlocks.code is null', () => { + // Mirrors editor's "skipping c_blocks_code.cpp generation" path: + // no C/C++ POUs → static baseline stays. + const skeleton = { 'examples/Baremetal/c_blocks_code.cpp': '// static baseline kept\n' } + const out = composeFirmwareBundle({ + ...baseInput, + firmwareSkeleton: skeleton, + cBlocks: { header: '// Empty file\n', code: null }, + }) + expect(out['examples/Baremetal/c_blocks_code.cpp']).toBe('// static baseline kept\n') + }) + + it('does not create examples/Baremetal/c_blocks_code.cpp when cBlocks.code is null and skeleton lacks it', () => { + const out = composeFirmwareBundle({ + ...baseInput, + firmwareSkeleton: {}, + cBlocks: { header: '// Empty file\n', code: null }, + }) + expect(out['examples/Baremetal/c_blocks_code.cpp']).toBeUndefined() + }) +}) + +describe('composeFirmwareBundle — full layout snapshot', () => { + it('produces the canonical simulator file map for a project with C/C++ POUs', () => { + const out = composeFirmwareBundle({ + firmwareSkeleton: { + 'examples/Baremetal/Baremetal.ino': 'BAREMETAL_INO', + 'examples/Baremetal/c_blocks_code.cpp': 'STATIC_BASELINE', + 'src/arduino.cpp': 'ARDUINO_HAL', + 'src/iec_std_lib.hpp': 'STRUCPP_RUNTIME_HEADER', + }, + strucppFiles: { + 'generated.cpp': 'GEN_CPP', + 'generated.hpp': 'GEN_HPP', + 'pou_BLINK_CPP.cpp': 'POU_BLINK_CPP', + }, + cBlocks: { header: 'CBLOCKS_HEADER', code: 'CBLOCKS_CODE_WITH_USER' }, + definesH: 'DEFINES_H', + }) + + expect(out).toEqual({ + 'examples/Baremetal/Baremetal.ino': 'BAREMETAL_INO', + 'examples/Baremetal/c_blocks_code.cpp': 'CBLOCKS_CODE_WITH_USER', + 'src/arduino.cpp': 'ARDUINO_HAL', + 'src/iec_std_lib.hpp': 'STRUCPP_RUNTIME_HEADER', + 'src/generated.cpp': 'GEN_CPP', + 'src/generated.hpp': 'GEN_HPP', + 'src/pou_BLINK_CPP.cpp': 'POU_BLINK_CPP', + 'src/c_blocks.h': 'CBLOCKS_HEADER', + 'src/defines.h': 'DEFINES_H', + }) + }) + + it('produces the canonical simulator file map for a project with NO C/C++ POUs', () => { + const out = composeFirmwareBundle({ + firmwareSkeleton: { + 'examples/Baremetal/Baremetal.ino': 'BAREMETAL_INO', + 'examples/Baremetal/c_blocks_code.cpp': 'STATIC_BASELINE_KEPT', + 'src/arduino.cpp': 'ARDUINO_HAL', + 'src/c_blocks.h': 'STATIC_HEADER_STUB', + }, + strucppFiles: { + 'generated.cpp': 'GEN_CPP', + }, + cBlocks: { header: '// Empty file\n', code: null }, + definesH: 'DEFINES_H', + }) + + expect(out).toEqual({ + 'examples/Baremetal/Baremetal.ino': 'BAREMETAL_INO', + 'examples/Baremetal/c_blocks_code.cpp': 'STATIC_BASELINE_KEPT', + 'src/arduino.cpp': 'ARDUINO_HAL', + // header was overwritten with the empty-file sentinel + 'src/c_blocks.h': '// Empty file\n', + 'src/generated.cpp': 'GEN_CPP', + 'src/defines.h': 'DEFINES_H', + }) + }) +}) + +describe('buildCBlocksFromPous', () => { + it('returns the empty-file sentinel + null code for empty input', () => { + const result = buildCBlocksFromPous([]) + expect(result).toEqual({ header: '// Empty file\n', code: null }) + }) + + it('returns generated header + code when POUs are present', () => { + const result = buildCBlocksFromPous([ + { + name: 'blink_cpp', + variables: [ + { + name: 'period_ms', + type: { definition: 'base-type', value: 'UINT' }, + class: 'input', + location: '', + documentation: '', + }, + ], + code: '#include \nvoid setup() {}\nvoid loop() {}\n', + }, + ]) + expect(typeof result.header).toBe('string') + expect(result.header).toContain('BLINK_CPP_VARS') + expect(result.header).toContain('blink_cpp_setup') + expect(typeof result.code).toBe('string') + expect(result.code).toContain('blink_cpp_setup') + }) + + it('passes a single POU as the only entry in both header and code generation', () => { + const pous = [ + { + name: 'one', + variables: [], + code: 'void setup() {}\nvoid loop() {}\n', + }, + ] + const result = buildCBlocksFromPous(pous) + // header references the one POU's vars struct + setup/loop + expect(result.header).toContain('one_setup') + expect(result.header).toContain('one_loop') + // code includes the user's body integrated into the wrapper + expect(result.code).toContain('one_setup') + }) +}) diff --git a/src/backend/shared/compile/__tests__/generate-confs.test.ts b/src/backend/shared/compile/__tests__/generate-confs.test.ts new file mode 100644 index 000000000..83e6a500d --- /dev/null +++ b/src/backend/shared/compile/__tests__/generate-confs.test.ts @@ -0,0 +1,276 @@ +/** + * Tests for the shared runtime-v4 conf orchestration step. + * + * The atomic generators (`generateModbusSlaveConfig`, + * `generateOpcUaConfig`, etc.) have their own tests in + * `frontend/utils/.../__tests__/`. This suite verifies the + * orchestration layer: error-handling for OPC-UA, validation gating + * for EtherCAT, the exact log messages the editor's compile pipeline + * emits, and the pure assembly of the resulting strings. + * + * Atomic generators are mocked so the orchestration can be driven + * through every branch (success, OpcUaConfigError, generic OPC-UA + * failure, EtherCAT validation failure) without constructing + * elaborate project fixtures. + */ + +import type { PLCRemoteDevice, PLCServer } from '../../types/PLC/open-plc' + +// Hoist mock declarations so they apply before the shared module +// imports its dependencies. Each generator returns a sentinel by +// default; individual tests override via `.mockReturnValueOnce` / +// `.mockImplementationOnce`. + +jest.mock('../../utils/modbus/generate-modbus-master-config', () => ({ + generateModbusMasterConfig: jest.fn(), +})) +jest.mock('../../ethercat/generate-ethercat-config', () => ({ + generateEthercatConfig: jest.fn(), +})) +jest.mock('../../ethercat/validate-ethercat-config', () => ({ + validateEthercatConfig: jest.fn(), +})) +jest.mock('../../../../frontend/utils/modbus/generate-modbus-slave-config', () => ({ + generateModbusSlaveConfig: jest.fn(), +})) +jest.mock('../../../../frontend/utils/opcua', () => { + // Matches the real 3-arg constructor in + // `src/frontend/utils/opcua/resolve-indices.ts`. Only the + // `message` field is read by the shared module under test. + class OpcUaConfigError extends Error { + constructor( + public readonly variableRef: string, + public readonly expectedPath: string, + message: string, + ) { + super(message) + this.name = 'OpcUaConfigError' + } + } + return { + generateOpcUaConfig: jest.fn(), + OpcUaConfigError, + } +}) +jest.mock('../../../../frontend/utils/s7comm', () => ({ + generateS7CommConfig: jest.fn(), +})) +jest.mock('../../../../frontend/utils/get-error-message', () => ({ + getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), +})) + +import { generateModbusMasterConfig } from '../../utils/modbus/generate-modbus-master-config' +import { generateEthercatConfig } from '../../ethercat/generate-ethercat-config' +import { validateEthercatConfig } from '../../ethercat/validate-ethercat-config' +import { generateModbusSlaveConfig } from '../../../../frontend/utils/modbus/generate-modbus-slave-config' +import { generateOpcUaConfig, OpcUaConfigError } from '../../../../frontend/utils/opcua' +import { generateS7CommConfig } from '../../../../frontend/utils/s7comm' +import { generateRuntimeConfs, type GenerateConfsInput } from '../steps/generate-confs' + +const mockedModbusSlave = generateModbusSlaveConfig as jest.MockedFunction +const mockedModbusMaster = generateModbusMasterConfig as jest.MockedFunction +const mockedS7Comm = generateS7CommConfig as jest.MockedFunction +const mockedOpcUa = generateOpcUaConfig as jest.MockedFunction +const mockedEthercatGen = generateEthercatConfig as jest.MockedFunction +const mockedEthercatValidate = validateEthercatConfig as jest.MockedFunction + +function makeInput(overrides?: Partial): GenerateConfsInput { + return { + servers: [] as PLCServer[], + remoteDevices: [] as PLCRemoteDevice[], + instances: [], + debugMapContent: '{}', + log: jest.fn(), + ...overrides, + } +} + +beforeEach(() => { + jest.clearAllMocks() + // Sensible defaults: every generator returns `null` (no config) + // and EtherCAT validation passes. Tests override per-case. + mockedModbusSlave.mockReturnValue(null) + mockedModbusMaster.mockReturnValue(null) + mockedS7Comm.mockReturnValue(null) + mockedOpcUa.mockReturnValue(null) + mockedEthercatGen.mockReturnValue(null) + mockedEthercatValidate.mockReturnValue([]) +}) + +describe('generateRuntimeConfs — happy path', () => { + it('assembles all five confs into a single output', () => { + mockedModbusSlave.mockReturnValue('{"modbus_slave":{}}') + mockedModbusMaster.mockReturnValue('{"modbus_master":{}}') + mockedS7Comm.mockReturnValue('{"s7":{}}') + mockedOpcUa.mockReturnValue('{"opcua":{}}') + mockedEthercatGen.mockReturnValue('{"ethercat":{}}') + + const result = generateRuntimeConfs(makeInput()) + expect(result).toEqual({ + modbusSlave: '{"modbus_slave":{}}', + modbusMaster: '{"modbus_master":{}}', + s7Comm: '{"s7":{}}', + opcUa: '{"opcua":{}}', + ethercat: '{"ethercat":{}}', + }) + }) + + it('passes servers + debugMapContent + instances + log to generateOpcUaConfig', () => { + const servers = [{ name: 'opcua-server' }] as PLCServer[] + const instances = [{ name: 'i0', task: 't0', program: 'main' }] + const log = jest.fn() + generateRuntimeConfs(makeInput({ servers, instances, debugMapContent: '{"k":"v"}', log })) + expect(mockedOpcUa).toHaveBeenCalledTimes(1) + expect(mockedOpcUa.mock.calls[0][0]).toBe(servers) + expect(mockedOpcUa.mock.calls[0][1]).toBe('{"k":"v"}') + expect(mockedOpcUa.mock.calls[0][2]).toBe(instances) + expect(typeof mockedOpcUa.mock.calls[0][3]).toBe('function') + }) + + it('forwards OPC-UA info messages through the log callback as level=info', () => { + const log = jest.fn() + mockedOpcUa.mockImplementation((_servers, _dbg, _inst, innerLog) => { + innerLog?.('OPC-UA Address Space: 5 node(s) configured') + return '{"opcua":{}}' + }) + generateRuntimeConfs(makeInput({ log })) + expect(log).toHaveBeenCalledWith('OPC-UA Address Space: 5 node(s) configured', 'info') + }) + + it('returns null for confs whose generator returned null', () => { + // Default-mock behavior (all null). + const result = generateRuntimeConfs(makeInput()) + expect(result).toEqual({ + modbusSlave: null, + modbusMaster: null, + s7Comm: null, + opcUa: null, + ethercat: null, + }) + }) +}) + +describe('generateRuntimeConfs — OPC-UA error handling', () => { + it('logs "OPC-UA Configuration Error:" prefix and rethrows on OpcUaConfigError', () => { + const log = jest.fn() + mockedOpcUa.mockImplementation(() => { + throw new OpcUaConfigError('var0', 'P0.task.var', 'Invalid node id "foo"') + }) + + expect(() => generateRuntimeConfs(makeInput({ log }))).toThrow(OpcUaConfigError) + expect(log).toHaveBeenCalledWith('OPC-UA Configuration Error:\nInvalid node id "foo"', 'error') + }) + + it('logs "Failed to generate OPC-UA config:" prefix and rethrows on generic Error', () => { + const log = jest.fn() + mockedOpcUa.mockImplementation(() => { + throw new Error('boom') + }) + + expect(() => generateRuntimeConfs(makeInput({ log }))).toThrow('boom') + expect(log).toHaveBeenCalledWith('Failed to generate OPC-UA config: boom', 'error') + }) + + it('logs "Failed to generate OPC-UA config:" prefix and rethrows on non-Error throws', () => { + const log = jest.fn() + mockedOpcUa.mockImplementation(() => { + throw 'string error' + }) + + expect(() => generateRuntimeConfs(makeInput({ log }))).toThrow() + expect(log).toHaveBeenCalledWith('Failed to generate OPC-UA config: string error', 'error') + }) + + it('does not run EtherCAT generation/validation when OPC-UA throws', () => { + mockedOpcUa.mockImplementation(() => { + throw new OpcUaConfigError('v', 'p', 'x') + }) + try { + generateRuntimeConfs(makeInput()) + } catch { + // expected + } + expect(mockedEthercatGen).not.toHaveBeenCalled() + expect(mockedEthercatValidate).not.toHaveBeenCalled() + }) +}) + +describe('generateRuntimeConfs — EtherCAT validation gate', () => { + it('throws with joined error message when validation returns errors', () => { + mockedEthercatGen.mockReturnValue('{"ethercat":"bad"}') + mockedEthercatValidate.mockReturnValue(['slave 0 missing vendor id', 'slave 2 invalid PDO']) + + expect(() => generateRuntimeConfs(makeInput())).toThrow( + 'EtherCAT configuration is invalid: slave 0 missing vendor id; slave 2 invalid PDO', + ) + }) + + it('passes the generated EtherCAT JSON through to validateEthercatConfig', () => { + mockedEthercatGen.mockReturnValue('{"ethercat":"x"}') + generateRuntimeConfs(makeInput()) + expect(mockedEthercatValidate).toHaveBeenCalledWith('{"ethercat":"x"}') + }) + + it('includes the ethercat JSON in the output when validation passes', () => { + mockedEthercatGen.mockReturnValue('{"ethercat":"ok"}') + mockedEthercatValidate.mockReturnValue([]) + const result = generateRuntimeConfs(makeInput()) + expect(result.ethercat).toBe('{"ethercat":"ok"}') + }) + + it('returns ethercat: null when no remote devices configured (generator returns null)', () => { + mockedEthercatGen.mockReturnValue(null) + mockedEthercatValidate.mockReturnValue([]) + const result = generateRuntimeConfs(makeInput()) + expect(result.ethercat).toBeNull() + }) + + it('does not log anything for EtherCAT validation failures (caller surfaces the message)', () => { + const log = jest.fn() + mockedEthercatValidate.mockReturnValue(['err']) + try { + generateRuntimeConfs(makeInput({ log })) + } catch { + // expected + } + expect(log).not.toHaveBeenCalled() + }) +}) + +describe('generateRuntimeConfs — ordering invariants', () => { + it('runs Modbus + S7 + OPC-UA generators before EtherCAT (OPC-UA error short-circuits the rest)', () => { + const callOrder: string[] = [] + mockedModbusSlave.mockImplementation(() => { + callOrder.push('modbus-slave') + return null + }) + mockedModbusMaster.mockImplementation(() => { + callOrder.push('modbus-master') + return null + }) + mockedS7Comm.mockImplementation(() => { + callOrder.push('s7') + return null + }) + mockedOpcUa.mockImplementation(() => { + callOrder.push('opcua') + return null + }) + mockedEthercatGen.mockImplementation(() => { + callOrder.push('ethercat-gen') + return null + }) + mockedEthercatValidate.mockImplementation(() => { + callOrder.push('ethercat-validate') + return [] + }) + + generateRuntimeConfs(makeInput()) + + // OPC-UA must run BEFORE EtherCAT so an OPC-UA failure aborts + // before EtherCAT generation runs (matches editor's compile + // ordering — saves wasted work on bad OPC-UA projects). + expect(callOrder.indexOf('opcua')).toBeLessThan(callOrder.indexOf('ethercat-gen')) + expect(callOrder.indexOf('ethercat-gen')).toBeLessThan(callOrder.indexOf('ethercat-validate')) + }) +}) diff --git a/src/backend/shared/compile/__tests__/generate-defines.test.ts b/src/backend/shared/compile/__tests__/generate-defines.test.ts new file mode 100644 index 000000000..cd1214ec6 --- /dev/null +++ b/src/backend/shared/compile/__tests__/generate-defines.test.ts @@ -0,0 +1,327 @@ +/** + * Tests for the shared `defines.h` content authoring step. + * + * The byte-for-byte snapshot tests are load-bearing: the OpenPLC + * runtime keys off `PROGRAM_MD5` for stale-program detection, and + * the firmware HAL headers `#ifdef`-gate include directives on the + * `USE_*_BLOCK` toggles emitted here. Any drift in this output + * would surface as either the runtime refusing the upload, or + * undefined-symbol link errors at firmware compile time. + * + * Pinning the canonical editor output here means a future change to + * any byte (formatting, line breaks, marker set) must be intentional + * — these tests will catch it. + */ + +import type { DevicePin } from '../../types/PLC/devices' +import { type BoardHalsDefinesEntry, generateDefinesContent } from '../steps/generate-defines' + +function makePin(overrides: { pin: string | number; pinType: DevicePin['pinType']; address?: string }): DevicePin { + return { + pin: String(overrides.pin), + pinType: overrides.pinType, + address: overrides.address ?? '%IX0.0', + } +} + +const EMPTY_INPUTS = { + boardEntry: undefined as BoardHalsDefinesEntry | undefined, + devicePinMapping: [] as DevicePin[], + stProgramFileContent: '', + buildMD5Hash: 'abc123', + boardRuntime: 'arduino-cli', +} + +describe('generateDefinesContent — board defines section', () => { + it('omits the Board defines header when no boardEntry is provided', () => { + const out = generateDefinesContent(EMPTY_INPUTS) + expect(out).not.toContain('// Board defines') + }) + + it('omits the Board defines header when boardEntry has no define field', () => { + const out = generateDefinesContent({ ...EMPTY_INPUTS, boardEntry: {} }) + expect(out).not.toContain('// Board defines') + }) + + it('emits a single define from a string boardEntry.define', () => { + const out = generateDefinesContent({ ...EMPTY_INPUTS, boardEntry: { define: 'BOARD_X' } }) + expect(out).toContain('// Board defines\n#define BOARD_X\n') + }) + + it('emits multiple defines from an array boardEntry.define', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardEntry: { define: ['BOARD_X', 'PIN_COUNT=8', 'HAS_ANALOG'] }, + }) + expect(out).toContain('// Board defines\n#define BOARD_X\n#define PIN_COUNT=8\n#define HAS_ANALOG\n') + }) + + it('omits the Board defines header when boardEntry.define is an empty array', () => { + // Empty array is falsy-ish for the loop but the header is gated on + // boardEntry.define being truthy; an empty array IS truthy, so a + // header with no entries would be a bug. Editor's behavior: + // empty-array case emits the header but no defines. Snapshot the + // editor's behavior here. + const out = generateDefinesContent({ ...EMPTY_INPUTS, boardEntry: { define: [] } }) + expect(out.startsWith('// Board defines\n\n\n')).toBe(true) + }) +}) + +describe('generateDefinesContent — PROGRAM_MD5', () => { + it('always emits PROGRAM_MD5 with the supplied hash', () => { + const out = generateDefinesContent({ ...EMPTY_INPUTS, buildMD5Hash: 'deadbeef' }) + expect(out).toContain('//Program MD5\n#define PROGRAM_MD5 "deadbeef"\n\n') + }) + + it('emits PROGRAM_MD5 even with an empty hash string', () => { + const out = generateDefinesContent({ ...EMPTY_INPUTS, buildMD5Hash: '' }) + expect(out).toContain('#define PROGRAM_MD5 ""') + }) +}) + +describe('generateDefinesContent — simulator comms block', () => { + it('emits the SIMULATOR_MODE block when boardRuntime === "simulator"', () => { + const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'simulator' }) + expect(out).toContain('//Comms Configuration\n') + expect(out).toContain('#define SIMULATOR_MODE\n') + expect(out).toContain('#define MBSERIAL_IFACE Serial\n') + expect(out).toContain('#define MBSERIAL_BAUD 115200\n') + expect(out).toContain('#define MBSERIAL_SLAVE 1\n') + expect(out).toContain('#define MBSERIAL\n') + expect(out).toContain('#define MODBUS_ENABLED\n') + }) + + it('omits the SIMULATOR_MODE block for non-simulator runtimes', () => { + const arduinoCli = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'arduino-cli' }) + const openplcCompiler = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'openplc-compiler' }) + expect(arduinoCli).not.toContain('SIMULATOR_MODE') + expect(arduinoCli).not.toContain('Comms Configuration') + expect(openplcCompiler).not.toContain('SIMULATOR_MODE') + expect(openplcCompiler).not.toContain('Comms Configuration') + }) +}) + +describe('generateDefinesContent — IO Config (pin masks)', () => { + it('emits empty pin masks when devicePinMapping is empty', () => { + const out = generateDefinesContent(EMPTY_INPUTS) + expect(out).toContain('//IO Config\n') + expect(out).toContain('#define PINMASK_DIN \n') + expect(out).toContain('#define PINMASK_AIN \n') + expect(out).toContain('#define PINMASK_DOUT \n') + expect(out).toContain('#define PINMASK_AOUT \n') + expect(out).toContain('#define NUM_DISCRETE_INPUT 0\n') + expect(out).toContain('#define NUM_ANALOG_INPUT 0\n') + expect(out).toContain('#define NUM_DISCRETE_OUTPUT 0\n') + expect(out).toContain('#define NUM_ANALOG_OUTPUT 0\n') + }) + + it('groups pins by pinType into the matching PINMASK_* and NUM_* defines', () => { + const pins: DevicePin[] = [ + makePin({ pin: 2, pinType: 'digitalInput' }), + makePin({ pin: 3, pinType: 'digitalInput' }), + makePin({ pin: 4, pinType: 'analogInput' }), + makePin({ pin: 5, pinType: 'digitalOutput' }), + makePin({ pin: 6, pinType: 'analogOutput' }), + makePin({ pin: 7, pinType: 'analogOutput' }), + ] + const out = generateDefinesContent({ ...EMPTY_INPUTS, devicePinMapping: pins }) + expect(out).toContain('#define PINMASK_DIN 2, 3\n') + expect(out).toContain('#define PINMASK_AIN 4\n') + expect(out).toContain('#define PINMASK_DOUT 5\n') + expect(out).toContain('#define PINMASK_AOUT 6, 7\n') + expect(out).toContain('#define NUM_DISCRETE_INPUT 2\n') + expect(out).toContain('#define NUM_ANALOG_INPUT 1\n') + expect(out).toContain('#define NUM_DISCRETE_OUTPUT 1\n') + expect(out).toContain('#define NUM_ANALOG_OUTPUT 2\n') + }) + + it('preserves input pin order in the PINMASK output (caller is responsible for sorting)', () => { + // Editor's comment: "This approach assumes that the pins are sorted." + // We don't sort here — verify the assumption is honoured by passing + // unsorted pins and asserting unsorted output. + const pins: DevicePin[] = [ + makePin({ pin: 8, pinType: 'digitalInput' }), + makePin({ pin: 2, pinType: 'digitalInput' }), + makePin({ pin: 5, pinType: 'digitalInput' }), + ] + const out = generateDefinesContent({ ...EMPTY_INPUTS, devicePinMapping: pins }) + expect(out).toContain('#define PINMASK_DIN 8, 2, 5\n') + }) +}) + +describe('generateDefinesContent — Arduino library toggles', () => { + function withMarker(marker: string) { + return generateDefinesContent({ ...EMPTY_INPUTS, stProgramFileContent: `PROGRAM main\n${marker}\nEND_PROGRAM` }) + } + + it('toggles USE_DS18B20_BLOCK for any of the five DS18B20 FB markers', () => { + expect(withMarker('DS18B20;')).toContain('#define USE_DS18B20_BLOCK\n') + expect(withMarker('DS18B20_2_OUT;')).toContain('#define USE_DS18B20_BLOCK\n') + expect(withMarker('DS18B20_3_OUT;')).toContain('#define USE_DS18B20_BLOCK\n') + expect(withMarker('DS18B20_4_OUT;')).toContain('#define USE_DS18B20_BLOCK\n') + expect(withMarker('DS18B20_5_OUT;')).toContain('#define USE_DS18B20_BLOCK\n') + }) + + it('toggles USE_P1AM_BLOCKS on P1AM_INIT;', () => { + expect(withMarker('P1AM_INIT;')).toContain('#define USE_P1AM_BLOCKS\n') + }) + + it('toggles USE_CLOUD_BLOCKS on CLOUD_BEGIN;', () => { + expect(withMarker('CLOUD_BEGIN;')).toContain('#define USE_CLOUD_BLOCKS\n') + }) + + it('toggles USE_MQTT_BLOCKS for either MQTT_CONNECT; or MQTT_CONNECT_AUTH;', () => { + expect(withMarker('MQTT_CONNECT;')).toContain('#define USE_MQTT_BLOCKS\n') + expect(withMarker('MQTT_CONNECT_AUTH;')).toContain('#define USE_MQTT_BLOCKS\n') + }) + + it('toggles USE_ARDUINOCAN_BLOCK for any of the four ARDUINOCAN markers', () => { + expect(withMarker('ARDUINOCAN_CONF;')).toContain('#define USE_ARDUINOCAN_BLOCK\n') + expect(withMarker('ARDUINOCAN_WRITE;')).toContain('#define USE_ARDUINOCAN_BLOCK\n') + expect(withMarker('ARDUINOCAN_WRITE_WORD;')).toContain('#define USE_ARDUINOCAN_BLOCK\n') + expect(withMarker('ARDUINOCAN_READ;')).toContain('#define USE_ARDUINOCAN_BLOCK\n') + }) + + it('toggles USE_STM32CAN_BLOCK for any of the three STM32CAN markers', () => { + expect(withMarker('STM32CAN_CONF;')).toContain('#define USE_STM32CAN_BLOCK\n') + expect(withMarker('STM32CAN_WRITE;')).toContain('#define USE_STM32CAN_BLOCK\n') + expect(withMarker('STM32CAN_READ;')).toContain('#define USE_STM32CAN_BLOCK\n') + }) + + it('toggles USE_SM_BLOCKS for any of the ten SM_* markers', () => { + const markers = [ + 'SM_8RELAY;', + 'SM_16RELAY;', + 'SM_8DIN;', + 'SM_16DIN;', + 'SM_4REL4IN;', + 'SM_INDUSTRIAL;', + 'SM_RTD;', + 'SM_BAS;', + 'SM_HOME;', + 'SM_8MOSFET;', + ] + for (const m of markers) { + expect(withMarker(m)).toContain('#define USE_SM_BLOCKS\n') + } + }) + + it('omits all USE_*_BLOCK defines when no markers are present', () => { + const out = generateDefinesContent({ ...EMPTY_INPUTS, stProgramFileContent: 'PROGRAM main\nEND_PROGRAM' }) + expect(out).not.toContain('USE_DS18B20_BLOCK') + expect(out).not.toContain('USE_P1AM_BLOCKS') + expect(out).not.toContain('USE_CLOUD_BLOCKS') + expect(out).not.toContain('USE_MQTT_BLOCKS') + expect(out).not.toContain('USE_ARDUINOCAN_BLOCK') + expect(out).not.toContain('USE_STM32CAN_BLOCK') + expect(out).not.toContain('USE_SM_BLOCKS') + }) + + it('emits multiple library toggles when several markers co-occur', () => { + const st = 'P1AM_INIT;\nMQTT_CONNECT;\nDS18B20_3_OUT;\nSM_8RELAY;' + const out = generateDefinesContent({ ...EMPTY_INPUTS, stProgramFileContent: st }) + expect(out).toContain('#define USE_DS18B20_BLOCK\n') + expect(out).toContain('#define USE_P1AM_BLOCKS\n') + expect(out).toContain('#define USE_MQTT_BLOCKS\n') + expect(out).toContain('#define USE_SM_BLOCKS\n') + expect(out).not.toContain('USE_CLOUD_BLOCKS') + expect(out).not.toContain('USE_ARDUINOCAN_BLOCK') + expect(out).not.toContain('USE_STM32CAN_BLOCK') + }) + + it('uses substring matching, not whole-word — markers within larger tokens still trigger', () => { + // Editor uses `String.prototype.includes`, so e.g. `XDS18B20;` is + // technically also a hit because it contains `DS18B20;`. This is + // the editor's behavior; pin it so a future change to whole-word + // matching surfaces as a test failure. + const out = generateDefinesContent({ ...EMPTY_INPUTS, stProgramFileContent: 'XDS18B20;' }) + expect(out).toContain('#define USE_DS18B20_BLOCK\n') + }) +}) + +describe('generateDefinesContent — full output snapshot', () => { + it('produces the canonical defines.h for a typical simulator project', () => { + const out = generateDefinesContent({ + boardEntry: { define: ['__AVR_ATmega2560__'] }, + devicePinMapping: [ + makePin({ pin: 2, pinType: 'digitalInput' }), + makePin({ pin: 3, pinType: 'digitalInput' }), + makePin({ pin: 13, pinType: 'digitalOutput' }), + ], + stProgramFileContent: 'PROGRAM main\nVAR\nEND_VAR\nEND_PROGRAM', + buildMD5Hash: '0123456789abcdef0123456789abcdef', + boardRuntime: 'simulator', + }) + + expect(out).toBe( + [ + '// Board defines', + '#define __AVR_ATmega2560__', + '', + '', + '//Program MD5', + '#define PROGRAM_MD5 "0123456789abcdef0123456789abcdef"', + '', + '//Comms Configuration', + '#define SIMULATOR_MODE', + '#define MBSERIAL_IFACE Serial', + '#define MBSERIAL_BAUD 115200', + '#define MBSERIAL_SLAVE 1', + '#define MBSERIAL', + '#define MODBUS_ENABLED', + '', + '', + '//IO Config', + '#define PINMASK_DIN 2, 3', + '#define PINMASK_AIN ', + '#define PINMASK_DOUT 13', + '#define PINMASK_AOUT ', + '#define NUM_DISCRETE_INPUT 2', + '#define NUM_ANALOG_INPUT 0', + '#define NUM_DISCRETE_OUTPUT 1', + '#define NUM_ANALOG_OUTPUT 0', + '', + '', + '//Arduino libraries', + '', + ].join('\n'), + ) + }) + + it('produces the canonical defines.h for a non-simulator project with library toggles', () => { + const out = generateDefinesContent({ + boardEntry: { define: 'PIN_LED=13' }, + devicePinMapping: [makePin({ pin: 4, pinType: 'analogInput' })], + stProgramFileContent: 'CLOUD_BEGIN; MQTT_CONNECT_AUTH;', + buildMD5Hash: 'ff'.repeat(16), + boardRuntime: 'arduino-cli', + }) + + expect(out).toBe( + [ + '// Board defines', + '#define PIN_LED=13', + '', + '', + '//Program MD5', + '#define PROGRAM_MD5 "ffffffffffffffffffffffffffffffff"', + '', + '//IO Config', + '#define PINMASK_DIN ', + '#define PINMASK_AIN 4', + '#define PINMASK_DOUT ', + '#define PINMASK_AOUT ', + '#define NUM_DISCRETE_INPUT 0', + '#define NUM_ANALOG_INPUT 1', + '#define NUM_DISCRETE_OUTPUT 0', + '#define NUM_ANALOG_OUTPUT 0', + '', + '', + '//Arduino libraries', + '#define USE_CLOUD_BLOCKS', + '#define USE_MQTT_BLOCKS', + '', + ].join('\n'), + ) + }) +}) diff --git a/src/backend/shared/compile/__tests__/merge-strucpp-runtime-into-skeleton.test.ts b/src/backend/shared/compile/__tests__/merge-strucpp-runtime-into-skeleton.test.ts new file mode 100644 index 000000000..0ed6cca3c --- /dev/null +++ b/src/backend/shared/compile/__tests__/merge-strucpp-runtime-into-skeleton.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from '@jest/globals' + +import { mergeStrucppRuntimeIntoSkeleton } from '../steps/merge-strucpp-runtime-into-skeleton' + +describe('mergeStrucppRuntimeIntoSkeleton', () => { + it('re-keys runtime headers from strucpp_runtime/include/ into src/', () => { + const merged = mergeStrucppRuntimeIntoSkeleton({ + firmwareSkeleton: { 'examples/Baremetal/Baremetal.ino': '/* sketch */' }, + strucppRuntimeHeaders: { + 'strucpp_runtime/include/iec_std_lib.hpp': '/* iec_std_lib */', + 'strucpp_runtime/include/debug_dispatch.hpp': '/* debug_dispatch */', + }, + }) + expect(merged['examples/Baremetal/Baremetal.ino']).toBe('/* sketch */') + expect(merged['src/iec_std_lib.hpp']).toBe('/* iec_std_lib */') + expect(merged['src/debug_dispatch.hpp']).toBe('/* debug_dispatch */') + // The original v4-shape key is NOT preserved — re-keyed only. + expect(merged['strucpp_runtime/include/iec_std_lib.hpp']).toBeUndefined() + }) + + it('drops boardHalContent at src/arduino.cpp when supplied', () => { + const merged = mergeStrucppRuntimeIntoSkeleton({ + firmwareSkeleton: {}, + strucppRuntimeHeaders: {}, + boardHalContent: 'void hardwareInit() {}', + }) + expect(merged['src/arduino.cpp']).toBe('void hardwareInit() {}') + }) + + it('does not overwrite src/arduino.cpp when boardHalContent is undefined', () => { + const merged = mergeStrucppRuntimeIntoSkeleton({ + firmwareSkeleton: { 'src/arduino.cpp': 'existing HAL' }, + strucppRuntimeHeaders: {}, + }) + expect(merged['src/arduino.cpp']).toBe('existing HAL') + }) + + it('does not overwrite src/arduino.cpp when boardHalContent is the empty string', () => { + // Empty content is treated as "no override" — caller signals + // "no HAL to merge" by passing undefined or "". Without this + // guard, an editor read that returned an empty file would wipe + // out a skeleton's HAL. + const merged = mergeStrucppRuntimeIntoSkeleton({ + firmwareSkeleton: { 'src/arduino.cpp': 'existing HAL' }, + strucppRuntimeHeaders: {}, + boardHalContent: '', + }) + expect(merged['src/arduino.cpp']).toBe('existing HAL') + }) + + it('runtime header re-key OVERWRITES a same-named entry in the skeleton (strucpp wins)', () => { + const merged = mergeStrucppRuntimeIntoSkeleton({ + firmwareSkeleton: { 'src/iec_std_lib.hpp': 'stale stub' }, + strucppRuntimeHeaders: { 'strucpp_runtime/include/iec_std_lib.hpp': 'canonical strucpp' }, + }) + expect(merged['src/iec_std_lib.hpp']).toBe('canonical strucpp') + }) + + it('skips runtime header entries whose key has no filename component', () => { + // Defensive: if an entry's key is something pathological like + // `strucpp_runtime/include/`, `split('/').pop()` yields '' and + // we don't want to emit `src/`. + const merged = mergeStrucppRuntimeIntoSkeleton({ + firmwareSkeleton: {}, + strucppRuntimeHeaders: { + '': '/* unkeyed garbage */', + }, + }) + expect(merged['src/']).toBeUndefined() + expect(Object.keys(merged)).toHaveLength(0) + }) + + it('does not mutate the input firmwareSkeleton', () => { + const input = { 'examples/Baremetal/Baremetal.ino': '/* sketch */' } + const before = { ...input } + mergeStrucppRuntimeIntoSkeleton({ + firmwareSkeleton: input, + strucppRuntimeHeaders: { 'strucpp_runtime/include/a.hpp': 'a' }, + boardHalContent: 'hal', + }) + expect(input).toEqual(before) + }) +}) diff --git a/src/backend/shared/compile/__tests__/pipeline.test.ts b/src/backend/shared/compile/__tests__/pipeline.test.ts new file mode 100644 index 000000000..efda4f0eb --- /dev/null +++ b/src/backend/shared/compile/__tests__/pipeline.test.ts @@ -0,0 +1,968 @@ +/** + * Tests for the shared compile pipeline orchestrator. + * + * The pipeline composes a bunch of shared helpers + four async port + * methods. Each branch (simulator / runtime v4 / runtime v3 / + * arduino-direct, with `compileOnly` variants for each) is exercised + * here by mocking the port + the heavy shared dependencies + * (`runProgramBuildPipeline`, `XmlGenerator`). + * The actual content-authoring steps (defines, confs, composers) are + * covered by their own unit tests; this suite focuses on the + * orchestration — call ordering, branch dispatch, error propagation, + * emit-event payloads. + */ + +import type { DevicePin } from '../../types/PLC/devices' +import type { PLCProjectData } from '../../types/PLC/open-plc' +import type { + CompilerPlatformPort, + PlatformDeviceContext, +} from '../../../../middleware/shared/ports/compiler-platform-port' + +// Mocks for heavy shared deps. Use `jest.fn()` so individual tests +// can override `.mockReturnValueOnce` / `.mockResolvedValueOnce`. +jest.mock('../../utils/PLC/xml-generator', () => ({ + XmlGenerator: jest.fn(), +})) +jest.mock('../../library/program-build-pipeline', () => ({ + runProgramBuildPipeline: jest.fn(), +})) +jest.mock('../../library/program-build-helpers', () => ({ + buildKnownPous: jest.fn(() => []), + emitCompileErrorEvents: jest.fn( + (errors: Array<{ formatted: string; raw: unknown }>, emit: (msg: string, level: 'error', err: unknown) => void) => { + for (const err of errors) emit(err.formatted, 'error', err.raw) + }, + ), +})) +jest.mock('../../firmware/build-arduino-cli-args', () => ({ + buildArduinoCliCompileArgs: jest.fn(() => ['compile', '-b', 'arduino:avr:mega']), +})) +jest.mock('../../firmware/runtime-version-gate', () => ({ + isStrucppCompatibleRuntime: jest.fn(() => true), + describeIncompatibleRuntime: jest.fn( + (v: string | null) => `Runtime ${String(v)} is too old; please upgrade to 4.1.0+.`, + ), +})) +// Mock the conf-generator step so tests can deterministically force +// the runtime-v4 confs branch to throw (covers the pipeline's outer +// try/catch that wraps the EtherCAT / OPC-UA validators). Default +// return value gives all branches a passing shape; individual tests +// override via `.mockImplementationOnce`. +jest.mock('../steps/generate-confs', () => ({ + generateRuntimeConfs: jest.fn(() => ({ + modbusSlave: '', + modbusMaster: '', + s7Comm: '', + opcUa: null, + ethercat: '', + })), +})) + +import { XmlGenerator } from '../../utils/PLC/xml-generator' +import { runProgramBuildPipeline } from '../../library/program-build-pipeline' +import { isStrucppCompatibleRuntime } from '../../firmware/runtime-version-gate' +import { generateRuntimeConfs } from '../steps/generate-confs' + +import { runCompilePipeline, type RunCompilePipelineArgs, type PipelineProgressEvent } from '../pipeline' + +const mockedXmlGen = XmlGenerator as jest.MockedFunction +const mockedConfs = generateRuntimeConfs as jest.MockedFunction +const mockedStrucpp = runProgramBuildPipeline as jest.MockedFunction +const mockedVersionGate = isStrucppCompatibleRuntime as jest.MockedFunction + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function makePort(overrides: Partial = {}): jest.Mocked { + return { + computeMd5: jest.fn().mockResolvedValue('a'.repeat(32)), + transpileXmlToSt: jest.fn().mockResolvedValue({ ok: true, programSt: 'PROGRAM main\nEND_PROGRAM' }), + installArduinoCore: jest.fn().mockResolvedValue({ ok: true }), + installArduinoLib: jest.fn().mockResolvedValue({ ok: true }), + compileArduino: jest.fn().mockResolvedValue({ ok: true, binary: new Uint8Array([1, 2, 3]) }), + uploadRuntimeV4: jest.fn().mockResolvedValue({ ok: true }), + uploadArduinoBoard: jest.fn().mockResolvedValue({ ok: true }), + uploadRuntimeV3: jest.fn().mockResolvedValue({ ok: true }), + checkRuntimeVersion: jest.fn().mockResolvedValue({ ok: true, version: '4.1.0' }), + packageVppPlugin: jest.fn().mockResolvedValue({ files: {} }), + ...overrides, + } as jest.Mocked +} + +const projectDataFixture = { + pous: [], + dataTypes: [], + configuration: { resource: { tasks: [], instances: [], globalVariables: [] } }, + servers: [], + remoteDevices: [], +} as unknown as PLCProjectData + +const deviceContextFixture: PlatformDeviceContext = { + kind: 'editor-https', + ip: '192.168.1.10', + jwt: 'jwt-token', +} + +function makeArgs(overrides: Partial = {}): RunCompilePipelineArgs { + return { + projectData: projectDataFixture, + boardTarget: 'OpenPLC Simulator', + boardRuntime: 'simulator', + boardEntry: { platform: 'arduino:avr:mega', core: 'arduino:avr', define: ['__AVR_ATmega2560__'] }, + devicePinMapping: [] as DevicePin[], + isSimulator: true, + isRuntimeV4: false, + isRuntimeV3: false, + compileOnly: false, + libraryArchives: [], + missingLibraries: [], + firmwareSkeleton: { + 'examples/Baremetal/Baremetal.ino': '// sketch', + 'src/arduino.cpp': '// hal', + }, + strucppRuntimeHeaders: {}, + avrLibStdCppInclude: '/usr/avr/include', + arduinoCliParallel: false, + ...overrides, + } +} + +function captureEvents() { + const events: PipelineProgressEvent[] = [] + return { events, emit: (e: PipelineProgressEvent) => events.push(e) } +} + +beforeEach(() => { + jest.clearAllMocks() + // Default-mock: XML generation succeeds. + mockedXmlGen.mockReturnValue({ ok: true, data: '', message: 'ok' } as never) + // Default-mock: strucpp succeeds with empty file map. + mockedStrucpp.mockReturnValue({ + success: true, + files: [{ name: 'debug-map.json', content: '{}' }], + errors: [], + warnings: [], + md5Hash: 'a'.repeat(32), + splitterFallbackMessage: null, + debugMapSummary: null, + }) + // Default-mock: version gate returns compatible. + mockedVersionGate.mockReturnValue(true) +}) + +// --------------------------------------------------------------------------- +// Happy paths +// --------------------------------------------------------------------------- + +describe('runCompilePipeline — simulator path', () => { + it('runs preprocess → XML → ST → strucpp → arduino-compile and returns the firmware binary', async () => { + const port = makePort() + const { events, emit } = captureEvents() + + const result = await runCompilePipeline(makeArgs(), port, emit) + + expect(result.success).toBe(true) + expect(result.binary).toBeInstanceOf(Uint8Array) + expect(result.uploaded).toBe(false) + expect(port.transpileXmlToSt).toHaveBeenCalledTimes(1) + expect(port.compileArduino).toHaveBeenCalledTimes(1) + expect(port.uploadRuntimeV4).not.toHaveBeenCalled() + expect(port.uploadArduinoBoard).not.toHaveBeenCalled() + expect(events.map((e) => e.stage)).toContain('done') + }) + + it('calls compileArduino with the assembled file map + arduino-cli argv', async () => { + const port = makePort() + const { emit } = captureEvents() + await runCompilePipeline( + makeArgs({ + firmwareSkeleton: { 'examples/Baremetal/Baremetal.ino': 'INO' }, + }), + port, + emit, + ) + const [callArgs] = port.compileArduino.mock.calls[0] + expect(callArgs.files['examples/Baremetal/Baremetal.ino']).toBe('INO') + expect(callArgs.files['src/defines.h']).toContain('PROGRAM_MD5') + expect(callArgs.argv).toEqual(['compile', '-b', 'arduino:avr:mega']) + }) + + it('calls installArduinoCore + installArduinoLib before compileArduino (no-op semantics for web)', async () => { + const port = makePort() + const { emit } = captureEvents() + await runCompilePipeline(makeArgs(), port, emit) + // Jest's invocationCallOrder is global, monotonically increasing — + // smaller value = called earlier. This is the canonical way to + // assert mock call ordering in jest. + const coreOrder = port.installArduinoCore.mock.invocationCallOrder[0] + const libOrder = port.installArduinoLib.mock.invocationCallOrder[0] + const compileOrder = port.compileArduino.mock.invocationCallOrder[0] + expect(coreOrder).toBeLessThan(compileOrder) + expect(libOrder).toBeLessThan(compileOrder) + }) + + it('compileOnly returns success without invoking uploadArduinoBoard', async () => { + const port = makePort() + const { emit } = captureEvents() + const result = await runCompilePipeline(makeArgs({ isSimulator: false, compileOnly: true }), port, emit) + expect(result.success).toBe(true) + expect(port.uploadArduinoBoard).not.toHaveBeenCalled() + }) +}) + +describe('runCompilePipeline — arduino direct path', () => { + it('uploads to the physical board when isSimulator=false and not compileOnly', async () => { + const port = makePort() + const { emit } = captureEvents() + const result = await runCompilePipeline( + makeArgs({ + isSimulator: false, + boardRuntime: 'arduino-cli', + deviceContext: deviceContextFixture, + }), + port, + emit, + ) + expect(result.success).toBe(true) + expect(result.uploaded).toBe(true) + expect(port.uploadArduinoBoard).toHaveBeenCalledTimes(1) + }) + + it('skips the upload step (success with warning) when deviceContext is absent', async () => { + const port = makePort() + const { events, emit } = captureEvents() + const result = await runCompilePipeline(makeArgs({ isSimulator: false, boardRuntime: 'arduino-cli' }), port, emit) + expect(result.success).toBe(true) + expect(result.uploaded).toBe(false) + expect(port.uploadArduinoBoard).not.toHaveBeenCalled() + expect(events.some((e) => e.level === 'warning' && /not configured/.test(e.message))).toBe(true) + }) + + it('returns success=false when uploadArduinoBoard reports failure', async () => { + // arduino-direct upload path: deviceContext present, board picked, + // but the port's upload fails (e.g. serial port busy). The + // pipeline must surface the failure with structured errors rather + // than reporting a successful compile. + const port = makePort({ + uploadArduinoBoard: jest.fn().mockResolvedValue({ + ok: false, + errors: [{ message: 'avrdude: serial port busy', line: 0, column: 0, severity: 'error' }], + }), + }) + const { events, emit } = captureEvents() + const result = await runCompilePipeline( + makeArgs({ + isSimulator: false, + boardRuntime: 'arduino-cli', + deviceContext: deviceContextFixture, + }), + port, + emit, + ) + expect(result.success).toBe(false) + expect(port.uploadArduinoBoard).toHaveBeenCalledTimes(1) + expect(events.some((e) => /Failed to upload to Arduino board/.test(e.message))).toBe(true) + }) +}) + +describe('runCompilePipeline — runtime v4 path', () => { + it('composes the v4 bundle and uploads when deviceContext is present + runtime is compatible', async () => { + const port = makePort() + const { emit } = captureEvents() + const result = await runCompilePipeline( + makeArgs({ + isSimulator: false, + isRuntimeV4: true, + boardRuntime: 'openplc-compiler', + deviceContext: deviceContextFixture, + }), + port, + emit, + ) + expect(result.success).toBe(true) + expect(result.uploaded).toBe(true) + expect(port.checkRuntimeVersion).toHaveBeenCalledTimes(1) + expect(port.uploadRuntimeV4).toHaveBeenCalledTimes(1) + // Arduino-cli compile is NOT invoked on the v4 path. + expect(port.compileArduino).not.toHaveBeenCalled() + }) + + it('aborts when checkRuntimeVersion reports an incompatible runtime', async () => { + mockedVersionGate.mockReturnValueOnce(false) + const port = makePort({ + checkRuntimeVersion: jest.fn().mockResolvedValue({ ok: true, version: '4.0.5' }), + }) + const { events, emit } = captureEvents() + const result = await runCompilePipeline( + makeArgs({ + isSimulator: false, + isRuntimeV4: true, + boardRuntime: 'openplc-compiler', + deviceContext: deviceContextFixture, + }), + port, + emit, + ) + expect(result.success).toBe(false) + expect(port.uploadRuntimeV4).not.toHaveBeenCalled() + expect(events.some((e) => /too old|upgrade/i.test(e.message))).toBe(true) + }) + + it('compileOnly on v4 returns success without invoking checkRuntimeVersion or uploadRuntimeV4', async () => { + const port = makePort() + const { emit } = captureEvents() + const result = await runCompilePipeline( + makeArgs({ + isSimulator: false, + isRuntimeV4: true, + boardRuntime: 'openplc-compiler', + compileOnly: true, + deviceContext: deviceContextFixture, + }), + port, + emit, + ) + expect(result.success).toBe(true) + expect(result.uploaded).toBe(false) + expect(port.checkRuntimeVersion).not.toHaveBeenCalled() + expect(port.uploadRuntimeV4).not.toHaveBeenCalled() + }) + + it('returns warning + success=true when deviceContext is missing on v4', async () => { + const port = makePort() + const { events, emit } = captureEvents() + const result = await runCompilePipeline( + makeArgs({ + isSimulator: false, + isRuntimeV4: true, + boardRuntime: 'openplc-compiler', + }), + port, + emit, + ) + expect(result.success).toBe(true) + expect(result.uploaded).toBe(false) + expect(events.some((e) => e.level === 'warning' && /not configured/i.test(e.message))).toBe(true) + }) + + it('invokes packageVppPlugin on v4 after composeRuntimeV4Bundle, before uploadRuntimeV4', async () => { + // The pre-refactor compileProgram called handleVendorPluginPackaging + // unconditionally on the v4 path between bundle compose and upload; + // the handler self-gated on whether the board is from a VPP package. + // Mirror that ordering through the port. + const callOrder: string[] = [] + const port = makePort({ + packageVppPlugin: jest.fn().mockImplementation(async () => { + callOrder.push('vpp') + return { files: {} } + }), + uploadRuntimeV4: jest.fn().mockImplementation(async () => { + callOrder.push('upload') + return { ok: true } + }), + }) + const { emit } = captureEvents() + const result = await runCompilePipeline( + makeArgs({ + isSimulator: false, + isRuntimeV4: true, + boardRuntime: 'openplc-compiler', + boardTarget: 'SLM-RP4', + deviceContext: deviceContextFixture, + }), + port, + emit, + ) + expect(result.success).toBe(true) + expect(port.packageVppPlugin).toHaveBeenCalledWith({ boardTarget: 'SLM-RP4' }, expect.any(Function)) + expect(callOrder).toEqual(['vpp', 'upload']) + }) + + it('merges packageVppPlugin files into the bundle passed to uploadRuntimeV4', async () => { + const port = makePort({ + packageVppPlugin: jest.fn().mockResolvedValue({ + files: { + 'vpp_plugins.conf': 'slm_rp4_plugin,./build/vpp/libslm_rp4_plugin.so,1,1,./build/vpp/slm_rp4_plugin.json,\n', + 'conf/slm_rp4_plugin.json': '{"vendor":"slm","modules":[]}', + 'vpp_plugin/Makefile': 'all:\n\tgcc -c plugin.c\n', + 'vpp_plugin/checksum.sha256': 'cafef00d\n', + }, + }), + }) + const { emit } = captureEvents() + await runCompilePipeline( + makeArgs({ + isSimulator: false, + isRuntimeV4: true, + boardRuntime: 'openplc-compiler', + boardTarget: 'SLM-RP4', + deviceContext: deviceContextFixture, + }), + port, + emit, + ) + const uploadedBundle = (port.uploadRuntimeV4 as jest.Mock).mock.calls[0][0].bundle as Record + expect(uploadedBundle['vpp_plugins.conf']).toMatch(/^slm_rp4_plugin,/) + expect(uploadedBundle['conf/slm_rp4_plugin.json']).toContain('"vendor":"slm"') + expect(uploadedBundle['vpp_plugin/Makefile']).toContain('gcc -c plugin.c') + expect(uploadedBundle['vpp_plugin/checksum.sha256']).toBe('cafef00d\n') + }) + + it('emits a log entry reporting the number of VPP plugin files merged into the bundle', async () => { + // Pins the log line at pipeline.ts:466 that runs when VPP + // packaging returns a non-empty file map. Acts as a regression + // guard for the "Merged N VPP plugin file(s) into bundle" UX — + // without this assertion the count never gets exercised. + const port = makePort({ + packageVppPlugin: jest.fn().mockResolvedValue({ + files: { + 'vpp_plugins.conf': 'slm,./build/vpp/libslm.so,1,1,./build/vpp/slm.json,\n', + 'conf/slm.json': '{}', + }, + }), + }) + const { events, emit } = captureEvents() + await runCompilePipeline( + makeArgs({ + isSimulator: false, + isRuntimeV4: true, + boardRuntime: 'openplc-compiler', + deviceContext: deviceContextFixture, + }), + port, + emit, + ) + expect(events.some((e) => /Merged 2 VPP plugin file\(s\) into bundle/.test(e.message))).toBe(true) + }) + + it('passes the project task instances through to generateRuntimeConfs in the expected shape', async () => { + // Covers the `instances.map(inst => ({name, task, program}))` + // lambda at pipeline.ts:410-417 — the v4 path's instance + // remapping that feeds `generateRuntimeConfs`. An empty default + // fixture leaves the lambda body uncovered; this test populates + // a real instance and asserts the remapped shape on the way out. + const port = makePort() + const { emit } = captureEvents() + await runCompilePipeline( + makeArgs({ + isSimulator: false, + isRuntimeV4: true, + boardRuntime: 'openplc-compiler', + deviceContext: deviceContextFixture, + projectData: { + ...projectDataFixture, + configuration: { + resource: { + tasks: [], + instances: [{ name: 'main0', task: 'MainTask', program: 'main' }], + globalVariables: [], + }, + }, + } as never, + }), + port, + emit, + ) + expect(mockedConfs).toHaveBeenCalledTimes(1) + const passedInstances = mockedConfs.mock.calls[0][0].instances + expect(passedInstances).toEqual([{ name: 'main0', task: 'MainTask', program: 'main' }]) + }) + + it('aborts the v4 upload when packageVppPlugin reports errors', async () => { + const port = makePort({ + packageVppPlugin: jest.fn().mockResolvedValue({ + files: {}, + errors: [{ message: 'VPP packaging exploded', line: 0, column: 0, severity: 'error' }], + }), + }) + const { events, emit } = captureEvents() + const result = await runCompilePipeline( + makeArgs({ + isSimulator: false, + isRuntimeV4: true, + boardRuntime: 'openplc-compiler', + deviceContext: deviceContextFixture, + }), + port, + emit, + ) + expect(result.success).toBe(false) + expect(port.uploadRuntimeV4).not.toHaveBeenCalled() + expect(events.some((e) => /VPP plugin packaging failed/i.test(e.message))).toBe(true) + }) +}) + +describe('runCompilePipeline — runtime v3 path', () => { + it('uploads via uploadRuntimeV3 (skips arduino-cli compile)', async () => { + const port = makePort() + const { emit } = captureEvents() + const result = await runCompilePipeline( + makeArgs({ + isSimulator: false, + isRuntimeV3: true, + boardRuntime: 'arduino-cli', + boardTarget: 'OpenPLC Runtime v3', + deviceContext: deviceContextFixture, + }), + port, + emit, + ) + expect(result.success).toBe(true) + expect(result.uploaded).toBe(true) + expect(port.uploadRuntimeV3).toHaveBeenCalledTimes(1) + expect(port.compileArduino).not.toHaveBeenCalled() + }) + + it('compileOnly on v3 returns success without invoking uploadRuntimeV3', async () => { + const port = makePort() + const { emit } = captureEvents() + const result = await runCompilePipeline( + makeArgs({ + isSimulator: false, + isRuntimeV3: true, + boardRuntime: 'arduino-cli', + boardTarget: 'OpenPLC Runtime v3', + compileOnly: true, + deviceContext: deviceContextFixture, + }), + port, + emit, + ) + expect(result.success).toBe(true) + expect(result.uploaded).toBe(false) + expect(port.uploadRuntimeV3).not.toHaveBeenCalled() + }) + + it('warns + skips upload when v3 deviceContext is missing', async () => { + const port = makePort() + const { events, emit } = captureEvents() + const result = await runCompilePipeline( + makeArgs({ + isSimulator: false, + isRuntimeV3: true, + boardRuntime: 'arduino-cli', + boardTarget: 'OpenPLC Runtime v3', + }), + port, + emit, + ) + expect(result.success).toBe(true) + expect(result.uploaded).toBe(false) + expect(port.uploadRuntimeV3).not.toHaveBeenCalled() + expect(events.some((e) => e.level === 'warning' && /v3 not configured/i.test(e.message))).toBe(true) + }) + + it('returns success=false when uploadRuntimeV3 reports failure', async () => { + const port = makePort({ + uploadRuntimeV3: jest.fn().mockResolvedValue({ ok: false }), + }) + const { emit } = captureEvents() + const result = await runCompilePipeline( + makeArgs({ + isSimulator: false, + isRuntimeV3: true, + boardRuntime: 'arduino-cli', + boardTarget: 'OpenPLC Runtime v3', + deviceContext: deviceContextFixture, + }), + port, + emit, + ) + expect(result.success).toBe(false) + }) +}) + +describe('runCompilePipeline — strucpp informational outputs', () => { + it('emits splitterFallbackMessage when strucpp reports one', async () => { + mockedStrucpp.mockReturnValueOnce({ + success: true, + files: [{ name: 'debug-map.json', content: '{}' }], + errors: [], + warnings: [], + md5Hash: 'a'.repeat(32), + splitterFallbackMessage: 'Falling back to monolithic compile (POU offsets unavailable).', + debugMapSummary: null, + }) + const port = makePort() + const { events, emit } = captureEvents() + await runCompilePipeline(makeArgs(), port, emit) + expect( + events.some((e) => e.stage === 'st' && /Falling back to monolithic/.test(e.message) && e.level === 'info'), + ).toBe(true) + }) + + it('emits debugMapSummary when strucpp reports one', async () => { + mockedStrucpp.mockReturnValueOnce({ + success: true, + files: [{ name: 'debug-map.json', content: '{}' }], + errors: [], + warnings: [], + md5Hash: 'a'.repeat(32), + splitterFallbackMessage: null, + debugMapSummary: 'Debug map: 42 leaves in 3 arrays', + }) + const port = makePort() + const { events, emit } = captureEvents() + await runCompilePipeline(makeArgs(), port, emit) + expect(events.some((e) => e.stage === 'st' && /Debug map: 42/.test(e.message))).toBe(true) + }) + + it('forwards strucpp warnings as level=warning events', async () => { + mockedStrucpp.mockReturnValueOnce({ + success: true, + files: [{ name: 'debug-map.json', content: '{}' }], + errors: [], + warnings: [ + { formatted: 'unused variable foo', raw: {} as never }, + { formatted: 'shadowed identifier bar', raw: {} as never }, + ], + md5Hash: 'a'.repeat(32), + splitterFallbackMessage: null, + debugMapSummary: null, + }) + const port = makePort() + const { events, emit } = captureEvents() + await runCompilePipeline(makeArgs(), port, emit) + const warningEvents = events.filter((e) => e.level === 'warning') + expect(warningEvents.map((e) => e.message)).toEqual( + expect.arrayContaining(['unused variable foo', 'shadowed identifier bar']), + ) + }) + + it('emits an "unknown warning" placeholder when a strucpp warning has no formatted text', async () => { + mockedStrucpp.mockReturnValueOnce({ + success: true, + files: [{ name: 'debug-map.json', content: '{}' }], + errors: [], + // `?? `-fallback fires on nullish values (undefined/null), not + // empty strings — pass undefined to exercise the unknown-warning + // path. + warnings: [{ formatted: undefined as never, raw: {} as never }], + md5Hash: 'a'.repeat(32), + splitterFallbackMessage: null, + debugMapSummary: null, + }) + const port = makePort() + const { events, emit } = captureEvents() + await runCompilePipeline(makeArgs(), port, emit) + expect(events.some((e) => e.level === 'warning' && /unknown warning/.test(e.message))).toBe(true) + }) +}) + +describe('runCompilePipeline — boardEntry shape variants', () => { + it('handles a boardEntry with no platform field (deriveArduinoCoreFromPlatform → empty)', async () => { + // When platform isn't set on the entry, the pipeline shouldn't + // crash — installArduinoCore is still called (with coreId='') and + // the no-op return resolves cleanly. + const port = makePort() + const { emit } = captureEvents() + const result = await runCompilePipeline( + makeArgs({ + boardEntry: { platform: '', core: '' }, + }), + port, + emit, + ) + expect(result.success).toBe(true) + expect(port.installArduinoCore).toHaveBeenCalledWith(expect.objectContaining({ coreId: '' }), expect.any(Function)) + }) + + it('derives the core id from `platform` (e.g. arduino:avr:mega → arduino:avr)', async () => { + const port = makePort() + const { emit } = captureEvents() + await runCompilePipeline( + makeArgs({ + boardEntry: { platform: 'arduino:avr:mega', core: 'arduino:avr' }, + }), + port, + emit, + ) + expect(port.installArduinoCore).toHaveBeenCalledWith( + expect.objectContaining({ coreId: 'arduino:avr' }), + expect.any(Function), + ) + }) +}) + +// --------------------------------------------------------------------------- +// Error paths +// --------------------------------------------------------------------------- + +describe('runCompilePipeline — failure propagation', () => { + it('returns success=false when XmlGenerator reports failure', async () => { + mockedXmlGen.mockReturnValueOnce({ ok: false, data: undefined, message: 'malformed pou' } as never) + const port = makePort() + const { events, emit } = captureEvents() + const result = await runCompilePipeline(makeArgs(), port, emit) + expect(result.success).toBe(false) + expect(events.some((e) => e.stage === 'xml' && /malformed pou/.test(e.message))).toBe(true) + expect(port.transpileXmlToSt).not.toHaveBeenCalled() + }) + + it('returns success=false when transpileXmlToSt reports failure', async () => { + const port = makePort({ + transpileXmlToSt: jest.fn().mockResolvedValue({ + ok: false, + errors: [{ message: 'bad xml', line: 1, column: 1, severity: 'error' }], + }), + }) + const { emit } = captureEvents() + const result = await runCompilePipeline(makeArgs(), port, emit) + expect(result.success).toBe(false) + expect(port.compileArduino).not.toHaveBeenCalled() + }) + + it('returns success=false when strucpp reports failure', async () => { + mockedStrucpp.mockReturnValueOnce({ + success: false, + files: [], + errors: [ + { + formatted: 'unknown symbol foo', + raw: { message: 'unknown symbol foo', line: 1, column: 1, severity: 'error' } as never, + }, + ], + warnings: [], + md5Hash: '', + splitterFallbackMessage: null, + debugMapSummary: null, + }) + const port = makePort() + const { emit } = captureEvents() + const result = await runCompilePipeline(makeArgs(), port, emit) + expect(result.success).toBe(false) + expect(port.compileArduino).not.toHaveBeenCalled() + }) + + it('returns success=false when compileArduino reports failure', async () => { + const port = makePort({ + compileArduino: jest.fn().mockResolvedValue({ + ok: false, + errors: [{ message: 'linker error', line: 1, column: 1, severity: 'error' }], + }), + }) + const { emit } = captureEvents() + const result = await runCompilePipeline(makeArgs(), port, emit) + expect(result.success).toBe(false) + }) + + it('returns success=false when uploadRuntimeV4 reports failure', async () => { + const port = makePort({ + uploadRuntimeV4: jest.fn().mockResolvedValue({ ok: false, errors: [] }), + }) + const { emit } = captureEvents() + const result = await runCompilePipeline( + makeArgs({ + isSimulator: false, + isRuntimeV4: true, + boardRuntime: 'openplc-compiler', + deviceContext: deviceContextFixture, + }), + port, + emit, + ) + expect(result.success).toBe(false) + }) + + it('returns success=false when installArduinoCore reports failure', async () => { + const port = makePort({ + installArduinoCore: jest.fn().mockResolvedValue({ ok: false }), + }) + const { emit } = captureEvents() + const result = await runCompilePipeline(makeArgs(), port, emit) + expect(result.success).toBe(false) + expect(port.compileArduino).not.toHaveBeenCalled() + }) + + it('returns success=false when installArduinoLib reports failure', async () => { + const port = makePort({ + installArduinoLib: jest.fn().mockResolvedValue({ ok: false }), + }) + const { emit } = captureEvents() + const result = await runCompilePipeline(makeArgs(), port, emit) + expect(result.success).toBe(false) + }) + + it('returns success=false when generateRuntimeConfs throws (OPC-UA / EtherCAT failure)', async () => { + // Pipeline wraps the v4 conf-generation step in a try/catch so a + // failed EtherCAT or OPC-UA validator surfaces as a clean + // `success: false` rather than crashing the IPC channel. Mock + // `generateRuntimeConfs` to throw so we exercise the catch path + // deterministically — the underlying validators have their own + // exhaustive throw-case suites (validate-ethercat-config.test.ts, + // generate-confs.test.ts). + mockedStrucpp.mockReturnValueOnce({ + success: true, + files: [{ name: 'debug-map.json', content: '{}' }], + errors: [], + warnings: [], + md5Hash: 'a'.repeat(32), + splitterFallbackMessage: null, + debugMapSummary: null, + }) + mockedConfs.mockImplementationOnce(() => { + throw new Error('EtherCAT validator: vendor id missing on slave #0') + }) + const port = makePort() + const { events, emit } = captureEvents() + const result = await runCompilePipeline( + makeArgs({ + isSimulator: false, + isRuntimeV4: true, + boardRuntime: 'openplc-compiler', + deviceContext: deviceContextFixture, + }), + port, + emit, + ) + expect(result.success).toBe(false) + // The original throw message rides through the error event so + // the user can see which validator complained. + expect(events.some((e) => /EtherCAT validator/.test(e.message))).toBe(true) + // And we never reached the upload step. + expect(port.uploadRuntimeV4).not.toHaveBeenCalled() + }) +}) + +// --------------------------------------------------------------------------- +// Side effects +// --------------------------------------------------------------------------- + +describe('runCompilePipeline — side effects', () => { + it('calls cacheDebugData with the strucpp MD5 + debug-map.json content', async () => { + const cacheDebugData = jest.fn() + const port = makePort() + const { emit } = captureEvents() + await runCompilePipeline(makeArgs({ cacheDebugData }), port, emit) + expect(cacheDebugData).toHaveBeenCalledWith('a'.repeat(32), '{}') + }) + + it('emits a done event with level=info on successful completion', async () => { + const port = makePort() + const { events, emit } = captureEvents() + await runCompilePipeline(makeArgs(), port, emit) + const done = events.find((e) => e.stage === 'done') + expect(done).toBeDefined() + expect(done?.level).toBe('info') + }) + + it('emits per-error events with structured compileError payloads on transpile failure', async () => { + const port = makePort({ + transpileXmlToSt: jest.fn().mockResolvedValue({ + ok: false, + errors: [ + { message: 'bad syntax', line: 5, column: 3, severity: 'error' }, + { message: 'undefined symbol', line: 7, column: 2, severity: 'error' }, + ], + }), + }) + const { events, emit } = captureEvents() + await runCompilePipeline(makeArgs(), port, emit) + const errorEvents = events.filter((e) => e.compileError !== undefined) + expect(errorEvents).toHaveLength(2) + }) + + it("forwards generateRuntimeConfs's log callback to emit at the 'confs' stage", async () => { + // Covers the `log: (message, level) => emit({...})` lambda at + // pipeline.ts:417. Atomic conf generators surface validation + // diagnostics through this callback (warnings about dropped + // OPC-UA refs, info about EtherCAT vendor lookups, etc.); we + // need to verify the pipeline wires the callback into the emit + // channel so those diagnostics reach the console panel. + mockedConfs.mockImplementationOnce((input) => { + input.log('dropped variable foo because bar', 'error') + input.log('opcua found 5 nodes', 'info') + return { modbusSlave: '', modbusMaster: '', s7Comm: '', opcUa: null, ethercat: '' } + }) + const port = makePort() + const { events, emit } = captureEvents() + await runCompilePipeline( + makeArgs({ + isSimulator: false, + isRuntimeV4: true, + boardRuntime: 'openplc-compiler', + deviceContext: deviceContextFixture, + }), + port, + emit, + ) + const confsEvents = events.filter((e) => e.stage === 'confs') + expect(confsEvents.some((e) => e.message === 'dropped variable foo because bar' && e.level === 'error')).toBe(true) + expect(confsEvents.some((e) => e.message === 'opcua found 5 nodes' && e.level === 'info')).toBe(true) + }) + + it('outer try/catch wraps unhandled exceptions in an error event (Error instance)', async () => { + // Covers the runCompilePipeline (outer) catch at pipeline.ts:258-267 + // — the bail path for any throw the inner orchestrator didn't + // already convert to a structured failure. Force a port method to + // throw and assert we get the canonical "Unhandled pipeline error" + // event + a clean `success: false` rather than an unhandled + // rejection that hangs the IPC channel. + const port = makePort({ + computeMd5: jest.fn().mockImplementation(() => { + throw new Error('crypto subsystem unavailable') + }), + }) + const { events, emit } = captureEvents() + const result = await runCompilePipeline( + makeArgs({ + isSimulator: false, + isRuntimeV4: true, + boardRuntime: 'openplc-compiler', + deviceContext: deviceContextFixture, + }), + port, + emit, + ) + expect(result.success).toBe(false) + expect(events.some((e) => /Unhandled pipeline error: crypto subsystem unavailable/.test(e.message))).toBe(true) + expect(events.some((e) => e.message === 'Stopping compilation process.')).toBe(true) + }) + + it('outer try/catch wraps unhandled non-Error throws by stringifying them', async () => { + // Same catch as above, but the thrown value is NOT an Error + // instance — exercises the `String(error)` branch in the catch. + const port = makePort({ + computeMd5: jest.fn().mockImplementation(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 'plain string throw' + }), + }) + const { events, emit } = captureEvents() + const result = await runCompilePipeline( + makeArgs({ + isSimulator: false, + isRuntimeV4: true, + boardRuntime: 'openplc-compiler', + deviceContext: deviceContextFixture, + }), + port, + emit, + ) + expect(result.success).toBe(false) + expect(events.some((e) => /Unhandled pipeline error: plain string throw/.test(e.message))).toBe(true) + }) + + it('port methods can stream log lines through the PlatformLog callback they receive', async () => { + // Covers the `(message, level) => emit({stage, message, level})` + // lambda makePlatformLog returns (pipeline.ts:209). Port methods + // receive that callback as their second arg and call it whenever + // they want a log line on the console panel. Tests that mock + // ports with `vi.fn()` never invoke the callback, leaving the + // lambda body uncovered — this test pins the wiring explicitly. + const port = makePort({ + transpileXmlToSt: jest.fn().mockImplementation(async (_args, log) => { + log('xml2st spawned subprocess', 'info') + log('xml2st: parsed 5 POUs', 'info') + return { ok: true, programSt: 'PROGRAM main\nEND_PROGRAM' } + }), + }) + const { events, emit } = captureEvents() + await runCompilePipeline(makeArgs(), port, emit) + const stEvents = events.filter((e) => e.stage === 'st') + expect(stEvents.some((e) => e.message === 'xml2st spawned subprocess' && e.level === 'info')).toBe(true) + expect(stEvents.some((e) => e.message === 'xml2st: parsed 5 POUs' && e.level === 'info')).toBe(true) + }) +}) diff --git a/src/backend/shared/compile/__tests__/resolve-board-selection.test.ts b/src/backend/shared/compile/__tests__/resolve-board-selection.test.ts new file mode 100644 index 000000000..9916e214a --- /dev/null +++ b/src/backend/shared/compile/__tests__/resolve-board-selection.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from '@jest/globals' + +import { resolveBoardSelection } from '../steps/resolve-board-selection' + +// Minimal hals fixtures. The resolver only inspects `.compiler`; +// other fields ride through verbatim, so tests don't need to +// construct full BoardHalsCompileEntry shapes. +const halsFixture = { + 'OpenPLC Simulator': { compiler: 'simulator', platform: 'arduino:avr:mega' }, + 'OpenPLC Runtime v3': { compiler: 'openplc-compiler' }, + 'OpenPLC Runtime v4': { compiler: 'openplc-compiler' }, + 'Arduino Mega 2560': { compiler: 'arduino-cli', platform: 'arduino:avr:mega' }, + 'Some Future Board': {}, +} + +describe('resolveBoardSelection', () => { + it('returns ok=false with a clear message when the boardTarget is missing from hals', () => { + const result = resolveBoardSelection(halsFixture, 'No Such Board') + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toMatch(/"No Such Board"/) + expect(result.error).toMatch(/hals\.json/) + } + }) + + it('returns the entry, boardRuntime, and flags for a simulator target', () => { + const result = resolveBoardSelection(halsFixture, 'OpenPLC Simulator') + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.boardEntry.compiler).toBe('simulator') + expect(result.boardRuntime).toBe('simulator') + expect(result.isSimulator).toBe(true) + expect(result.isRuntimeV3).toBe(false) + expect(result.isRuntimeV4).toBe(false) + } + }) + + it('OpenPLC Runtime v3 sets isRuntimeV3 and NOT isRuntimeV4 even when compiler is openplc-compiler', () => { + const result = resolveBoardSelection(halsFixture, 'OpenPLC Runtime v3') + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.boardRuntime).toBe('openplc-compiler') + expect(result.isRuntimeV3).toBe(true) + expect(result.isRuntimeV4).toBe(false) + expect(result.isSimulator).toBe(false) + } + }) + + it('OpenPLC Runtime v4 sets isRuntimeV4 (compiler openplc-compiler + not v3)', () => { + const result = resolveBoardSelection(halsFixture, 'OpenPLC Runtime v4') + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.isRuntimeV4).toBe(true) + expect(result.isRuntimeV3).toBe(false) + expect(result.isSimulator).toBe(false) + } + }) + + it('Arduino direct-board target sets none of the runtime flags', () => { + const result = resolveBoardSelection(halsFixture, 'Arduino Mega 2560') + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.boardRuntime).toBe('arduino-cli') + expect(result.isSimulator).toBe(false) + expect(result.isRuntimeV3).toBe(false) + expect(result.isRuntimeV4).toBe(false) + } + }) + + it('entry without a `compiler` field produces an empty boardRuntime and all flags false', () => { + const result = resolveBoardSelection(halsFixture, 'Some Future Board') + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.boardRuntime).toBe('') + expect(result.isSimulator).toBe(false) + expect(result.isRuntimeV3).toBe(false) + expect(result.isRuntimeV4).toBe(false) + } + }) + + it('non-string `compiler` is treated as empty (defensive against bad hals data)', () => { + const halsWithBadField = { + 'Bad Board': { compiler: 42 as unknown as string }, + } + const result = resolveBoardSelection(halsWithBadField, 'Bad Board') + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.boardRuntime).toBe('') + } + }) +}) diff --git a/src/backend/shared/compile/pipeline.ts b/src/backend/shared/compile/pipeline.ts new file mode 100644 index 000000000..e5ada7289 --- /dev/null +++ b/src/backend/shared/compile/pipeline.ts @@ -0,0 +1,666 @@ +/** + * Shared OpenPLC compile pipeline. + * + * Single source of truth for the full compile flow (Steps 0–13 in + * the editor's canonical pipeline). Editor and web both drive this + * function through a `CompilerPlatformPort`; the platform port + * abstracts the three places where platform truly differs (xml2st + * transport, arduino-cli transport, runtime upload transport). + * Everything else — preprocessing, XML generation, strucpp compile, + * conf authoring, defines authoring, bundle composition, ordering, + * error formatting, log messages — is shared. + * + * Editor-canonical behaviour: every byte and every log line matches + * what editor's `handleCompile` used to emit before this refactor. + * The web pipeline will produce identical output once it lands on + * this function in a follow-up PR. + * + * The function is pure with respect to side effects EXCEPT for the + * platform port calls (subprocess spawns / HTTP requests) and the + * `emit` callback (progress events). No disk I/O, no globals. + */ + +import type { + CompilerPlatformPort, + PlatformDeviceContext, + PlatformLog, +} from '../../../middleware/shared/ports/compiler-platform-port' +import type { StructuredCompileError } from '../../../middleware/shared/ports/types' +import { composeRuntimeV4Bundle } from '../../../middleware/shared/utils/library/compose-runtime-v4-bundle' +import type { BoardHalsCompileEntry } from '../firmware/build-arduino-cli-args' +import { buildArduinoCliCompileArgs } from '../firmware/build-arduino-cli-args' +import { describeIncompatibleRuntime, isStrucppCompatibleRuntime } from '../firmware/runtime-version-gate' +import { buildKnownPous, emitCompileErrorEvents } from '../library/program-build-helpers' +import { runProgramBuildPipeline } from '../library/program-build-pipeline' +import type { DevicePin } from '../types/PLC/devices' +// PLCProjectData is read from the schema-shape type (singular `configuration`) +// because that's the runtime shape the editor's pipeline operates on. +// The web adapter currently keeps the renderer store in the port-shape +// (plural `configurations`) and converts at the pipeline entry — see C1 +// in the architectural plan. +import type { PLCProjectData } from '../types/PLC/open-plc' +import { XmlGenerator } from '../utils/PLC/xml-generator' +import { buildCBlocksFromPous, composeFirmwareBundle } from './steps/compose-firmware-bundle' +import { generateRuntimeConfs } from './steps/generate-confs' +import { generateDefinesContent } from './steps/generate-defines' + +// --------------------------------------------------------------------------- +// Public contract +// --------------------------------------------------------------------------- + +/** + * Stages the pipeline emits during a single run. Each stage carries + * an info / warning / error message and (for compile errors) the + * structured `compileError` payload the renderer's click-to-navigate + * keys off. + */ +export interface PipelineProgressEvent { + stage: + | 'preprocess' + | 'xml' + | 'st' + | 'strucpp' + | 'confs' + | 'firmware-bundle' + | 'runtime-v4-bundle' + | 'embed-c-blocks' + | 'core-install' + | 'lib-install' + | 'arduino-compile' + | 'runtime-version' + | 'upload' + | 'done' + | 'error' + message: string + level: 'info' | 'warning' | 'error' + compileError?: StructuredCompileError +} + +/** + * Slice of a `hals.json` board entry the pipeline reads. Superset + * of `BoardHalsCompileEntry` (used by `buildArduinoCliCompileArgs`) + * plus the optional `define` field used by `generateDefinesContent`. + * Caller passes the relevant entry from its platform's `hals.json`; + * both platforms ship a byte-identical `hals.json` so the entry + * shape is the same. + */ +export interface BoardHalsBuildEntry extends BoardHalsCompileEntry { + /** Per-board #defines, fed through to `generateDefinesContent` as + * the `// Board defines` section. */ + define?: string | string[] +} + +export interface RunCompilePipelineArgs { + /** Source project (renderer-side store flat shape). Pipeline + * internally preprocesses to canonical schema shape before + * threading through downstream steps. */ + projectData: PLCProjectData + /** Board target identifier from the user's selection (e.g. `'OpenPLC + * Simulator'`, `'OpenPLC Runtime v4 (RPi)'`, `'Arduino Mega 2560'`). */ + boardTarget: string + /** Runtime identifier from the matching `hals.json` entry: + * `'simulator'` (avr8js), `'arduino-cli'` (direct Arduino board), + * `'openplc-compiler'` (runtime v4 vPLC). */ + boardRuntime: string + /** Resolved `hals.json` entry for `boardTarget`. Carries the + * per-board `define` field consumed by `generateDefinesContent` + * and the `platform` / c_flags / cxx_flags arduino-cli passes + * through. */ + boardEntry: BoardHalsBuildEntry + /** Pin mappings parsed from `devices/pin-mapping.json`. Threaded + * through to `generateDefinesContent` for the `PINMASK_*` and + * `NUM_*` defines. */ + devicePinMapping: DevicePin[] + /** `true` when the user picked the simulator board. Drives whether + * the pipeline returns after arduino-cli compile (simulator) or + * goes on to upload (physical Arduino). */ + isSimulator: boolean + /** `true` when the runtime is the OpenPLC v4 vPLC (boardRuntime + * `'openplc-compiler'` + `boardTarget !== 'OpenPLC Runtime v3'`). + * Drives the v4 bundle path (composeRuntimeV4Bundle + uploadRuntimeV4). */ + isRuntimeV4: boolean + /** `true` when the runtime is the legacy v3 (boardTarget === + * `'OpenPLC Runtime v3'`). Drives the v3 embed-c-blocks path. */ + isRuntimeV3: boolean + /** `true` when the caller only wants a compile, no upload. The + * pipeline still runs through every step but returns before the + * upload phase. */ + compileOnly: boolean + /** Pre-loaded `.stlib` archives the strucpp compile needs. + * Resolved by the adapter (editor: from `node_modules/strucpp/lib` + * + user-installed pool; web: from bundled assets). */ + libraryArchives: unknown[] + /** Library names the project enables but couldn't be resolved. + * Strucpp's pre-compile gate fails fast on these with a clear + * message. */ + missingLibraries: string[] + /** Firmware skeleton — bundled `Baremetal.ino`, Arduino HAL, + * strucpp runtime headers, simulator HAL adapter. Editor: from + * filesystem; web: from `import.meta.glob`. Contents byte- + * identical between repos. */ + firmwareSkeleton: Record + /** Strucpp runtime headers keyed under `strucpp_runtime/include/`. + * Only used by `composeRuntimeV4Bundle`; pass empty for the + * simulator path. */ + strucppRuntimeHeaders: Record + /** Server-resolved path to the avr-libstdcpp include directory. + * Threaded through to `buildArduinoCliCompileArgs`. Empty string + * when not applicable (e.g. non-AVR cores). */ + avrLibStdCppInclude: string + /** When `false`, arduino-cli runs with `--jobs 1` (web sandbox + * default). When `true`, defaults to `--jobs 0` (editor's + * use-every-core). */ + arduinoCliParallel: boolean + /** Device context for upload steps. `undefined` when the caller + * is compile-only or the runtime upload step won't run. The + * pipeline never inspects this — it just forwards through. */ + deviceContext?: PlatformDeviceContext + /** Serial port to hand to `arduino-cli upload --port` when the + * build targets a physical Arduino board. Captured from the + * user's device-board UI picker — the renderer reads it from the + * store at compile time and passes it through unchanged. When + * absent (older callers, runtime v4 / simulator paths), the + * pipeline still threads `''` through so the editor adapter can + * fall back to its legacy `devices/configuration.json` disk + * read. Ignored entirely on simulator + runtime-v3/v4 branches. */ + communicationPort?: string + /** Optional cache hook for the strucpp debug-map.json bytes — the + * debugger reads these out of memory to map debug variable + * addresses without re-reading the file. Called once per + * successful strucpp compile. */ + cacheDebugData?: (md5: string, debugMapJson: string) => void +} + +export interface RunCompilePipelineResult { + success: boolean + /** Structured strucpp + xml2st diagnostics from this run. Carries + * the per-error events the renderer's navigation keys off. */ + errors?: StructuredCompileError[] + /** Compiled firmware bytes when the pipeline reached the + * arduino-cli compile step successfully. `undefined` when the + * pipeline targeted runtime v4 (no arduino-cli step) or failed + * before compile. Caller decides what to do with these bytes + * (editor: write to `Baremetal.ino.hex`; web: feed avr8js). */ + binary?: Uint8Array + /** MD5 of the strucpp-compiled `program.st`. Echoed back so + * callers can use it as a cache key (defines.h PROGRAM_MD5 + * refers to it). */ + md5?: string + /** `true` when an upload step ran successfully (runtime v4 upload, + * arduino direct upload, or runtime v3 upload). `false` when + * the pipeline returned via `compileOnly` or before reaching + * upload. */ + uploaded?: boolean +} + +// --------------------------------------------------------------------------- +// Internal: emit helpers +// --------------------------------------------------------------------------- + +function makePlatformLog( + emit: (event: PipelineProgressEvent) => void, + stage: PipelineProgressEvent['stage'], +): PlatformLog { + return (message, level) => emit({ stage, message, level }) +} + +// --------------------------------------------------------------------------- +// Internal: bail helpers (single point for the "stop the pipeline" message) +// --------------------------------------------------------------------------- + +function bailError( + emit: (event: PipelineProgressEvent) => void, + stage: PipelineProgressEvent['stage'], + message: string, + errors?: StructuredCompileError[], +): RunCompilePipelineResult { + emit({ stage, message, level: 'error' }) + emit({ stage: 'error', message: 'Stopping compilation process.', level: 'error' }) + return { success: false, errors } +} + +// --------------------------------------------------------------------------- +// The pipeline +// --------------------------------------------------------------------------- + +/** + * Run the full compile pipeline for a single project. Branches on + * `isRuntimeV4` / `isRuntimeV3` / `isSimulator` to drive the four + * editor-canonical paths: + * + * - Runtime v4 (openplc-compiler runtime): preprocess → XML → ST → + * strucpp → confs → composeRuntimeV4Bundle → version check → + * uploadRuntimeV4. + * - Simulator (avr8js): preprocess → XML → ST → + * strucpp → defines → composeFirmwareBundle → installCore/Lib + * (no-op on web) → compileArduino → return hex. + * - Arduino direct (physical board): same as simulator, then + * uploadArduinoBoard. + * - Runtime v3 (legacy): preprocess → XML → ST → + * strucpp → embed c-blocks → uploadRuntimeV3. + * + * Each branch returns the canonical `RunCompilePipelineResult` + * shape — `success`, `errors`, `binary`, `md5`, `uploaded` — that + * adapters surface to their `CompilerPort` callers. + */ +export async function runCompilePipeline( + args: RunCompilePipelineArgs, + port: CompilerPlatformPort, + emit: (event: PipelineProgressEvent) => void, +): Promise { + try { + return await runCompilePipelineInner(args, port, emit) + } catch (error) { + // Any unhandled throw (data shape mismatch, port impl crash, + // strucpp module load failure) surfaces here as a single error + // event so the renderer's IPC channel doesn't hang waiting on a + // success/failure that never arrives. + const message = error instanceof Error ? `${error.message}\n${error.stack ?? ''}` : String(error) + emit({ stage: 'error', message: `Unhandled pipeline error: ${message}`, level: 'error' }) + emit({ stage: 'error', message: 'Stopping compilation process.', level: 'error' }) + return { success: false } + } +} + +async function runCompilePipelineInner( + args: RunCompilePipelineArgs, + port: CompilerPlatformPort, + emit: (event: PipelineProgressEvent) => void, +): Promise { + const { + projectData, + boardTarget, + boardRuntime, + boardEntry, + devicePinMapping, + isSimulator, + isRuntimeV4, + isRuntimeV3, + compileOnly, + libraryArchives, + missingLibraries, + firmwareSkeleton, + strucppRuntimeHeaders, + avrLibStdCppInclude, + arduinoCliParallel, + deviceContext, + communicationPort, + cacheDebugData, + } = args + + // --------------------------------------------------------------------- + // Step 0: Use the already-preprocessed project data. + // + // Preprocessing (Python POU → ST stub conversion + C/C++ POU + // sidecar extraction) runs on each platform's renderer side + // BEFORE the pipeline is called — editor does it in the + // compile-action that posts the IPC message, web does it in its + // compile-adapter before invoking the pipeline. Doing it again + // here would double-process the data (and on editor the IPC + // shape-conversion makes preprocessPous's port-shape assumptions + // fail at runtime). The pipeline trusts that `projectData.pous` + // are already in ST form and that `originalCppPous` is attached + // when the project has C/C++ POUs. + // --------------------------------------------------------------------- + const processedData = projectData as PLCProjectData & { + originalCppPous?: Array<{ name: string; code: string; variables: unknown[] }> + } + const originalCppPous = processedData.originalCppPous ?? [] + + // --------------------------------------------------------------------- + // Step 1: Generate IEC 61131-3 XML from the project JSON. + // --------------------------------------------------------------------- + emit({ stage: 'xml', message: 'Generating IEC 61131-3 XML...', level: 'info' }) + // XmlGenerator accepts the schema-shape PLCProjectData (singular + // `configuration`). We pass through the preprocessor's output + // which is structurally compatible at runtime — see Step 0's type + // note. + const xmlResult = XmlGenerator(processedData as never, 'old-editor') + if (!xmlResult.ok || !xmlResult.data) { + return bailError(emit, 'xml', `Error generating XML from JSON: ${xmlResult.message}`) + } + const plcXml = xmlResult.data + + // --------------------------------------------------------------------- + // Step 2: Transpile XML to ST via the platform port (xml2st binary + // on editor, HTTP /generate-st on web). + // --------------------------------------------------------------------- + emit({ stage: 'st', message: 'Generating Structured Text...', level: 'info' }) + const stResult = await port.transpileXmlToSt({ xml: plcXml }, makePlatformLog(emit, 'st')) + if (!stResult.ok || !stResult.programSt) { + if (stResult.errors && stResult.errors.length > 0) { + emitCompileErrorEvents( + stResult.errors.map((e) => ({ formatted: e.message, raw: e as unknown as never })), + (msg, level, compileError) => emit({ stage: 'st', message: msg, level, compileError }), + ) + } + return bailError(emit, 'st', 'Failed to generate Structured Text', stResult.errors) + } + const programSt = stResult.programSt + + // --------------------------------------------------------------------- + // Step 3: Strucpp compile. Emits generated.cpp/hpp, + // generated_debug.cpp, debug-map.json, per-POU *.cpp splits, and the + // program.st.map.json offset map. + // --------------------------------------------------------------------- + emit({ stage: 'strucpp', message: 'Compiling Structured Text to C++ with STruC++...', level: 'info' }) + const hasCBlocks = originalCppPous.length > 0 + // `buildKnownPous` is typed against the port-shape `PLCPou`; cast + // through `never` for the same reason described in Step 0. + const knownPous = buildKnownPous(processedData.pous as never) + // MD5 of program.st — the runtime embeds this into defines.h via + // `generateDefinesContent` for stale-program detection. Each + // platform's adapter implements `computeMd5` (editor: Node crypto; + // web: spark-md5) so the shared module doesn't carry a + // heavyweight hash dependency. Both implementations produce + // byte-identical hex. + const md5 = await port.computeMd5(programSt) + const strucppResult = runProgramBuildPipeline({ + source: programSt, + md5, + pous: knownPous, + libraries: libraryArchives, + missingLibraries, + hasCBlocks, + }) + if (strucppResult.splitterFallbackMessage) { + emit({ stage: 'st', message: strucppResult.splitterFallbackMessage, level: 'info' }) + } + if (!strucppResult.success) { + emitCompileErrorEvents(strucppResult.errors, (msg, level, compileError) => + emit({ stage: 'st', message: msg, level, compileError }), + ) + return bailError(emit, 'strucpp', 'STruC++ compilation failed') + } + for (const warn of strucppResult.warnings) { + emit({ stage: 'st', message: warn.formatted ?? 'unknown warning', level: 'warning' }) + } + if (strucppResult.debugMapSummary) { + emit({ stage: 'st', message: strucppResult.debugMapSummary, level: 'info' }) + } + + // Cache the debug-map.json bytes so the debugger can map variable + // addresses without re-reading them from disk later. + const strucppFilesMap: Record = {} + for (const file of strucppResult.files) { + strucppFilesMap[file.name] = file.content + } + const debugMapJson = strucppFilesMap['debug-map.json'] ?? '' + if (cacheDebugData) { + cacheDebugData(md5, debugMapJson) + } + + // --------------------------------------------------------------------- + // Step 4a: Runtime v4 branch — compose v4 bundle, run version + // check, upload. + // --------------------------------------------------------------------- + if (isRuntimeV4) { + let confs + try { + emit({ stage: 'confs', message: 'Generating Runtime v4 conf files...', level: 'info' }) + confs = generateRuntimeConfs({ + servers: processedData.servers as never, + remoteDevices: processedData.remoteDevices as never, + instances: processedData.configuration.resource.instances.map( + (inst: { name: string; task: string; program: string }) => ({ + name: inst.name, + task: inst.task, + program: inst.program, + }), + ), + debugMapContent: debugMapJson, + log: (message, level) => emit({ stage: 'confs', message, level }), + }) + } catch (error) { + return bailError( + emit, + 'confs', + `Error generating Runtime v4 configs: ${error instanceof Error ? error.message : String(error)}`, + ) + } + + emit({ stage: 'runtime-v4-bundle', message: 'Composing Runtime v4 upload bundle...', level: 'info' }) + const cBlocks = buildCBlocksFromPous(originalCppPous as never) + const bundle = composeRuntimeV4Bundle({ + programSt, + md5, + strucppFiles: strucppFilesMap, + cBlocks: { header: cBlocks.header, code: cBlocks.code }, + strucppRuntimeHeaders, + confs: { + modbusSlave: confs.modbusSlave, + modbusMaster: confs.modbusMaster, + s7Comm: confs.s7Comm, + opcUa: confs.opcUa, + // `generateRuntimeConfs` validated EtherCAT before returning; + // null here means "no EtherCAT devices" → composer skips. + ethercat: confs.ethercat ?? '', + }, + }) + emit({ + stage: 'runtime-v4-bundle', + message: `Runtime v4 bundle composed: ${Object.keys(bundle).length} files`, + level: 'info', + }) + + // VPP boards (those from an installed `.vpp` package) ship a + // vendor I/O driver alongside the program. The platform port's + // `packageVppPlugin` returns the extra files to merge in + // (driver source under `vpp_plugin/`, the generated plugin + // config under `conf/`, and `vpp_plugins.conf` which enables + // the driver on the device). Non-VPP boards return an empty + // map and the bundle is unchanged. Without this, programs + // upload but the runtime runs as a generic v4 with no physical + // I/O — the diagnostic surface for that failure is silence. + const vppResult = await port.packageVppPlugin({ boardTarget }, makePlatformLog(emit, 'runtime-v4-bundle')) + if (vppResult.errors && vppResult.errors.length > 0) { + return bailError(emit, 'runtime-v4-bundle', 'VPP plugin packaging failed.', vppResult.errors) + } + const vppFileCount = Object.keys(vppResult.files).length + if (vppFileCount > 0) { + Object.assign(bundle, vppResult.files) + emit({ + stage: 'runtime-v4-bundle', + message: `Merged ${vppFileCount} VPP plugin file(s) into bundle (bundle now ${Object.keys(bundle).length} files)`, + level: 'info', + }) + } + + if (compileOnly) { + emit({ stage: 'done', message: 'Compile only mode — skipping upload to runtime.', level: 'info' }) + return { success: true, md5, uploaded: false } + } + + if (!deviceContext) { + emit({ + stage: 'upload', + message: 'Runtime not configured or not logged in. Skipping upload to runtime.', + level: 'warning', + }) + return { success: true, md5, uploaded: false } + } + + // Strucpp-compatibility gate: a 4.0.x runtime can't load the + // strucpp artefacts. Probe before uploading so the user gets + // "upgrade your runtime" instead of a cryptic 500. + emit({ stage: 'runtime-version', message: 'Checking runtime version...', level: 'info' }) + const versionCheck = await port.checkRuntimeVersion( + { context: deviceContext }, + makePlatformLog(emit, 'runtime-version'), + ) + if (!isStrucppCompatibleRuntime(versionCheck.version)) { + return bailError(emit, 'runtime-version', describeIncompatibleRuntime(versionCheck.version)) + } + + emit({ stage: 'upload', message: 'Uploading Runtime v4 bundle...', level: 'info' }) + const uploadResult = await port.uploadRuntimeV4({ bundle, context: deviceContext }, makePlatformLog(emit, 'upload')) + if (!uploadResult.ok) { + return bailError(emit, 'upload', 'Failed to upload to runtime.', uploadResult.errors) + } + emit({ stage: 'done', message: 'Upload complete.', level: 'info' }) + return { success: true, md5, uploaded: true } + } + + // --------------------------------------------------------------------- + // Step 4b: Arduino / Simulator path — install core + lib (no-op on + // web), generate defines.h, compose firmware bundle, compile via + // arduino-cli. + // --------------------------------------------------------------------- + emit({ stage: 'core-install', message: 'Installing Arduino core...', level: 'info' }) + const coreInstall = await port.installArduinoCore( + { coreId: typeof boardEntry.platform === 'string' ? deriveArduinoCoreFromPlatform(boardEntry.platform) : '' }, + makePlatformLog(emit, 'core-install'), + ) + if (!coreInstall.ok) { + return bailError(emit, 'core-install', 'Failed to install Arduino core.', coreInstall.errors) + } + + emit({ stage: 'lib-install', message: 'Installing Arduino libraries...', level: 'info' }) + const libInstall = await port.installArduinoLib({ libId: '' }, makePlatformLog(emit, 'lib-install')) + if (!libInstall.ok) { + return bailError(emit, 'lib-install', 'Failed to install Arduino libraries.', libInstall.errors) + } + + // Build defines.h using the shared content authoring step. + const definesH = generateDefinesContent({ + boardEntry, + devicePinMapping, + stProgramFileContent: programSt, + buildMD5Hash: md5, + boardRuntime, + }) + + // Compose firmware bundle (firmware skeleton + strucpp output + + // c_blocks header/code + defines.h). Pure function. + emit({ stage: 'firmware-bundle', message: 'Composing firmware bundle...', level: 'info' }) + const cBlocks = buildCBlocksFromPous(originalCppPous as never) + const firmwareFiles = composeFirmwareBundle({ + strucppFiles: strucppFilesMap, + cBlocks, + definesH, + firmwareSkeleton, + }) + + // Build arduino-cli argv via the shared helper. Same input/output + // on both platforms. `boardEntry` carries `platform` / `core` / + // `c_flags` / etc. straight from `hals.json`. + const arduinoArgs = buildArduinoCliCompileArgs(boardEntry, { + sketchPath: 'examples/Baremetal/Baremetal.ino', + libraryPath: 'src', + avrLibStdCppInclude, + parallel: arduinoCliParallel, + }) + + // --------------------------------------------------------------------- + // Step 4b (cont.): Runtime v3 branch is a sub-case that runs BEFORE + // the arduino-cli compile — it embeds C blocks into program.st and + // uploads the merged ST file directly to the device. + // --------------------------------------------------------------------- + if (isRuntimeV3) { + if (compileOnly) { + emit({ stage: 'done', message: 'Compile only mode — skipping upload to runtime v3.', level: 'info' }) + return { success: true, md5, uploaded: false } + } + if (!deviceContext) { + emit({ + stage: 'upload', + message: 'Runtime v3 not configured. Skipping upload.', + level: 'warning', + }) + return { success: true, md5, uploaded: false } + } + emit({ stage: 'embed-c-blocks', message: 'Embedding C blocks into program.st...', level: 'info' }) + // Editor's port implementation embeds c_blocks.h + c_blocks_code.cpp + // into program.st as (*FILE:...*) marked comments; web's port + // implementation no-ops (web doesn't target v3). The pipeline + // delegates to the port so the embed details stay platform-specific. + const uploadResult = await port.uploadRuntimeV3( + { programSt, context: deviceContext }, + makePlatformLog(emit, 'upload'), + ) + if (!uploadResult.ok) { + return bailError(emit, 'upload', 'Failed to upload to Runtime v3.', uploadResult.errors) + } + emit({ stage: 'done', message: 'Runtime v3 upload complete.', level: 'info' }) + return { success: true, md5, uploaded: true } + } + + // Run arduino-cli compile. Editor: spawns the binary. Web: HTTP + // POST. Both consume the same `files` map + `argv`. + emit({ stage: 'arduino-compile', message: 'Compiling Arduino firmware...', level: 'info' }) + const compileResult = await port.compileArduino( + { files: firmwareFiles, argv: arduinoArgs, parallel: arduinoCliParallel }, + makePlatformLog(emit, 'arduino-compile'), + ) + if (!compileResult.ok || !compileResult.binary) { + if (compileResult.errors && compileResult.errors.length > 0) { + emitCompileErrorEvents( + compileResult.errors.map((e) => ({ formatted: e.message, raw: e as unknown as never })), + (msg, level, compileError) => emit({ stage: 'arduino-compile', message: msg, level, compileError }), + ) + } + return bailError(emit, 'arduino-compile', 'Arduino compilation failed', compileResult.errors) + } + + // Simulator: return the hex bytes for the caller to load into avr8js. + if (isSimulator) { + emit({ stage: 'done', message: 'Simulator firmware ready', level: 'info' }) + return { success: true, md5, binary: compileResult.binary, uploaded: false } + } + + // Compile-only: skip the physical upload step. + if (compileOnly) { + emit({ stage: 'done', message: 'Compile only mode — skipping upload to Arduino board.', level: 'info' }) + return { success: true, md5, binary: compileResult.binary, uploaded: false } + } + + // Physical Arduino direct upload. Web no-ops (web doesn't target + // physical Arduinos directly). + if (!deviceContext) { + emit({ + stage: 'upload', + message: 'Arduino board not configured (no device context). Skipping upload.', + level: 'warning', + }) + return { success: true, md5, binary: compileResult.binary, uploaded: false } + } + emit({ stage: 'upload', message: 'Uploading firmware to Arduino board...', level: 'info' }) + const uploadResult = await port.uploadArduinoBoard( + { + compilationPath: '', + fqbn: typeof boardEntry.platform === 'string' ? boardEntry.platform : '', + // User-selected serial port from the device-board UI picker. + // Forwarded verbatim; the adapter decides what to do if the + // caller didn't supply one (editor: fall back to the disk- + // persisted value in `devices/configuration.json`). + port: communicationPort ?? '', + }, + makePlatformLog(emit, 'upload'), + ) + if (!uploadResult.ok) { + return bailError(emit, 'upload', 'Failed to upload to Arduino board.', uploadResult.errors) + } + + emit({ stage: 'done', message: 'Arduino upload complete.', level: 'info' }) + return { success: true, md5, binary: compileResult.binary, uploaded: true } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Derive the arduino-cli core id from a fully-qualified board name. + * + * `'arduino:avr:mega'` → `'arduino:avr'` + * `'arduino:samd:zero'` → `'arduino:samd'` + * + * Used to pass the core id to `port.installArduinoCore`. On web + * (where install is a no-op) this never actually drives anything; + * on editor it gates arduino-cli's lazy install. + */ +function deriveArduinoCoreFromPlatform(platform: string): string { + const parts = platform.split(':') + if (parts.length < 2) return '' + return `${parts[0]}:${parts[1]}` +} diff --git a/src/backend/shared/compile/steps/compose-firmware-bundle.ts b/src/backend/shared/compile/steps/compose-firmware-bundle.ts new file mode 100644 index 000000000..cb43fa491 --- /dev/null +++ b/src/backend/shared/compile/steps/compose-firmware-bundle.ts @@ -0,0 +1,165 @@ +/** + * Compose the simulator / Arduino firmware compile bundle. + * + * Canonical, pure function that assembles every file `arduino-cli + * compile` needs to see: the firmware skeleton (`Baremetal.ino`, + * Arduino-core headers, simulator HAL), strucpp's emitted artefacts, + * the `c_blocks.h` / `c_blocks_code.cpp` pair, and `defines.h`. + * + * **Single source of truth for the firmware-compile file layout.** + * Both repos used to assemble this layout independently — editor via + * scattered `writeFile` calls across `copyStaticFiles` / + * `handleGenerateCBlocksHeader` / `handleGenerateCBlocksCode` / + * `handleGenerateDefinitionsFile` / `handleGenerateArduinoCppFile`, + * web via inline assembly into `arduinoFiles` in the simulator + * branch. The duplication drifted (the C/C++ POU bug that surfaced + * recently was a symptom). Routing both through this composer + * makes drift impossible: change a key here and both platforms + * pick it up. + * + * Side effects: none. No disk I/O, no HTTP, no DOM. Inputs in, + * `Record` out. The caller (editor: + * `compiler-module.ts`'s `compileArduino` flow; web: + * `compiler-adapter.ts`'s simulator branch) decides what to do + * with the result — editor writes each entry to disk under + * `build//`, web POSTs the whole map to the centralised + * compiler service. + * + * Input shape mirrors `composeRuntimeV4Bundle` so the two + * composers feel symmetric and a future shared pipeline can flow + * from one to the other without re-deriving inputs. + */ + +import type { CppPouData as CppPouDataCode } from '../../utils/cpp/generateCBlocksCode' +import { generateCBlocksCode } from '../../utils/cpp/generateCBlocksCode' +import type { CppPouData as CppPouDataHeader } from '../../utils/cpp/generateCBlocksHeader' +import { generateCBlocksHeader } from '../../utils/cpp/generateCBlocksHeader' + +export interface ComposeFirmwareBundleInput { + /** Strucpp emitted artefacts (key = filename at zip root, value + * = file content). Same map `runProgramBuildPipeline` returns: + * `generated.cpp`, `generated.hpp`, `generated_debug.cpp`, + * `debug-map.json`, per-POU `*.cpp` splits, `program.st.map.json`. */ + strucppFiles: Record + /** Pre-rendered C blocks artefacts. See `composeRuntimeV4Bundle` + * for the same contract: + * - `header`: required. Empty / no-cpp projects pass + * `'// Empty file\n'` (the static stub from + * `src/assets/firmware/arduino/c_blocks.h`). + * - `code`: pass `null` when the project has no C/C++ POUs + * so the composer leaves the static baseline at + * `examples/Baremetal/c_blocks_code.cpp` alone. Otherwise + * pass `generateCBlocksCode(originalCppPous)` and the + * static file gets overwritten with the user-facing version. */ + cBlocks: { + header: string + code: string | null + } + /** Pre-authored `defines.h` content. Caller invokes the shared + * `generateDefinesContent` to produce this — the composer keeps + * it opaque so a future re-shaping of `defines.h` content + * doesn't ripple through. */ + definesH: string + /** Firmware skeleton: the bundled set of base files arduino-cli + * needs but the user doesn't see (`Baremetal.ino`, the Arduino + * HAL, strucpp runtime headers, simulator HAL adapter). Each + * platform provides this differently — editor copies from + * `resources/sources/...` at build time, web reads from + * `src/assets/firmware/...` via Vite's `import.meta.glob`. The + * contents should be byte-identical between repos (enforced by + * the Shared Surface Sync CI check). */ + firmwareSkeleton: Record +} + +/** + * Per-POU header metadata used by `generateCBlocksHeader`. Re-exported + * so callers (and tests) don't have to chase the original module to + * understand the shape they're passing through. + */ +export type CBlocksHeaderPou = CppPouDataHeader +/** Per-POU code metadata used by `generateCBlocksCode`. */ +export type CBlocksCodePou = CppPouDataCode + +/** + * Helper for the common "I have `originalCppPous`, give me the + * `cBlocks` input shape" case. Caller can either use this or hand + * the composer the pre-rendered strings directly. + */ +export function buildCBlocksFromPous(originalCppPous: CppPouDataCode[]): ComposeFirmwareBundleInput['cBlocks'] { + if (originalCppPous.length === 0) { + // Editor's behaviour: leave the static `c_blocks.h` baseline + // in place (`null` here means the composer skips the write). + // Static `c_blocks_code.cpp` likewise stays untouched. + return { header: '// Empty file\n', code: null } + } + const headers: CppPouDataHeader[] = originalCppPous.map((pou) => ({ + name: pou.name, + variables: pou.variables, + })) + return { + header: generateCBlocksHeader(headers), + code: generateCBlocksCode(originalCppPous), + } +} + +/** + * Assemble the firmware file tree. + * + * Layout produced (paths relative to project root): + * - `examples/Baremetal/Baremetal.ino` — from skeleton + * - `examples/Baremetal/c_blocks_code.cpp` — overwritten when `cBlocks.code !== null` + * - `examples/Baremetal/modules/...` — from skeleton (Arduino library helpers) + * - `src/arduino.cpp` — from skeleton (HAL adapter, simulator-specific) + * - `src/c_blocks.h` — written verbatim from `cBlocks.header` + * - `src/defines.h` — written verbatim from `definesH` + * - `src/` — every key from `strucppFiles` + * - `src/.hpp` — from skeleton (strucpp runtime headers) + * - other skeleton entries — passed through verbatim + * + * Ordering: skeleton first, then overwrites. Strucpp output + * overwrites any same-named skeleton file (strucpp generally adds + * new files; collisions are intentional when they happen). + * `c_blocks.h` and `defines.h` overwrite the skeleton's static + * stubs. `c_blocks_code.cpp` is overwritten ONLY when the project + * has C/C++ POUs — otherwise the static baseline stays. + */ +export function composeFirmwareBundle(input: ComposeFirmwareBundleInput): Record { + const { strucppFiles, cBlocks, definesH, firmwareSkeleton } = input + + // Skeleton first (every Baremetal.ino, arduino HAL, strucpp + // runtime header, etc.). Subsequent overwrites replace specific + // entries. + const files: Record = { ...firmwareSkeleton } + + // Strucpp output lands under `src/` alongside the runtime glue + // — arduino-cli's `--library src` pass picks every TU there into + // libsketch. + for (const [filename, content] of Object.entries(strucppFiles)) { + files[`src/${filename}`] = content + } + + // C blocks header always overwrites: empty projects pass + // `'// Empty file\n'`; non-empty pass the strucpp-friendly + // declarations. The static baseline in the skeleton at + // `src/c_blocks.h` is harmless either way — this overwrite + // resolves which one wins. + files['src/c_blocks.h'] = cBlocks.header + + // C blocks code overwrites ONLY when the project has C/C++ POUs. + // For empty projects, the firmware skeleton's static + // `examples/Baremetal/c_blocks_code.cpp` baseline stays (it's + // a benign empty unit per the editor's emission, providing + // helpers the runtime expects regardless of user code). + if (cBlocks.code !== null) { + files['examples/Baremetal/c_blocks_code.cpp'] = cBlocks.code + } + + // defines.h is the authored output of the shared + // `generateDefinesContent` helper. Always overwrites — the + // skeleton ships a stub but this replaces it with the + // project-specific content (board defines, PROGRAM_MD5, IO Config, + // library toggles). + files['src/defines.h'] = definesH + + return files +} diff --git a/src/backend/shared/compile/steps/generate-confs.ts b/src/backend/shared/compile/steps/generate-confs.ts new file mode 100644 index 000000000..d9b7ea768 --- /dev/null +++ b/src/backend/shared/compile/steps/generate-confs.ts @@ -0,0 +1,151 @@ +/** + * Author the runtime v4 conf/* JSON strings (Modbus slave + master, + * S7Comm, OPC-UA, EtherCAT) from a project's `servers`, + * `remoteDevices`, and program metadata. + * + * Editor-canonical behaviour — every output string is byte-identical + * to what the editor's `compileProgram` used to author inline in the + * runtime-v4 branch. Each conf is opaque to the + * `composeRuntimeV4Bundle` step (which just slots them into + * `conf/.json`), so byte-identical here means byte-identical + * uploads to the runtime, which means the v4 runtime sees the same + * conf files regardless of which editor produced the program. + * + * Why this lives in shared: + * - The atomic generators (`generateModbusSlaveConfig`, + * `generateModbusMasterConfig`, `generateS7CommConfig`, + * `generateOpcUaConfig`, `generateEthercatConfig`, + * `validateEthercatConfig`) are already shared. + * - The orchestration (error-handling for OPC-UA, validation gate + * for EtherCAT, the specific log-message strings) used to live + * in two places: editor's `compiler-module.ts` and web's + * `compiler-adapter.ts`. Drift between them surfaces as + * inconsistent error UX (one platform fails fast, the other + * silently produces a bad config). This module is the single + * place where that orchestration lives. + * + * Pure function: no fs I/O, no DOM, no global state. Caller passes + * the project's `servers` / `remoteDevices`, the OPC-UA + * resolver-input (`debugMapContent` + `instances`), and a log + * callback that maps shared-pipeline progress events to the + * platform's native log channel. + */ + +// Frontend/utils imports — pure data transformers, Node-safe (no +// DOM dependencies). Same legacy-tree path the editor's compiler- +// module.ts uses; flagged as an organisational smell (`backend-shared +// → frontend`) but functional, see the build-pipeline refactor plan. +import { getErrorMessage } from '../../../../frontend/utils/get-error-message' +import { generateModbusSlaveConfig } from '../../../../frontend/utils/modbus/generate-modbus-slave-config' +import { generateOpcUaConfig, OpcUaConfigError } from '../../../../frontend/utils/opcua' +import { generateS7CommConfig } from '../../../../frontend/utils/s7comm' +import { generateEthercatConfig } from '../../ethercat/generate-ethercat-config' +import { validateEthercatConfig } from '../../ethercat/validate-ethercat-config' +import type { PLCRemoteDevice, PLCServer } from '../../types/PLC/open-plc' +import { generateModbusMasterConfig } from '../../utils/modbus/generate-modbus-master-config' + +/** + * Tagged shape for the program instances OPC-UA needs to resolve + * `%I/%Q/%M` addresses against the runtime's variable table. Matches + * the editor's `projectData.configuration.resource.instances` slice. + */ +export interface OpcUaInstance { + name: string + task: string + program: string +} + +export interface GenerateConfsInput { + /** Project's `data.servers` — Modbus slave, S7Comm, OPC-UA all + * read from this. */ + servers: PLCServer[] | undefined + /** Project's `data.remoteDevices` — Modbus master + EtherCAT read + * from this. */ + remoteDevices: PLCRemoteDevice[] | undefined + /** Program instances (mapped from `projectData.configuration.resource. + * instances`). OPC-UA resolver uses this to bind addresses to + * the program that owns them. */ + instances: OpcUaInstance[] + /** Strucpp's `debug-map.json` content (NOT `generated_debug.cpp`). + * OPC-UA's `parseDebugMap` reads this to resolve `%I/%Q/%M`. + * Caller pulls it out of the strucpp emitted-files map. */ + debugMapContent: string + /** Log channel. OPC-UA generation emits informational progress + * via this; error logs go here too before the relevant errors + * are rethrown. Each adapter wires its native log channel + * through this callback. */ + log: (message: string, level: 'info' | 'error') => void +} + +/** + * Each output is the JSON string that lands at `conf/.json` in + * the runtime v4 bundle, or `null` when the project has no config of + * that type (the composer skips the file in that case). + */ +export interface GenerateConfsOutput { + modbusSlave: string | null + modbusMaster: string | null + s7Comm: string | null + opcUa: string | null + /** EtherCAT is gated on `validateEthercatConfig`; this function + * throws BEFORE returning when validation fails. Successful + * return guarantees either `null` (no devices) or a string that + * passed validation. */ + ethercat: string | null +} + +/** + * Errors: + * - `OpcUaConfigError` (from `generateOpcUaConfig`) is logged with + * `OPC-UA Configuration Error:` prefix and rethrown. Other + * errors from OPC-UA generation are logged as `Failed to + * generate OPC-UA config:` and rethrown. The rethrow stops the + * caller's pipeline — same gate the editor's compileProgram + * runtime-v4 try/catch uses. + * - EtherCAT validation errors throw `Error('EtherCAT + * configuration is invalid: ')` without logging + * here; caller surfaces the error message in its own catch. + * + * Both error paths abort BEFORE the composer runs — matching the + * editor's "fail fast" gate. + */ +export function generateRuntimeConfs(input: GenerateConfsInput): GenerateConfsOutput { + const { servers, remoteDevices, instances, debugMapContent, log } = input + + // Modbus slave / master / S7Comm: pure helpers, no I/O. Each + // returns `null` when the project has no config of that type. + // Type assertions match the editor's call sites — the generators + // accept a narrower shape than `PLCServer[]` / `PLCRemoteDevice[]` + // but the runtime values are compatible. + const modbusSlave = generateModbusSlaveConfig(servers as Parameters[0]) + const modbusMaster = generateModbusMasterConfig(remoteDevices as Parameters[0]) + const s7Comm = generateS7CommConfig(servers) + + // OPC-UA: throws `OpcUaConfigError` on invalid project state. + // Editor logs the error message with a specific prefix BEFORE + // rethrowing so the user sees the diagnostic in the compile log + // even if the outer pipeline catches and short-circuits. + let opcUa: string | null = null + try { + opcUa = generateOpcUaConfig(servers, debugMapContent, instances, (msg) => log(msg, 'info')) + } catch (error) { + if (error instanceof OpcUaConfigError) { + log(`OPC-UA Configuration Error:\n${error.message}`, 'error') + } else { + log(`Failed to generate OPC-UA config: ${getErrorMessage(error)}`, 'error') + } + throw error + } + + // EtherCAT: validate BEFORE returning so a bad config aborts the + // compile before the composer runs. Validation failures throw a + // plain `Error` — caller's try/catch wraps it with the runtime-v4 + // "Stopping compilation process" log line, matching the editor. + const ethercat = generateEthercatConfig(remoteDevices) + const ethercatErrors = validateEthercatConfig(ethercat) + if (ethercatErrors.length > 0) { + throw new Error(`EtherCAT configuration is invalid: ${ethercatErrors.join('; ')}`) + } + + return { modbusSlave, modbusMaster, s7Comm, opcUa, ethercat } +} diff --git a/src/backend/shared/compile/steps/generate-defines.ts b/src/backend/shared/compile/steps/generate-defines.ts new file mode 100644 index 000000000..65dd0b4d1 --- /dev/null +++ b/src/backend/shared/compile/steps/generate-defines.ts @@ -0,0 +1,214 @@ +/** + * Author the `defines.h` content for an OpenPLC build target. + * + * Editor-canonical behavior — every byte of the output matches what + * the editor's `compiler-module.ts` `handleGenerateDefinitionsFile` + * used to emit directly to disk. Lifted into shared so both repos' + * build pipelines author the same `defines.h` from the same inputs: + * the OpenPLC runtime keys off `PROGRAM_MD5` to detect stale programs, + * and the per-board `USE_*_BLOCK` defines gate which Arduino libraries + * the firmware links against. Drift here would manifest as either + * the runtime refusing the program (MD5 mismatch) or undefined-symbol + * link errors for blocks the program references. + * + * Pure function: no fs I/O, no DOM, no global state. Caller writes + * the returned string to `defines.h` at the platform-appropriate + * location (editor: `build//src/defines.h`; web: bundled + * into the in-memory file map sent to `/compile-arduino`). + */ + +import type { DevicePin } from '../../types/PLC/devices' + +/** + * Slice of a `hals.json` board entry we read here. Defined inline + * rather than reusing one of the existing repo-specific Hals types + * so this module stays free of editor-only types (`HalsFile` lives + * in `src/backend/editor/...`) and the web's slightly different + * `BoardHalsCompileEntry`. Both shapes carry the optional `define` + * field; we only need that. + */ +export interface BoardHalsDefinesEntry { + /** Per-board #defines. Can be a single string (`"BOARD_ID=42"`) + * or an array of strings. Each entry is emitted verbatim as + * `#define ` so callers control the value-vs-flag form. */ + define?: string | string[] +} + +export interface GenerateDefinesInput { + /** The board entry from `hals.json` for the current target. + * `undefined` when the board has no entry (defensive — should + * not happen in a real build). When present and carrying a + * `define` field, those become the "Board defines" section. */ + boardEntry?: BoardHalsDefinesEntry | undefined + /** Pin mappings parsed from `devices/pin-mapping.json`. The + * generator filters by `pinType` and emits one PINMASK per + * category (`DIN` / `AIN` / `DOUT` / `AOUT`) plus matching + * count defines (`NUM_DISCRETE_INPUT` etc.). */ + devicePinMapping: DevicePin[] + /** Concatenated ST program content (the output of xml2st). + * Scanned with `String.prototype.includes` for the marker + * function-block names that toggle the Arduino-library + * `USE_*_BLOCK` defines. The set of marker strings here is + * the canonical list — every change MUST match what the + * firmware's HAL headers `#ifdef`-gate on. */ + stProgramFileContent: string + /** MD5 hash of `program.st` bytes — embedded as `PROGRAM_MD5` so + * the runtime can detect a stale upload (a v4 runtime reports + * this back on `FC 0x45` and the debugger uses it to confirm + * the layout it's reading matches the program it last + * uploaded). */ + buildMD5Hash: string + /** Runtime identifier from `hals.json` (`'simulator'` / + * `'arduino-cli'` / `'openplc-compiler'`). Only `'simulator'` + * changes the output here — it adds the SIMULATOR_MODE + + * fixed-Modbus block, which the avr8js emulator's serial + * bridge keys off. Real Arduino targets emit comms defines + * via VPP packages instead. */ + boardRuntime: string +} + +/** + * Build the contents of `defines.h`. + * + * Output sections, in order: + * 1. `// Board defines` — only when `boardEntry.define` is present. + * 2. `#define PROGRAM_MD5 ""` — always. + * 3. `// Comms Configuration` (simulator-only) — fixed Modbus RTU + * over emulated USART0 so avr8js's serial bridge can drive + * Modbus traffic into the running emulator. + * 4. `// IO Config` — PINMASK_{DIN,AIN,DOUT,AOUT} + NUM_* derived + * from `devicePinMapping`. + * 5. `// Arduino libraries` — `USE_*_BLOCK` toggles gated on FB + * names appearing in the ST source. + * + * The output is plain C preprocessor text terminated with newlines + * matching the editor's emission exactly (so a byte-diff between + * editor-produced and web-produced firmware comes out clean). + */ +export function generateDefinesContent(input: GenerateDefinesInput): string { + const { boardEntry, devicePinMapping, stProgramFileContent, buildMD5Hash, boardRuntime } = input + + let DEFINES_CONTENT = '' + + // 1. Board defines from hals.json. Single-string and array forms + // both supported; absent `define` field means no Board defines + // section header at all (the next section starts directly). + if (boardEntry && boardEntry.define) { + DEFINES_CONTENT = '// Board defines\n' + if (Array.isArray(boardEntry.define)) { + boardEntry.define.forEach((define) => { + DEFINES_CONTENT += `#define ${define}\n` + }) + } else if (typeof boardEntry.define === 'string') { + DEFINES_CONTENT += `#define ${boardEntry.define}\n` + } + } + + // 2. Trailing blank-line pair after the board-defines section + // (or at the top of the file when board defines were absent — + // intentional so the PROGRAM_MD5 block always lands two blank + // lines below whatever preceded it, matching editor). + DEFINES_CONTENT += '\n\n' + + // 3. Program MD5 — load-bearing for the runtime's stale-program + // detection. Always emitted. + DEFINES_CONTENT += '//Program MD5\n' + DEFINES_CONTENT += `#define PROGRAM_MD5 "${buildMD5Hash}"` + DEFINES_CONTENT += `\n\n` + + // 4. Simulator-only Comms Configuration. Real Arduino targets + // emit comms defines via their VPP packages (or returned + // silently when communicationConfigurationSchema was removed + // — see the editor history); only the simulator still emits + // them from the core compiler because there's no VPP wrapping + // the emulator HAL. + if (boardRuntime === 'simulator') { + DEFINES_CONTENT += '//Comms Configuration\n' + DEFINES_CONTENT += '#define SIMULATOR_MODE\n' + DEFINES_CONTENT += '#define MBSERIAL_IFACE Serial\n' + DEFINES_CONTENT += '#define MBSERIAL_BAUD 115200\n' + DEFINES_CONTENT += '#define MBSERIAL_SLAVE 1\n' + DEFINES_CONTENT += '#define MBSERIAL\n' + DEFINES_CONTENT += '#define MODBUS_ENABLED\n' + DEFINES_CONTENT += `\n\n` + } + + // 5. IO Config — derived from devicePinMapping. Pin order is + // the iteration order of the input array; callers are + // expected to have sorted by address. + DEFINES_CONTENT += '//IO Config\n' + const digitalInputPins = devicePinMapping.filter((pin) => pin.pinType === 'digitalInput') + const analogInputPins = devicePinMapping.filter((pin) => pin.pinType === 'analogInput') + const digitalOutputPins = devicePinMapping.filter((pin) => pin.pinType === 'digitalOutput') + const analogOutputPins = devicePinMapping.filter((pin) => pin.pinType === 'analogOutput') + + DEFINES_CONTENT += `#define PINMASK_DIN ${digitalInputPins.map(({ pin }) => pin).join(', ')}\n` + DEFINES_CONTENT += `#define PINMASK_AIN ${analogInputPins.map(({ pin }) => pin).join(', ')}\n` + DEFINES_CONTENT += `#define PINMASK_DOUT ${digitalOutputPins.map(({ pin }) => pin).join(', ')}\n` + DEFINES_CONTENT += `#define PINMASK_AOUT ${analogOutputPins.map(({ pin }) => pin).join(', ')}\n` + + DEFINES_CONTENT += `#define NUM_DISCRETE_INPUT ${digitalInputPins.length}\n` + DEFINES_CONTENT += `#define NUM_ANALOG_INPUT ${analogInputPins.length}\n` + DEFINES_CONTENT += `#define NUM_DISCRETE_OUTPUT ${digitalOutputPins.length}\n` + DEFINES_CONTENT += `#define NUM_ANALOG_OUTPUT ${analogOutputPins.length}\n` + DEFINES_CONTENT += `\n\n` + + // 6. Arduino libraries — toggled on FB names appearing in the ST. + // The marker-string set here is the canonical list; the + // firmware HAL headers `#ifdef`-gate `#include` directives off + // these names, so adding a marker here without a matching + // HAL change is harmless, but adding a HAL gate without the + // matching marker here silently breaks link. + DEFINES_CONTENT += '//Arduino libraries\n' + + if ( + stProgramFileContent.includes('DS18B20;') || + stProgramFileContent.includes('DS18B20_2_OUT;') || + stProgramFileContent.includes('DS18B20_3_OUT;') || + stProgramFileContent.includes('DS18B20_4_OUT;') || + stProgramFileContent.includes('DS18B20_5_OUT;') + ) { + DEFINES_CONTENT += '#define USE_DS18B20_BLOCK\n' + } + + if (stProgramFileContent.includes('P1AM_INIT;')) DEFINES_CONTENT += '#define USE_P1AM_BLOCKS\n' + + if (stProgramFileContent.includes('CLOUD_BEGIN;')) DEFINES_CONTENT += '#define USE_CLOUD_BLOCKS\n' + + if (stProgramFileContent.includes('MQTT_CONNECT;') || stProgramFileContent.includes('MQTT_CONNECT_AUTH;')) + DEFINES_CONTENT += '#define USE_MQTT_BLOCKS\n' + + if ( + stProgramFileContent.includes('ARDUINOCAN_CONF;') || + stProgramFileContent.includes('ARDUINOCAN_WRITE;') || + stProgramFileContent.includes('ARDUINOCAN_WRITE_WORD;') || + stProgramFileContent.includes('ARDUINOCAN_READ;') + ) { + DEFINES_CONTENT += '#define USE_ARDUINOCAN_BLOCK\n' + } + + if ( + stProgramFileContent.includes('STM32CAN_CONF;') || + stProgramFileContent.includes('STM32CAN_WRITE;') || + stProgramFileContent.includes('STM32CAN_READ;') + ) { + DEFINES_CONTENT += '#define USE_STM32CAN_BLOCK\n' + } + + if ( + stProgramFileContent.includes('SM_8RELAY;') || + stProgramFileContent.includes('SM_16RELAY;') || + stProgramFileContent.includes('SM_8DIN;') || + stProgramFileContent.includes('SM_16DIN;') || + stProgramFileContent.includes('SM_4REL4IN;') || + stProgramFileContent.includes('SM_INDUSTRIAL;') || + stProgramFileContent.includes('SM_RTD;') || + stProgramFileContent.includes('SM_BAS;') || + stProgramFileContent.includes('SM_HOME;') || + stProgramFileContent.includes('SM_8MOSFET;') + ) { + DEFINES_CONTENT += '#define USE_SM_BLOCKS\n' + } + + return DEFINES_CONTENT +} diff --git a/src/backend/shared/compile/steps/merge-strucpp-runtime-into-skeleton.ts b/src/backend/shared/compile/steps/merge-strucpp-runtime-into-skeleton.ts new file mode 100644 index 000000000..c381e0830 --- /dev/null +++ b/src/backend/shared/compile/steps/merge-strucpp-runtime-into-skeleton.ts @@ -0,0 +1,71 @@ +/** + * Re-key the strucpp runtime headers from their v4-bundle layout + * (`strucpp_runtime/include/`) into the flat `src/` + * layout the Arduino-cli firmware build expects. + * + * Both platforms used to do the same inline rewrite at the entry to + * `compileProgram` (editor: against bytes loaded from disk; web: + * against bytes loaded via Vite's `import.meta.glob`). The merge + * itself is pure key transformation — pulling it into shared lets a + * future tweak (e.g. a new strucpp header set) update both repos at + * once. + * + * For the runtime-v4 target the headers stay at their canonical + * `strucpp_runtime/include/` layout because `composeRuntimeV4Bundle` + * forwards them verbatim into the upload zip — there's no skeleton + * merge to do. Callers branch on `isRuntimeV4` before invoking this + * helper. + * + * Pure: no I/O. Input bytes are platform-loaded by the caller; output + * is a new merged file map the pipeline consumes. + */ + +export interface MergeStrucppRuntimeArgs { + /** Arduino-cli firmware skeleton — bundled `Baremetal.ino`, + * arduino HAL headers, etc. Keys are paths relative to the + * project's build directory (e.g. + * `examples/Baremetal/Baremetal.ino`, `src/openplc.h`). */ + firmwareSkeleton: Record + /** Strucpp runtime headers keyed at `strucpp_runtime/include/` + * — same layout the v4 bundle composer feeds out. This helper + * re-keys each entry to `src/` so arduino-cli's + * `--library src` pass picks them up. */ + strucppRuntimeHeaders: Record + /** Optional board-specific HAL adapter (`hardwareInit`, + * `updateInputBuffers`, `updateOutputBuffers` — the link target + * for `arduino_runtime_glue.cpp`). Editor reads this from + * `resources/sources/hal/` per-board; web + * bundles a single simulator HAL into `getFirmwareFiles()` + * already, so it doesn't need this override. + * + * When present, the helper writes the content at the canonical + * `src/arduino.cpp` path inside the merged skeleton — matching + * the path the editor's pre-refactor `handleGenerateArduinoCppFile` + * step used. */ + boardHalContent?: string +} + +/** + * Merge `strucppRuntimeHeaders` (keyed at `strucpp_runtime/include/`) + * into `firmwareSkeleton` at `src/`. Returns the merged file map; + * doesn't mutate `firmwareSkeleton`. + * + * Existing entries in `firmwareSkeleton` at `src/
` are + * overwritten by the runtime header — strucpp's headers are the + * canonical source for those filenames. The optional + * `boardHalContent` overwrites any existing `src/arduino.cpp` + * (web's simulator HAL is dropped in favour of the editor's + * per-board variant when both are present). + */ +export function mergeStrucppRuntimeIntoSkeleton(args: MergeStrucppRuntimeArgs): Record { + const merged: Record = { ...args.firmwareSkeleton } + for (const [v4Key, content] of Object.entries(args.strucppRuntimeHeaders)) { + const filename = v4Key.split('/').pop() + if (!filename) continue + merged[`src/${filename}`] = content + } + if (typeof args.boardHalContent === 'string' && args.boardHalContent.length > 0) { + merged['src/arduino.cpp'] = args.boardHalContent + } + return merged +} diff --git a/src/backend/shared/compile/steps/resolve-board-selection.ts b/src/backend/shared/compile/steps/resolve-board-selection.ts new file mode 100644 index 000000000..f939843e9 --- /dev/null +++ b/src/backend/shared/compile/steps/resolve-board-selection.ts @@ -0,0 +1,86 @@ +/** + * Resolve the user's selected board to the canonical pipeline inputs + * derived from `hals.json`. + * + * Both platforms hand `runCompilePipeline` the same five fields it + * needs to branch on the target — `boardEntry`, `boardRuntime`, + * `isSimulator`, `isRuntimeV4`, `isRuntimeV3` — and both used to do + * the lookup + flag derivation inline at the entry to `compileProgram`, + * duplicated character-for-character. Centralising it here keeps the + * branching logic on one side of the platform boundary so a future + * tweak (e.g. introducing a new runtime kind) doesn't risk diverging + * editor and web. + * + * Pure: no I/O. Caller is responsible for loading `hals.json` — + * editor reads it off disk, web bundles it via Vite's + * `import.meta.glob`. The file's content is byte-identical between + * the two repos (Shared Surface Sync gate). + * + * Returns either the resolved selection or an `error` discriminator + * with a human-readable message the renderer can surface verbatim. + */ + +/** + * Subset of a `hals.json` entry this resolver inspects. Kept narrow + * so test fixtures can construct an entry without dragging through + * every field downstream code consumes. The full entry shape lives + * in `backend/shared/firmware/build-arduino-cli-args.ts`. + */ +export interface HalsEntryForSelection { + /** Runtime identifier — `'simulator'` (avr8js), `'arduino-cli'` + * (direct Arduino board), `'openplc-compiler'` (OpenPLC v4 vPLC). */ + compiler?: string +} + +export type ResolvedBoardSelection = + | { + ok: true + boardEntry: HalsEntryForSelection & Record + boardRuntime: string + isSimulator: boolean + isRuntimeV4: boolean + isRuntimeV3: boolean + } + | { ok: false; error: string } + +/** + * Look up `boardTarget` in `halsContent` and derive the four + * mutually-exclusive runtime flags the pipeline branches on. + * + * - `isRuntimeV3` is decided purely by the boardTarget string + * (legacy runtime is a special "OpenPLC Runtime v3" key — no + * `compiler` field would let it overlap with v4 otherwise). + * - `isRuntimeV4` is derived from `compiler === 'openplc-compiler'` + * AND NOT v3 — the v4 vPLC and the legacy v3 daemon share the + * `openplc-compiler` field on disk for historical reasons. + * - `isSimulator` is the in-browser avr8js path + * (`compiler === 'simulator'`). + * - The Arduino direct-board path is the residual: not v3, not v4, + * not simulator. + */ +export function resolveBoardSelection( + halsContent: Record>, + boardTarget: string, +): ResolvedBoardSelection { + const boardEntry = halsContent[boardTarget] + if (!boardEntry) { + return { + ok: false, + error: `hals.json is missing the "${boardTarget}" entry — bundled asset is out of sync.`, + } + } + + const boardRuntime = typeof boardEntry.compiler === 'string' ? boardEntry.compiler : '' + const isRuntimeV3 = boardTarget === 'OpenPLC Runtime v3' + const isRuntimeV4 = boardRuntime === 'openplc-compiler' && !isRuntimeV3 + const isSimulator = boardRuntime === 'simulator' + + return { + ok: true, + boardEntry, + boardRuntime, + isSimulator, + isRuntimeV4, + isRuntimeV3, + } +} diff --git a/src/backend/shared/firmware/hals-loader.ts b/src/backend/shared/firmware/hals-loader.ts new file mode 100644 index 000000000..9936f7d17 --- /dev/null +++ b/src/backend/shared/firmware/hals-loader.ts @@ -0,0 +1,55 @@ +/** + * Shared hals.json loader. + * + * `hals.json` is the canonical board catalogue both editor and web + * consume — the file lives next to this module (`./hals.json`) and + * is byte-identical between repos (Shared Surface Sync gate + * enforces it). This loader is the single import surface so all + * call sites get the same parsed object, and so swapping the + * physical location (bundling rules, packaging quirks) only needs + * a one-line change here. + * + * Returns the parsed object via a `Promise` for compatibility with + * the legacy `readJSONFile` async callers that scattered across + * `compiler-module.ts` / `hardware-module.ts` — those calls become + * `await readHalsFile()` with no further restructure. The promise + * resolves synchronously off the bundled module — there is no + * actual I/O. + * + * Module is intentionally named `hals-loader.ts` (not `hals.ts`): + * editor's webpack `resolve.extensions` lists `.json` before `.ts`, + * so an extensionless import of `'.../firmware/hals'` was resolving + * to `hals.json` and `readHalsFile` ended up `undefined` at + * runtime — empty board list, board-settings screen blank. Keeping + * the loader's basename distinct from the data file's basename + * sidesteps that resolution clash for both webpack and Vite. + */ + +import halsContent from './hals.json' + +/** + * Untyped raw content from `hals.json`. Consumers cast to the + * platform-specific `HalsFile` (editor: `backend/editor/hardware/ + * types.ts`; web: same shape under bundled types) — both platforms + * point their schema at this same file, so the cast lands in the + * same place. + */ +export type RawHalsContent = typeof halsContent + +/** + * Async-shaped helper for legacy call sites built around + * `readJSONFile(halsFilePath)`. Resolves synchronously off the + * imported JSON; safe to call repeatedly. + */ +export function readHalsFile(): Promise { + return Promise.resolve(halsContent as unknown as T) +} + +/** + * Synchronous accessor for new call sites that don't need to thread + * a promise. Use this when the caller is already synchronous + * (e.g. test fixtures, pipeline-arg construction). + */ +export function getHalsFile(): T { + return halsContent as unknown as T +} diff --git a/resources/sources/boards/hals.json b/src/backend/shared/firmware/hals.json similarity index 100% rename from resources/sources/boards/hals.json rename to src/backend/shared/firmware/hals.json diff --git a/src/backend/shared/library/__tests__/poll-runtime-compilation.test.ts b/src/backend/shared/library/__tests__/poll-runtime-compilation.test.ts index 8a3ba0e14..0c7c42fef 100644 --- a/src/backend/shared/library/__tests__/poll-runtime-compilation.test.ts +++ b/src/backend/shared/library/__tests__/poll-runtime-compilation.test.ts @@ -101,6 +101,42 @@ describe('pollRuntimeCompilation', () => { expect(entries.find((e) => e.message === 'no prefix')?.level).toBe('info') }) + it('classifies and strips the prefix even when the runtime appends a trailing newline', async () => { + // openplc-runtime appends "\n" to every entry it pushes into + // build_state.logs (see webserver/plcapp_management.py — every + // build_state.log(...) call passes a "\\n"-terminated f-string). + // Earlier we anchored the body match with `$`, which JS without + // the `m` flag refuses to match across a trailing newline; the + // classifier then bailed and routed everything as level='info' + // with the prefix preserved. Console rendered errors blue. + const { fetch } = scriptedFetch([ + { + status: 'SUCCESS', + logs: [ + "[ERROR] core/generated/c_blocks_code.cpp:97:1: error: 'asd' does not name a type\n", + '[WARNING] PLC program has not been updated because the build failed\n', + '[INFO] Compiling core/generated/pou_MAIN.cpp...\n', + '[DEBUG] update_plugin_configurations called\n', + ], + exit_code: 0, + }, + ]) + const entries: Array<{ level: string; message: string }> = [] + await pollRuntimeCompilation({ + fetchStatus: fetch, + onLog: (level, message) => entries.push({ level, message }), + pollIntervalMs: 1, + }) + const err = entries.find((e) => e.message.startsWith('core/generated/')) + expect(err?.level).toBe('error') + expect(err?.message).toBe("core/generated/c_blocks_code.cpp:97:1: error: 'asd' does not name a type") + expect(entries.find((e) => e.message.startsWith('PLC program'))?.level).toBe('warning') + expect(entries.find((e) => e.message.startsWith('Compiling'))?.level).toBe('info') + expect(entries.find((e) => e.message.startsWith('update_plugin'))?.level).toBe('debug') + // No stray "\n" at the end of any classified message. + expect(entries.every((e) => !/\n$/.test(e.message))).toBe(true) + }) + it('bails with ERROR after maxConsecutiveErrors failures', async () => { const { fetch, calls } = scriptedFetch([ { error: 'first' }, diff --git a/src/backend/shared/library/__tests__/probe-runtime-version.test.ts b/src/backend/shared/library/__tests__/probe-runtime-version.test.ts new file mode 100644 index 000000000..e090b5c77 --- /dev/null +++ b/src/backend/shared/library/__tests__/probe-runtime-version.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from '@jest/globals' + +import { probeRuntimeVersion } from '../probe-runtime-version' + +// `jest` is available as a global on both runners: jest provides it +// natively, vitest's setup shim aliases `globalThis.jest = vi`. We +// deliberately don't import it because `@jest/globals` resolves to +// the `vitest` package on web (vite alias), and vitest doesn't +// export a `jest` namespace — importing the symbol would crash the +// vitest run with "Cannot read properties of undefined". + +describe('probeRuntimeVersion', () => { + it('returns the version when the transport returns a body with a string `version` field', async () => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchVersion: async () => ({ success: true, body: { version: '4.1.2' } }), + log, + }) + expect(result).toEqual({ version: '4.1.2' }) + expect(log).not.toHaveBeenCalled() + }) + + it('surfaces an older runtime version verbatim so the gate can reject it', async () => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchVersion: async () => ({ success: true, body: { version: '4.0.5' } }), + log, + }) + expect(result).toEqual({ version: '4.0.5' }) + }) + + it('returns version=null and logs a warning when the transport fails', async () => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchVersion: async () => ({ success: false, error: 'ECONNREFUSED' }), + log, + }) + expect(result).toEqual({ version: null }) + expect(log).toHaveBeenCalledWith(expect.stringContaining('Could not reach runtime: ECONNREFUSED'), 'warning') + }) + + it('returns version=null + warns when the transport throws (Error instance)', async () => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchVersion: async () => { + throw new Error('orchestrator HTTP down') + }, + log, + }) + expect(result).toEqual({ version: null }) + expect(log).toHaveBeenCalledWith( + expect.stringContaining('Runtime version probe failed: orchestrator HTTP down'), + 'warning', + ) + }) + + it('returns version=null + warns when the transport throws a non-Error value', async () => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchVersion: async () => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 'plain string failure' + }, + log, + }) + expect(result).toEqual({ version: null }) + expect(log).toHaveBeenCalledWith(expect.stringContaining('plain string failure'), 'warning') + }) + + it('returns version=null when the body lacks a `version` field', async () => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchVersion: async () => ({ success: true, body: { otherField: 'noise' } }), + log, + }) + expect(result).toEqual({ version: null }) + expect(log).not.toHaveBeenCalled() + }) + + it('returns version=null when `version` is present but not a string', async () => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchVersion: async () => ({ success: true, body: { version: 4 } }), + log, + }) + expect(result).toEqual({ version: null }) + }) + + it('returns version=null when the body is null', async () => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchVersion: async () => ({ success: true, body: null }), + log, + }) + expect(result).toEqual({ version: null }) + }) + + it('returns version=null when the body is a primitive (not an object)', async () => { + const log = jest.fn() + const result = await probeRuntimeVersion({ + fetchVersion: async () => ({ success: true, body: 'a string' }), + log, + }) + expect(result).toEqual({ version: null }) + }) +}) diff --git a/src/backend/shared/library/poll-runtime-compilation.ts b/src/backend/shared/library/poll-runtime-compilation.ts index 1c7554f1e..20dc51d1d 100644 --- a/src/backend/shared/library/poll-runtime-compilation.ts +++ b/src/backend/shared/library/poll-runtime-compilation.ts @@ -62,14 +62,25 @@ const DEFAULT_MAX_CONSECUTIVE_ERRORS = 10 /** Strip `[LEVEL]` prefix from a runtime log line and route the * remainder at the matching level. Same convention editor's - * `parseLogLevel` follows. */ + * `parseLogLevel` follows. + * + * The runtime appends a trailing newline to every entry it pushes + * into `build_state.logs` (e.g. `build_state.log(f"[INFO] ...\\n")` + * in openplc-runtime's `plcapp_management.py`). We can't anchor + * the body match with `$` because JS without the `m` flag treats + * the trailing `\\n` as still inside the string and the anchor + * fails — historically that dropped every classified line back to + * `level: 'info'` with the prefix preserved, which is why error + * output rendered blue in the console. Anchor only the prefix + * and capture the rest by slicing, so trailing whitespace (or any + * control characters) doesn't break classification. */ function classifyLogLine(line: string): { level: RuntimeCompilationLogLevel; message: string } { - const m = line.match(/^\[(ERROR|WARN|WARNING|INFO|DEBUG)\]\s*(.*)$/i) - if (!m) return { level: 'info', message: line } + const m = line.match(/^\[(ERROR|WARN|WARNING|INFO|DEBUG)\]\s*/i) + if (!m) return { level: 'info', message: line.replace(/\r?\n$/, '') } const tag = m[1].toUpperCase() const level: RuntimeCompilationLogLevel = tag === 'ERROR' ? 'error' : tag === 'WARN' || tag === 'WARNING' ? 'warning' : tag === 'DEBUG' ? 'debug' : 'info' - return { level, message: m[2] } + return { level, message: line.slice(m[0].length).replace(/\r?\n$/, '') } } /** diff --git a/src/backend/shared/library/probe-runtime-version.ts b/src/backend/shared/library/probe-runtime-version.ts new file mode 100644 index 000000000..ecf1b3cfc --- /dev/null +++ b/src/backend/shared/library/probe-runtime-version.ts @@ -0,0 +1,98 @@ +/** + * Probe the openplc-runtime container's `/api/version` and return + * the canonical `{ version, error? }` shape the shared compile + * pipeline's strucpp-compatibility gate consumes. + * + * Single source of truth for the response-parsing + null-handling + * logic that used to live duplicated in editor's + * `editor-compiler-platform-port.ts:checkRuntimeVersion` and web's + * `web-compiler-platform-port.ts:checkRuntimeVersion`. Both + * platforms now call this helper with a transport-specific + * `fetchVersion()` callback; everything from "got a response" to + * "what version is this" lives here. + * + * The runtime container is the SAME openplc-runtime build on both + * platforms — editor reaches it over direct HTTPS, web reaches it + * via the orchestrator's `run-command` proxy. Both endpoints return + * a body shaped like `{ version: '4.1.x' }`; this helper extracts + * the field and surfaces it (or `null`) to the caller. + * + * Returns `version: null` on any failure path (transport error, + * missing field, parse failure) so the shared + * `isStrucppCompatibleRuntime` gate uniformly rejects "unknown" + * answers the same way it rejects pre-4.1.0 versions — matching the + * editor's pre-refactor behaviour where an unreachable runtime + * blocked the upload. + * + * Pure: no I/O. Caller supplies the transport via `fetchVersion`. + */ + +/** Outcome of the transport-level fetch. Adapters return this from + * their HTTPS / orchestrator round-trip; the shared helper takes + * it from here. */ +export type FetchVersionResult = + | { + /** Transport succeeded and parsed a JSON-ish body. The + * helper looks for a top-level `version` field of type + * `string`. */ + success: true + body: unknown + } + | { + /** Transport failed. `error` lands in the log line as the + * reachability diagnostic; `version` becomes `null`. */ + success: false + error: string + } + +export interface ProbeRuntimeVersionOptions { + /** Transport callback: editor uses Electron's HTTPS bridge to + * hit the device's `/api/version`; web POSTs to the + * orchestrator's `run-command` with `api: 'api/version'`. */ + fetchVersion(): Promise + /** Warning channel for diagnostics the user can see in the + * compile console (e.g. "Could not reach runtime: ECONNREFUSED"). + * Wired to the platform port's `log` callback by the caller so + * the message stays in the pipeline's event stream. */ + log(message: string, level: 'warning'): void +} + +export interface ProbeRuntimeVersionResult { + /** The runtime's reported version (e.g. `'4.1.2'`), or `null` + * when the probe couldn't extract one. The shared compile + * pipeline feeds this verbatim to `isStrucppCompatibleRuntime`. */ + version: string | null +} + +/** + * Run the probe. Always resolves — never throws — so the pipeline + * gets a deterministic answer it can branch on. + */ +export async function probeRuntimeVersion(opts: ProbeRuntimeVersionOptions): Promise { + try { + const result = await opts.fetchVersion() + if (!result.success) { + opts.log(`Could not reach runtime: ${result.error}`, 'warning') + return { version: null } + } + return { version: extractVersionFromBody(result.body) } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + opts.log(`Runtime version probe failed: ${message}`, 'warning') + return { version: null } + } +} + +/** + * Pull the top-level `version` field out of the runtime's response + * body. The runtime emits `{ version: '4.1.x' }`; everything else + * (missing field, non-string value, unexpected shape) collapses to + * `null` so the strucpp-compatibility gate treats the unknown + * answer as incompatible. + */ +function extractVersionFromBody(body: unknown): string | null { + if (typeof body !== 'object' || body === null) return null + if (!('version' in body)) return null + const v = (body as { version: unknown }).version + return typeof v === 'string' ? v : null +} diff --git a/src/backend/shared/utils/cpp/__tests__/generateCBlocksCode.test.ts b/src/backend/shared/utils/cpp/__tests__/generateCBlocksCode.test.ts index 6a07920ab..02d2ed905 100644 --- a/src/backend/shared/utils/cpp/__tests__/generateCBlocksCode.test.ts +++ b/src/backend/shared/utils/cpp/__tests__/generateCBlocksCode.test.ts @@ -48,6 +48,27 @@ describe('generateCBlocksCode', () => { expect(result).toMatch(/typedef\s+struct\s+\{[\s\S]*?__strlen_t len;[\s\S]*?\}\s+IEC_STRING;/) }) + it("undefines Arduino.h's min/max macros before pulling in strucpp/std headers", () => { + // Regression guard: Arduino.h defines `min` / `max` as preprocessor + // macros that wreck `` / `` (both transitively + // included via iec_string.hpp). Order must be: + // include -> #undef min/max -> #include "iec_string.hpp" + const variables: PLCVariable[] = [makeScalarVar('x', 'input', 'INT')] + const code = 'void setup() { }\nvoid loop() { }' + const result = generateCBlocksCode([{ name: 'B', code, variables }]) + + const arduinoIdx = result.indexOf('#include ') + const undefMinIdx = result.indexOf('#undef min') + const undefMaxIdx = result.indexOf('#undef max') + const iecStringIdx = result.indexOf('#include "iec_string.hpp"') + + expect(arduinoIdx).toBeGreaterThan(-1) + expect(undefMinIdx).toBeGreaterThan(arduinoIdx) + expect(undefMaxIdx).toBeGreaterThan(arduinoIdx) + expect(iecStringIdx).toBeGreaterThan(undefMinIdx) + expect(iecStringIdx).toBeGreaterThan(undefMaxIdx) + }) + it('generates struct, extern declarations, defines, code, and undefs for a pou', () => { const variables: PLCVariable[] = [makeScalarVar('speed', 'input', 'INT'), makeScalarVar('result', 'output', 'REAL')] const code = 'void setup() { }\nvoid loop() { }' @@ -100,9 +121,13 @@ describe('generateCBlocksCode', () => { expect(result).toContain('typedef struct {') expect(result).toContain('} EMPTY_VARS;') // No #define / #undef for variables (the baseline's STR_MAX_LEN / - // STR_LEN_TYPE defines are unrelated bookkeeping). + // STR_LEN_TYPE defines and the Arduino min/max macro undefs are + // unrelated bookkeeping). expect(result).not.toMatch(/^#define\s+\w+\s+\(/m) - expect(result).not.toMatch(/^#undef\s+\w+\s*$/m) + // Strip the baseline's `#undef min` / `#undef max` (Arduino.h macro + // scrubbing — see baseline) before asserting no per-variable undefs. + const withoutArduinoUndefs = result.replace(/^#undef\s+(min|max)\s*$/gm, '') + expect(withoutArduinoUndefs).not.toMatch(/^#undef\s+\w+\s*$/m) }) it('processes multiple pous', () => { diff --git a/src/backend/shared/utils/cpp/generateCBlocksCode.ts b/src/backend/shared/utils/cpp/generateCBlocksCode.ts index f7ec9dc43..c2929c6b8 100644 --- a/src/backend/shared/utils/cpp/generateCBlocksCode.ts +++ b/src/backend/shared/utils/cpp/generateCBlocksCode.ts @@ -30,6 +30,15 @@ const C_BLOCKS_BASELINE = `#include #ifdef ARDUINO #include +// Arduino.h defines \`min\` and \`max\` as preprocessor macros, which +// collide with the \`std::min\` / \`std::max\` function templates and +// the \`numeric_limits::min()\` / \`max()\` static members that +// \`\` / \`\` declare (both pulled in transitively +// via \`iec_string.hpp\` below). Undef'ing them here keeps the user's +// c_blocks code free to call \`std::min\` / \`std::max\` and lets the +// strucpp runtime headers compile cleanly on AVR. +#undef min +#undef max #endif // STruC++ runtime types — IECVar wrappers under namespace strucpp. diff --git a/src/frontend/components/_features/[workspace]/ai-settings-panel/AcuExhaustionModal.tsx b/src/frontend/components/_features/[workspace]/ai-settings-panel/AcuExhaustionModal.tsx index fb57d7b8a..b6c1de015 100644 --- a/src/frontend/components/_features/[workspace]/ai-settings-panel/AcuExhaustionModal.tsx +++ b/src/frontend/components/_features/[workspace]/ai-settings-panel/AcuExhaustionModal.tsx @@ -51,7 +51,7 @@ export const AcuExhaustionModal = ({ const description = isInactive ? `Your subscription is ${billingError.subscriptionStatus ?? 'inactive'}. Reactivate it to keep using AI features.` : billingError.monthlyLimit != null - ? `You've used all ${billingError.monthlyLimit} ACU for this billing period. Buy more ACU or upgrade your plan to keep going.` + ? `You've used all ${Math.round(billingError.monthlyLimit)} ACU for this billing period. Buy more ACU or upgrade your plan to keep going.` : "You're out of ACU for this billing period. Buy more ACU or upgrade your plan to keep going." const ctaLabel = isInactive ? 'Reactivate subscription' : 'Upgrade plan' // subscription_inactive carries its own reactivation URL; insufficient_acu doesn't. @@ -76,7 +76,8 @@ export const AcuExhaustionModal = ({ typeof billingError.remaining === 'number' && typeof billingError.required === 'number' && (

- This request needed {billingError.required} ACU; only {billingError.remaining} remaining. + This request needed {Math.round(billingError.required)} ACU; only {Math.round(billingError.remaining)}{' '} + remaining.

)}
diff --git a/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx b/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx index 87a4452f7..c8b2bc264 100644 --- a/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx @@ -1257,6 +1257,19 @@ void loop() // Editor options // ----------------------------------------------------------------------- + // Inline AI completions take over the suggest widget only while they are + // actually active (same gate as the provider registration above). When the + // user turns inline completions off, fall back to Monaco's normal quick + // suggestions (the auto-dropdown). Ctrl+Space still triggers the suggest + // widget manually in both modes — `quickSuggestions` only governs the + // automatic popup, and `suppressSuggestions` only suppresses the auto popup + // while an inline suggestion is showing. + const inlineCompletionsActive = + capabilities.hasAIAssistant && + aiState.isEnabled && + aiState.hasConsented && + aiState.preferences.inlineCompletionsEnabled + const monacoEditorUserOptions: monacoEditorOptionsType = { minimap: { enabled: false }, dropIntoEditor: { enabled: true }, @@ -1273,7 +1286,7 @@ void loop() tabSize: 4, insertSpaces: true, detectIndentation: false, - quickSuggestions: capabilities.hasAIAssistant ? false : undefined, + quickSuggestions: inlineCompletionsActive ? false : undefined, // Pinned for cross-platform consistency with the variables-code-editor. // Monaco's default is platform-dependent (12 on macOS, 14 elsewhere) — // without this both surfaces would mismatch on Linux/Windows even @@ -1295,7 +1308,7 @@ void loop() // `document.body` with `position: fixed`, so they escape both // the editor container and the variables table above it. fixedOverflowWidgets: true, - ...(capabilities.hasAIAssistant && { + ...(inlineCompletionsActive && { inlineSuggest: { enabled: true, suppressSuggestions: true, diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index 0465d78f9..66b35e511 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -165,6 +165,12 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa isSimulator: isSimulatorBoard, runtimeIpAddress: deviceDefinitions.configuration.runtimeIpAddress || null, runtimeJwtToken: jwtToken || null, + // Live serial-port picker value from the device store. + // Threaded through so arduino-cli upload uses the + // picker's current selection even when the user hasn't + // saved the project yet (the legacy disk-read path lags + // the live store by one save cycle). + communicationPort: deviceDefinitions.configuration.communicationPort || undefined, }, (event) => { if (event.plcStatus) { diff --git a/src/middleware/adapters/editor/__tests__/compiler-adapter.test.ts b/src/middleware/adapters/editor/__tests__/compiler-adapter.test.ts index bfb680b07..706cff17f 100644 --- a/src/middleware/adapters/editor/__tests__/compiler-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/compiler-adapter.test.ts @@ -209,7 +209,15 @@ describe('createEditorCompilerAdapter', () => { expect(window.bridge.getAvailableBoards).toHaveBeenCalled() expect(window.bridge.runCompileProgram).toHaveBeenCalledWith( - ['/path/to/project', 'Arduino Mega', 'arduino:avr:mega', true, expect.any(Object), null, null, false], + // Args layout (verbatim, in order): + // projectPath, boardTarget, boardCore, compileOnly, + // projectData, runtimeIpAddress, runtimeJwtToken, + // cleanBuild, communicationPort. + // `communicationPort` is the 9th slot — added so the + // arduino-cli upload step receives the user's serial-port + // picker selection without waiting on a project save round- + // trip. `null` when the caller didn't supply one. + ['/path/to/project', 'Arduino Mega', 'arduino:avr:mega', true, expect.any(Object), null, null, false, null], expect.any(Function), ) expect(result).toEqual({ success: true, message: 'Compilation complete', hexPath: undefined }) diff --git a/src/middleware/adapters/editor/compiler-adapter.ts b/src/middleware/adapters/editor/compiler-adapter.ts index f03cfbfd2..f2e87a931 100644 --- a/src/middleware/adapters/editor/compiler-adapter.ts +++ b/src/middleware/adapters/editor/compiler-adapter.ts @@ -215,6 +215,7 @@ export function createEditorCompilerAdapter(): CompilerPort { args.runtimeIpAddress ?? null, args.runtimeJwtToken ?? null, args.cleanBuild ?? false, + args.communicationPort ?? null, ], (data: Record) => { // Extract simulator firmware path BEFORE the closePort early return, diff --git a/src/middleware/shared/ports/compiler-platform-port.ts b/src/middleware/shared/ports/compiler-platform-port.ts new file mode 100644 index 000000000..d5f4d6fdf --- /dev/null +++ b/src/middleware/shared/ports/compiler-platform-port.ts @@ -0,0 +1,307 @@ +/** + * Thin platform-bridge for the shared compile pipeline. + * + * The shared compile pipeline orchestrator (`backend/shared/compile/ + * pipeline.ts`) drives the full editor-canonical build flow — XML + * generation, ST transpile, strucpp compile, conf authoring, defines + * authoring, firmware-bundle composition, arduino-cli invocation, + * runtime upload. Every step is shared and pure EXCEPT for three + * places that genuinely depend on the platform: + * + * 1. `xml2st` transpile — editor spawns the bundled binary, + * web HTTP-POSTs to the centralised compiler-service backend. + * 2. `arduino-cli` compile — editor spawns arduino-cli, web POSTs + * to the same backend (which spawns it server-side). + * 3. Runtime upload — editor HTTPS-POSTs to the device's + * `/api/upload`, web pipes through the orchestrator + * (WebRTC data channel with HTTP fallback). + * + * Plus the supporting Arduino-CLI lifecycle calls on editor that + * the web platform's pre-provisioned compiler image makes redundant: + * `installArduinoCore` and `installArduinoLib` are no-ops on web. + * + * Each method on this port takes the same canonical arguments + * regardless of platform and returns the same canonical result + * shape. The pipeline orchestrator is fully platform-agnostic — + * it never knows whether it's running in Electron's main process + * or a browser tab. Each platform's adapter implements this port + * once and wires it into its `compiler-adapter.ts`. + * + * Editor-canonical contract: web's no-op implementations of + * `installArduinoCore` / `installArduinoLib` / `uploadArduinoBoard` + * / `uploadRuntimeV3` MUST resolve to `{ ok: true }` so the + * pipeline's ordering and downstream steps run identically on both + * platforms. Web simply skips work that's already handled + * server-side, but the pipeline never knows. + */ + +import type { StructuredCompileError } from './types' + +/** + * Canonical progress callback the pipeline passes to every port + * method. Wraps both informational progress and error logging + * through one channel — adapters translate to their native log + * shape (editor: `_mainProcessPort.postMessage`; web: + * `onProgress({ stage, message, level, ... })`). + * + * `level: 'error'` carries diagnostic text for the user; the actual + * compile-error STRUCTURE travels in `errors[]` on the method's + * return value, where the renderer's navigation can key off it. + */ +export type PlatformLog = (message: string, level: 'info' | 'warning' | 'error') => void + +/** + * Discriminated device-context shape. Each adapter picks the + * variant that matches its transport. The pipeline never inspects + * the contents — it just forwards `context` through to the upload + * methods. + */ +export type PlatformDeviceContext = + | { + /** Editor: direct HTTPS to the device. `ip` is the device's + * reachable IP from the desktop; `jwt` is the auth token the + * user obtained via the desktop login flow. */ + kind: 'editor-https' + ip: string + jwt: string + } + | { + /** Web: bundle is piped through the orchestrator agent that + * fronts the device. `agentId` identifies the orchestrator + * agent; `sessionId` (when present) carries a WebRTC session + * the adapter can attach to; otherwise the adapter falls + * back to the orchestrator's HTTP proxy. */ + kind: 'web-orchestrator' + agentId: string + sessionId?: string + } + +// --------------------------------------------------------------------------- +// Per-method I/O contracts +// --------------------------------------------------------------------------- + +/** `xml2st` input: a single XML string (the IEC 61131-3 PLC XML the + * shared `XmlGenerator` produces). Same input on both platforms. */ +export interface TranspileXmlToStArgs { + xml: string +} + +export interface TranspileXmlToStResult { + ok: boolean + /** ST source emitted by xml2st when the transpile succeeded. + * Empty / undefined on failure. */ + programSt?: string + /** Structured diagnostics emitted by xml2st. Web maps the + * server's `output_stderr` parser output here; editor parses + * the binary's stderr stream. Carried over to the pipeline's + * caller via the `errors[]` return on `CompileResult`. */ + errors?: StructuredCompileError[] + /** Same shape as `errors[]` but for non-fatal warnings. */ + warnings?: StructuredCompileError[] +} + +/** Arduino compile input: the full source tree as a file map plus + * the argv arduino-cli should be invoked with. Both already shared + * (`composeFirmwareBundle` produces `files`, + * `buildArduinoCliCompileArgs` produces `argv`). */ +export interface CompileArduinoArgs { + /** Project-root-relative path → file content. Includes + * `examples/Baremetal/Baremetal.ino`, `src/generated.cpp`, + * `src/c_blocks.h`, `examples/Baremetal/c_blocks_code.cpp`, + * `src/defines.h`, and the bundled firmware skeleton + strucpp + * runtime headers. On editor: written to disk before + * arduino-cli runs. On web: POSTed in the request body. */ + files: Record + /** Argv suffix for arduino-cli compile (after the `compile` + * subcommand). Comes from the shared `buildArduinoCliCompileArgs` + * helper. */ + argv: string[] + /** When `false`, arduino-cli is invoked with `--jobs 1` (web + * default — backend runs many clients in sandboxes, so saturating + * cores would starve concurrents). When `true`, arduino-cli + * defaults to `--jobs 0` (editor default — uses every core). */ + parallel: boolean +} + +export interface CompileArduinoResult { + ok: boolean + /** Compiled firmware bytes (`.hex` for AVR). Editor: read from + * the build output directory. Web: base64-decoded from the + * server response. Caller decides whether to write to disk + * (editor: stores the path; web: holds in memory and hands to + * the simulator). */ + binary?: Uint8Array + errors?: StructuredCompileError[] +} + +/** Runtime v4 upload: the full v4 bundle file map (output of + * `composeRuntimeV4Bundle`). Both platforms send the same bytes + * to the runtime — the difference is just the transport. */ +export interface UploadRuntimeV4Args { + /** File map the runtime extracts on the device. Already + * composed by `composeRuntimeV4Bundle`; the pipeline passes it + * straight through. */ + bundle: Record + /** Discriminated device context; see `PlatformDeviceContext`. */ + context: PlatformDeviceContext +} + +export interface UploadResult { + ok: boolean + errors?: StructuredCompileError[] +} + +/** Arduino direct-upload (editor-only, used for non-simulator + * Arduino targets). Web's adapter MUST no-op this with + * `{ ok: true }` — web only targets the simulator (avr8js, no + * device upload step) or runtime v4 (handled separately). */ +export interface UploadArduinoBoardArgs { + /** Path arduino-cli reads the compiled artefacts from. Editor: + * the build output dir. Web: ignored (no-op). */ + compilationPath: string + /** Fully-qualified Arduino board name (e.g. `arduino:avr:mega`). */ + fqbn: string + /** Serial port for upload (e.g. `/dev/cu.usbmodem1101`). Editor + * resolves; web's adapter receives but ignores. */ + port: string +} + +/** Runtime v3 upload (legacy, editor-only). Web's adapter MUST + * no-op this with `{ ok: true }` — v3 is end-of-life and the web + * frontend never offers v3 as a target. */ +export interface UploadRuntimeV3Args { + /** Concatenated `program.st` content with embedded C-blocks + * (output of `embedCBlocksInProgramSt`). Editor sends to the + * device's v3 `/api/upload`. */ + programSt: string + context: PlatformDeviceContext +} + +/** Arduino-CLI core install (editor-only. Web's adapter MUST + * no-op with `{ ok: true }` — web's compiler-service backend + * ships with every core preinstalled). */ +export interface InstallArduinoCoreArgs { + /** Core identifier (e.g. `arduino:avr`). Editor invokes + * `arduino-cli core install `. */ + coreId: string +} + +/** Arduino-CLI library install (editor-only. Same no-op + * contract as core install for web). */ +export interface InstallArduinoLibArgs { + libId: string +} + +/** Runtime-version probe (used for the v4 strucpp-compatibility + * gate that aborts uploads to a pre-4.1.0 runtime). Editor: GETs + * the device's unauthenticated `/api/version`. Web: queries the + * orchestrator's device-info endpoint. */ +export interface CheckRuntimeVersionArgs { + context: PlatformDeviceContext +} + +export interface CheckRuntimeVersionResult { + ok: boolean + /** Reported version string (semver-ish; e.g. `'4.1.0'`). `null` + * when the runtime is unreachable or doesn't expose the + * endpoint (very old v3 runtimes). */ + version: string | null +} + +/** VPP (Vendor Plugin Package) runtime-v4 packaging. Boards that + * come from an installed `.vpp` package ship a vendor I/O driver + * alongside the program — the driver's source files, a generated + * plugin config JSON, and `vpp_plugins.conf` (which enables the + * driver) must land in the v4 upload bundle so the runtime's + * `compile.sh` builds the driver and `apply_vpp_plugin_conf()` + * loads it. Without this step the program uploads but the device + * runs as a generic runtime with no physical I/O. */ +export interface PackageVppPluginArgs { + /** Selected board name, looked up against installed VPP packages. */ + boardTarget: string +} + +export interface PackageVppPluginResult { + /** Extra files to merge into the runtime-v4 upload bundle. Keys + * are paths relative to the bundle root (matching + * `composeRuntimeV4Bundle`'s convention). Empty record means + * the board isn't a VPP board, the package lacks the necessary + * HAL metadata, or VPP integration is skipped on this platform — + * in any of those cases the pipeline proceeds with the unchanged + * bundle. Errors that should abort the upload are reported via + * `errors[]`; soft skips emit log lines via the `log` callback + * and return an empty record without errors. */ + files: Record + errors?: StructuredCompileError[] +} + +// --------------------------------------------------------------------------- +// The port itself +// --------------------------------------------------------------------------- + +/** + * Every method takes a `log` callback so the pipeline orchestrator + * can stream progress events through the same channel regardless of + * platform. The adapters are responsible for translating + * `PlatformLog` calls to their native log shape (editor: + * `_mainProcessPort.postMessage`; web: `onProgress({ stage, ... })`). + * + * Method return values carry the canonical `CompileError[]` shape so + * the pipeline can decide what to surface to the user — the log + * channel is for progress + diagnostic text, the return value's + * `errors[]` is for structured navigation-ready diagnostics. + * + * Every method is asynchronous because at least one platform + * implementation has to wait on a subprocess or HTTP round-trip. + */ +export interface CompilerPlatformPort { + /** Compute the MD5 hex digest of an arbitrary string. Editor: + * Node's `crypto.createHash('md5')`. Web: `spark-md5` (already + * a web dep). Both produce byte-identical output. The pipeline + * uses this to compute `program.st`'s MD5 — embedded in + * `defines.h` as `PROGRAM_MD5` for the v4 runtime's stale-program + * detection. Kept in the port (rather than hardcoding either + * library in shared code) so the shared module ships without a + * hash-impl dependency. */ + computeMd5(input: string): Promise + + /** Step 3 of the editor pipeline. Transpile IEC XML to ST. */ + transpileXmlToSt(args: TranspileXmlToStArgs, log: PlatformLog): Promise + + /** Step 5 of the editor pipeline. Arduino-CLI core install. + * Web returns `{ ok: true }` immediately. */ + installArduinoCore(args: InstallArduinoCoreArgs, log: PlatformLog): Promise + + /** Step 9 of the editor pipeline. Arduino-CLI library install. + * Web returns `{ ok: true }` immediately. */ + installArduinoLib(args: InstallArduinoLibArgs, log: PlatformLog): Promise + + /** Step 12 of the editor pipeline. Arduino-CLI compile. */ + compileArduino(args: CompileArduinoArgs, log: PlatformLog): Promise + + /** Step 13a of the editor pipeline (runtime v4 path). Upload + * the strucpp bundle. */ + uploadRuntimeV4(args: UploadRuntimeV4Args, log: PlatformLog): Promise + + /** Step 13b of the editor pipeline (Arduino direct path). + * Web no-ops. */ + uploadArduinoBoard(args: UploadArduinoBoardArgs, log: PlatformLog): Promise + + /** Step 13c of the editor pipeline (runtime v3 path). + * Web no-ops. */ + uploadRuntimeV3(args: UploadRuntimeV3Args, log: PlatformLog): Promise + + /** Pre-upload gate for runtime v4. Used to short-circuit + * uploads to incompatible (pre-4.1.0) runtimes. */ + checkRuntimeVersion(args: CheckRuntimeVersionArgs, log: PlatformLog): Promise + + /** Runtime-v4 VPP plugin packaging. Returns the extra files to + * merge into the v4 upload bundle when the selected board comes + * from an installed VPP package (driver source, plugin config, + * `vpp_plugins.conf`, checksum). Returns `{ files: {} }` for + * non-VPP boards or when VPP integration is unavailable on this + * platform (web defaults to this until its adapter wires up + * remote VPP packages). The pipeline calls this between + * `composeRuntimeV4Bundle` and `uploadRuntimeV4`. */ + packageVppPlugin(args: PackageVppPluginArgs, log: PlatformLog): Promise +}