From 50b053f2a2627c0373ae298e6432681c1ae3ff42 Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Sat, 23 May 2026 15:05:56 +0200 Subject: [PATCH 01/61] feat(device): VPP target.platformOptions + BoardInfoResolver foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces that prepare the compile pipeline to consume VPP definitions uniformly with hals.json boards: BoardInfoResolver ================= New src/backend/editor/hardware/board-info-resolver.ts looks up a board by name across installed VPP packages first and falls back to hals.json, returning a normalised BoardBuildInfo (platform FQBN, HAL source file, compiler flags, defines, libraries, platformOptions). HardwareModule exposes it via getBoardBuildInfo so the compiler module can replace direct hals.json reads with a single VPP-aware lookup. platformOptions =============== VPP target.platformOptions (Nano cpu=atmega328|atmega328old, Mega cpu=atmega2560|atmega1280, board-specific upload methods, etc.) are new metadata that a manifest can declare so the editor can render labelled dropdowns for FQBN sub-options. The middleware/shared/ports/ types.ts exposes PlatformOption + PlatformOptionValue interfaces; PackageManifest, BoardInfo, and DeviceConfiguration gain the corresponding fields (platformOptions on the manifest device target, platformOptions on BoardInfo, selectedPlatformOptions on DeviceConfiguration). Frontend wiring =============== The device Zustand slice exposes setSelectedPlatformOption(key, value) and clearSelectedPlatformOptions(). setDeviceBoard now clears selectedPlatformOptions when the board actually changes — platformOptions are board-specific and a cpu=atmega328old pick on Nano makes no sense after switching to Mega. mergeDeviceConfigWithDefaults forwards selectedPlatformOptions so loading a project that predates the field returns the same record reference across selectors (a fresh literal would trigger an infinite Zustand re-render loop). The hardware module forwards manifest.target.platformOptions onto the flat BoardInfo only when the manifest actually declares some, so the UI's `platformOptions?.length` gate stays tight for boards that don't expose variants. Tests cover BoardInfoResolver (legacy + VPP + path traversal + runtime-v4 + flag composition) and the new device slice actions (set/clear/board-change clear/preserve-same-board). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../show_properties_dummy.ino | 6 + .../__tests__/board-info-resolver.test.ts | 422 ++++++++++++++++++ .../editor/hardware/board-info-resolver.ts | 208 +++++++++ .../editor/hardware/hardware-module.ts | 23 + src/backend/editor/hardware/index.ts | 1 + src/backend/editor/hardware/types.ts | 5 + .../shared/types/PLC/devices/configuration.ts | 5 + src/frontend/hooks/use-store-selectors.ts | 14 + .../store/__tests__/device-slice.test.ts | 65 +++ .../store/slices/device/data/types.ts | 1 + src/frontend/store/slices/device/slice.ts | 32 ++ src/frontend/store/slices/device/types.ts | 6 + src/middleware/shared/ports/types.ts | 59 +++ src/types/PLC/devices/configuration.ts | 4 + 14 files changed, 851 insertions(+) create mode 100644 resources/sources/show_properties_dummy/show_properties_dummy.ino create mode 100644 src/backend/editor/hardware/__tests__/board-info-resolver.test.ts create mode 100644 src/backend/editor/hardware/board-info-resolver.ts diff --git a/resources/sources/show_properties_dummy/show_properties_dummy.ino b/resources/sources/show_properties_dummy/show_properties_dummy.ino new file mode 100644 index 000000000..47e524244 --- /dev/null +++ b/resources/sources/show_properties_dummy/show_properties_dummy.ino @@ -0,0 +1,6 @@ +// Empty sketch used as `arduino-cli compile --show-properties=expanded` target. +// We never actually compile this — we only ask arduino-cli to resolve every +// platform/board property for a given FQBN so the editor can feed those +// values into its own pre-compile pipeline (see CompilerModule.extractToolchainProperties). +void setup() {} +void loop() {} diff --git a/src/backend/editor/hardware/__tests__/board-info-resolver.test.ts b/src/backend/editor/hardware/__tests__/board-info-resolver.test.ts new file mode 100644 index 000000000..58ebc0559 --- /dev/null +++ b/src/backend/editor/hardware/__tests__/board-info-resolver.test.ts @@ -0,0 +1,422 @@ +import { join, sep } from 'node:path' + +import type { InstalledPackage, PackageManifest } from '../../package-manager/types' +import { BoardInfoResolver, type PackageManagerLike } from '../board-info-resolver' +import type { BoardInfo, HalsFile } from '../types' + +const HALS_PATH = '/fake/resources/sources/boards/hals.json' +const SOURCES_DIR = '/fake/resources/sources' +const PKG_PATH = '/fake/user-data/packages/com.openplc.arduino' + +function makeHalsEntry(overrides: Partial = {}): BoardInfo { + return { + compiler: 'arduino-cli', + core: 'arduino:avr', + platform: 'arduino:avr:mega', + default_din: '2, 3', + default_dout: '4, 5', + default_ain: 'A0', + default_aout: '6', + preview: 'mega.png', + source: 'mega_due.cpp', + specs: { + CPU: 'ATmega 2560', + RAM: '8 KB', + Flash: '256 KB', + DigitalPins: '70', + AnalogPins: '16', + PWMPins: '15', + WiFi: 'No', + Bluetooth: 'No', + Ethernet: 'No', + }, + ...overrides, + } +} + +function makeHalsReader(content: HalsFile | Error): (path: string) => Promise { + return async () => { + if (content instanceof Error) throw content + return content as unknown as T + } +} + +function makePkg(overrides: Partial = {}): InstalledPackage { + return { + packageId: 'com.openplc.arduino', + version: '0.1.0', + installedAt: '2026-05-13T00:00:00.000Z', + path: PKG_PATH, + devices: ['arduino-mega'], + ...overrides, + } +} + +function makeManifest(overrides: Partial = {}): PackageManifest { + return { + formatVersion: '1.0', + package: { + id: 'com.openplc.arduino', + name: 'Arduino', + version: '0.1.0', + vendor: { name: 'Arduino', logo: 'assets/logo.png' }, + description: 'desc', + }, + devices: [ + { + id: 'arduino-mega', + name: 'Arduino Mega', + preview: 'assets/boards/mega.png', + target: { type: 'arduino-cli', core: 'arduino:avr', platform: 'arduino:avr:mega' }, + hal: { type: 'arduino-hal', source: 'hal/arduino/mega_due.cpp' }, + }, + ], + ...overrides, + } +} + +function makePackageManager( + installed: InstalledPackage[], + manifests: Record, +): PackageManagerLike { + return { + listInstalled: () => installed, + getInstalledPackageManifest: (id) => manifests[id] ?? null, + } +} + +describe('BoardInfoResolver', () => { + describe('hals.json lookup', () => { + it('resolves a board found in hals.json into a `source: hals` BoardBuildInfo', async () => { + const hals: HalsFile = { 'Arduino Mega': makeHalsEntry() } + const r = new BoardInfoResolver(HALS_PATH, SOURCES_DIR, makePackageManager([], {}), makeHalsReader(hals)) + const info = await r.resolve('Arduino Mega') + expect(info.source).toBe('hals') + expect(info.compiler).toBe('arduino-cli') + expect(info.platform).toBe('arduino:avr:mega') + expect(info.core).toBe('arduino:avr') + expect(info.halSourceFile).toBe(join(SOURCES_DIR, 'hal', 'mega_due.cpp')) + }) + + it('maps optional hals fields (board_manager_url, flags, define, extra_libraries)', async () => { + const hals: HalsFile = { + 'Sequent ESP32': makeHalsEntry({ + board_manager_url: 'https://example.com/index.json', + c_flags: ['-MMD'], + cxx_flags: ['-std=gnu++17'], + ld_flags: ['-Wl,foo'], + define: 'BOARD_ESP32', + extra_libraries: ['SomeLib'], + }), + } + const r = new BoardInfoResolver(HALS_PATH, SOURCES_DIR, makePackageManager([], {}), makeHalsReader(hals)) + const info = await r.resolve('Sequent ESP32') + expect(info.boardManagerUrl).toBe('https://example.com/index.json') + expect(info.compilerFlags).toEqual({ + c_flags: ['-MMD'], + cxx_flags: ['-std=gnu++17'], + ld_flags: ['-Wl,foo'], + }) + expect(info.define).toBe('BOARD_ESP32') + expect(info.extraArduinoLibraries).toEqual(['SomeLib']) + }) + + it('omits compilerFlags entirely when no flag arrays exist', async () => { + const hals: HalsFile = { 'Arduino Uno': makeHalsEntry() } + const r = new BoardInfoResolver(HALS_PATH, SOURCES_DIR, makePackageManager([], {}), makeHalsReader(hals)) + const info = await r.resolve('Arduino Uno') + expect(info.compilerFlags).toBeUndefined() + }) + + it('falls through to VPP when hals.json read fails (missing file)', async () => { + const pkg = makePkg() + const manifest = makeManifest() + const pm = makePackageManager([pkg], { [pkg.packageId]: manifest }) + const r = new BoardInfoResolver(HALS_PATH, SOURCES_DIR, pm, makeHalsReader(new Error('ENOENT'))) + const info = await r.resolve('Arduino Mega') + expect(info.source).toBe('vpp') + }) + }) + + describe('precedence', () => { + it('hals.json wins when the same board exists in both catalogs', async () => { + const hals: HalsFile = { 'Arduino Mega': makeHalsEntry({ platform: 'hals-platform' }) } + const pkg = makePkg() + const manifest = makeManifest({ + devices: [ + { + id: 'arduino-mega', + name: 'Arduino Mega', + preview: 'assets/boards/mega.png', + target: { type: 'arduino-cli', core: 'arduino:avr', platform: 'vpp-platform' }, + hal: { type: 'arduino-hal', source: 'hal/arduino/mega_due.cpp' }, + }, + ], + }) + const pm = makePackageManager([pkg], { [pkg.packageId]: manifest }) + const r = new BoardInfoResolver(HALS_PATH, SOURCES_DIR, pm, makeHalsReader(hals)) + const info = await r.resolve('Arduino Mega') + expect(info.source).toBe('hals') + expect(info.platform).toBe('hals-platform') + }) + }) + + describe('VPP lookup', () => { + it('resolves a VPP-only arduino-cli board with full field mapping', async () => { + const pkg = makePkg() + const manifest = makeManifest({ + devices: [ + { + id: 'arduino-giga', + name: 'Arduino Giga', + preview: 'assets/boards/generic.png', + target: { + type: 'arduino-cli', + core: 'arduino:mbed_giga', + platform: 'arduino:mbed_giga:giga', + boardManagerUrl: 'https://example.com/mbed.json', + }, + hal: { + type: 'arduino-hal', + source: 'hal/arduino/giga.cpp', + compilerFlags: { c_flags: ['-MMD'], cxx_flags: ['-std=gnu++17'] }, + define: ['BOARD_GIGA', 'EXTRA'], + extraArduinoLibraries: ['Ethernet'], + libraries: 'hal/arduino/libraries', + }, + }, + ], + }) + const pm = makePackageManager([pkg], { [pkg.packageId]: manifest }) + const r = new BoardInfoResolver(HALS_PATH, SOURCES_DIR, pm, makeHalsReader({})) + const info = await r.resolve('Arduino Giga') + expect(info).toMatchObject({ + source: 'vpp', + compiler: 'arduino-cli', + core: 'arduino:mbed_giga', + platform: 'arduino:mbed_giga:giga', + boardManagerUrl: 'https://example.com/mbed.json', + halSourceFile: join(PKG_PATH, 'hal', 'arduino', 'giga.cpp'), + compilerFlags: { c_flags: ['-MMD'], cxx_flags: ['-std=gnu++17'] }, + define: ['BOARD_GIGA', 'EXTRA'], + extraArduinoLibraries: ['Ethernet'], + localLibrariesDir: join(PKG_PATH, 'hal', 'arduino', 'libraries'), + vppPackageId: 'com.openplc.arduino', + vppDeviceId: 'arduino-giga', + vppPackagePath: PKG_PATH, + }) + }) + + it('resolves a runtime-v4 plugin board (python) and maps target type to openplc-compiler', async () => { + const pkg = makePkg({ packageId: 'com.openplc.raspberry-pi' }) + const manifest = makeManifest({ + package: { + id: 'com.openplc.raspberry-pi', + name: 'Raspberry Pi', + version: '0.1.0', + vendor: { name: 'Raspberry Pi', logo: 'assets/logo.png' }, + description: 'desc', + }, + devices: [ + { + id: 'raspberry-pi', + name: 'Raspberry Pi', + preview: 'assets/boards/raspberry-pi.png', + target: { type: 'runtime-v4', platform: 'linux-arm' }, + hal: { + type: 'runtime-v4-plugin', + pluginType: 'python', + pluginEntry: 'hal/runtime-v4/plugin/rpi_hal.py', + configTemplate: 'hal/runtime-v4/plugin/config_template.json', + requirements: 'hal/runtime-v4/plugin/requirements.txt', + }, + }, + ], + }) + const pm = makePackageManager([pkg], { [pkg.packageId]: manifest }) + const r = new BoardInfoResolver(HALS_PATH, SOURCES_DIR, pm, makeHalsReader({})) + const info = await r.resolve('Raspberry Pi') + expect(info.compiler).toBe('openplc-compiler') + expect(info.pluginType).toBe('python') + expect(info.pluginEntry).toBe(join(pkg.path, 'hal', 'runtime-v4', 'plugin', 'rpi_hal.py')) + expect(info.configTemplate).toBe(join(pkg.path, 'hal', 'runtime-v4', 'plugin', 'config_template.json')) + expect(info.requirements).toBe(join(pkg.path, 'hal', 'runtime-v4', 'plugin', 'requirements.txt')) + }) + + it('forwards target.platformOptions verbatim from the manifest', async () => { + const pkg = makePkg() + const manifest = makeManifest({ + devices: [ + { + id: 'arduino-nano', + name: 'Arduino Nano', + preview: 'p.png', + target: { + type: 'arduino-cli', + core: 'arduino:avr', + platform: 'arduino:avr:nano', + platformOptions: [ + { + key: 'cpu', + label: 'Processor', + default: 'atmega328', + help: 'Pick the bootloader variant.', + values: [ + { id: 'atmega328', label: 'New Bootloader' }, + { id: 'atmega328old', label: 'Old Bootloader', help: '57600 baud' }, + ], + }, + ], + }, + hal: { type: 'arduino-hal', source: 'hal/arduino/nano.cpp' }, + }, + ], + }) + const pm = makePackageManager([pkg], { [pkg.packageId]: manifest }) + const r = new BoardInfoResolver(HALS_PATH, SOURCES_DIR, pm, makeHalsReader({})) + const info = await r.resolve('Arduino Nano') + expect(info.platformOptions).toEqual([ + { + key: 'cpu', + label: 'Processor', + default: 'atmega328', + help: 'Pick the bootloader variant.', + values: [ + { id: 'atmega328', label: 'New Bootloader' }, + { id: 'atmega328old', label: 'Old Bootloader', help: '57600 baud' }, + ], + }, + ]) + }) + + it('omits platformOptions when the manifest does not declare any', async () => { + const pkg = makePkg() + const manifest = makeManifest({ + devices: [ + { + id: 'arduino-mega', + name: 'Arduino Mega', + preview: 'p.png', + target: { + type: 'arduino-cli', + core: 'arduino:avr', + platform: 'arduino:avr:mega', + }, + hal: { type: 'arduino-hal', source: 'hal/arduino/mega.cpp' }, + }, + ], + }) + const pm = makePackageManager([pkg], { [pkg.packageId]: manifest }) + const r = new BoardInfoResolver(HALS_PATH, SOURCES_DIR, pm, makeHalsReader({})) + const info = await r.resolve('Arduino Mega') + expect(info.platformOptions).toBeUndefined() + }) + + it('passes through unknown target types as compiler value', async () => { + const pkg = makePkg() + const manifest = makeManifest({ + devices: [ + { + id: 'weird', + name: 'Weird Board', + preview: 'p.png', + target: { type: 'my-future-toolchain' }, + hal: { type: 'arduino-hal' }, + }, + ], + }) + const pm = makePackageManager([pkg], { [pkg.packageId]: manifest }) + const r = new BoardInfoResolver(HALS_PATH, SOURCES_DIR, pm, makeHalsReader({})) + const info = await r.resolve('Weird Board') + expect(info.compiler).toBe('my-future-toolchain') + }) + + it('skips installed packages whose manifest fails to load', async () => { + const broken = makePkg({ packageId: 'com.broken.pkg' }) + const good = makePkg({ packageId: 'com.openplc.arduino', devices: ['arduino-mega'] }) + const pm = makePackageManager([broken, good], { + 'com.broken.pkg': null, + 'com.openplc.arduino': makeManifest(), + }) + const r = new BoardInfoResolver(HALS_PATH, SOURCES_DIR, pm, makeHalsReader({})) + const info = await r.resolve('Arduino Mega') + expect(info.source).toBe('vpp') + expect(info.vppPackageId).toBe('com.openplc.arduino') + }) + + it('finds a board in the second installed package when the first does not have it', async () => { + const a = makePkg({ packageId: 'com.openplc.arduino', devices: ['arduino-mega'] }) + const b = makePkg({ packageId: 'com.openplc.espressif', path: '/fake/user-data/packages/com.openplc.espressif' }) + const pm = makePackageManager([a, b], { + 'com.openplc.arduino': makeManifest(), + 'com.openplc.espressif': makeManifest({ + package: { + id: 'com.openplc.espressif', + name: 'Espressif', + version: '0.1.0', + vendor: { name: 'Espressif', logo: 'assets/logo.png' }, + description: 'desc', + }, + devices: [ + { + id: 'esp32-generic', + name: 'ESP32 Generic', + preview: 'assets/boards/esp32.png', + target: { type: 'arduino-cli', core: 'esp32:esp32', platform: 'esp32:esp32:esp32' }, + hal: { type: 'arduino-hal', source: 'hal/arduino/esp32.cpp' }, + }, + ], + }), + }) + const r = new BoardInfoResolver(HALS_PATH, SOURCES_DIR, pm, makeHalsReader({})) + const info = await r.resolve('ESP32 Generic') + expect(info.vppPackageId).toBe('com.openplc.espressif') + expect(info.halSourceFile).toBe(join(b.path, 'hal', 'arduino', 'esp32.cpp')) + }) + }) + + describe('errors', () => { + it('throws when board exists in neither catalog', async () => { + const r = new BoardInfoResolver(HALS_PATH, SOURCES_DIR, makePackageManager([], {}), makeHalsReader({})) + await expect(r.resolve('Phantom Board')).rejects.toThrow(/not found in hals\.json or any installed VPP package/) + }) + + it('rejects path-traversal in manifest paths', async () => { + const pkg = makePkg() + const manifest = makeManifest({ + devices: [ + { + id: 'evil', + name: 'Evil Board', + preview: 'p.png', + target: { type: 'arduino-cli' }, + hal: { type: 'arduino-hal', source: '../../../etc/passwd' }, + }, + ], + }) + const pm = makePackageManager([pkg], { [pkg.packageId]: manifest }) + const r = new BoardInfoResolver(HALS_PATH, SOURCES_DIR, pm, makeHalsReader({})) + await expect(r.resolve('Evil Board')).rejects.toThrow(/escapes package directory/) + }) + + it('accepts manifest paths that resolve exactly at the package root (no traversal)', async () => { + const pkg = makePkg() + const manifest = makeManifest({ + devices: [ + { + id: 'root-hal', + name: 'Root HAL Board', + preview: 'p.png', + target: { type: 'arduino-cli' }, + hal: { type: 'arduino-hal', source: './hal/arduino/mega_due.cpp' }, + }, + ], + }) + const pm = makePackageManager([pkg], { [pkg.packageId]: manifest }) + const r = new BoardInfoResolver(HALS_PATH, SOURCES_DIR, pm, makeHalsReader({})) + const info = await r.resolve('Root HAL Board') + expect(info.halSourceFile).toBe(join(pkg.path, 'hal', 'arduino', 'mega_due.cpp')) + expect(info.halSourceFile?.startsWith(pkg.path + sep)).toBe(true) + }) + }) +}) diff --git a/src/backend/editor/hardware/board-info-resolver.ts b/src/backend/editor/hardware/board-info-resolver.ts new file mode 100644 index 000000000..bcccd76c4 --- /dev/null +++ b/src/backend/editor/hardware/board-info-resolver.ts @@ -0,0 +1,208 @@ +/** + * Board build info resolver. + * + * Returns per-board compile/upload information from a uniform shape, + * regardless of source (legacy `hals.json` or installed VPP manifest). + * Phase 0 of the VPP compile-pipeline migration (see + * `local-dev-toolkit/backlog/vpp-compile-pipeline-migration.md`): + * additive only — no existing call site is rewired here. Subsequent + * phases swap each `hals.json` read in the compiler module for + * `getBoardBuildInfo()`. + * + * Precedence: `hals.json` wins when a board exists in both catalogs. + * This preserves current behavior; once the builtin entries leave + * `hals.json` (Phase 7) the conflict surface disappears. + */ + +import { readFile } from 'node:fs/promises' +import { join, resolve, sep } from 'node:path' + +import type { PlatformOption } from '../../../middleware/shared/ports/types' +import type { InstalledPackage, PackageManifest } from '../package-manager/types' +import type { BoardInfo, HalsFile } from './types' + +/** Minimal interface from PackageManagerModule that the resolver needs. */ +export interface PackageManagerLike { + listInstalled(): InstalledPackage[] + getInstalledPackageManifest(packageId: string): PackageManifest | null +} + +/** + * Compile/upload-time information about a board, sourced uniformly. + * The compiler reads from this shape only; never directly from + * `hals.json` or the VPP manifest after Phase 6. + */ +export interface BoardBuildInfo { + /** Which catalog provided the entry. */ + source: 'hals' | 'vpp' + /** Toolchain selector: `arduino-cli` | `openplc-compiler` | `simulator`. */ + compiler: string + + // arduino-cli targets ---------------------------------------------------- + core?: string + platform?: string + boardManagerUrl?: string + /** Absolute path to the HAL `.cpp` copied into the Baremetal sketch. */ + halSourceFile?: string + compilerFlags?: { + c_flags?: string[] + cxx_flags?: string[] + ld_flags?: string[] + } + define?: string | string[] + extraArduinoLibraries?: string[] + /** Absolute path to a package-supplied `libraries/` folder, if any. */ + localLibrariesDir?: string + /** Override for arduino-cli's post-link `upload.maximum_data_size` check. */ + maxDataSize?: number + /** + * User-selectable FQBN sub-options surfaced from the VPP manifest. The + * editor renders a dropdown per entry and appends `:=` + * to `platform` at compile/upload time. Absent for boards that don't + * expose variants (Mega, Uno R4, ESP32 boards today). hals.json + * builtins (Simulator, Runtime v3/v4) never carry this field. + */ + platformOptions?: PlatformOption[] + + // runtime-v4 targets ----------------------------------------------------- + pluginType?: 'python' | 'native' + pluginEntry?: string + configTemplate?: string + requirements?: string + + // VPP metadata ----------------------------------------------------------- + vppPackageId?: string + vppDeviceId?: string + vppPackagePath?: string +} + +type JsonReader = (filePath: string) => Promise + +const defaultReader: JsonReader = async (filePath: string) => { + const data = await readFile(filePath, 'utf-8') + return JSON.parse(data) as T +} + +export class BoardInfoResolver { + constructor( + private readonly halsFilePath: string, + private readonly sourcesDirectoryPath: string, + private readonly packageManager: PackageManagerLike, + private readonly readJSONFile: JsonReader = defaultReader, + ) {} + + async resolve(boardName: string): Promise { + const fromHals = await this.#tryHalsLookup(boardName) + if (fromHals) return fromHals + + const fromVpp = this.#tryVppLookup(boardName) + if (fromVpp) return fromVpp + + throw new Error(`Board "${boardName}" not found in hals.json or any installed VPP package`) + } + + async #tryHalsLookup(boardName: string): Promise { + let hals: HalsFile + try { + hals = await this.readJSONFile(this.halsFilePath) + } catch { + return null + } + const entry = hals[boardName] as BoardInfo | undefined + if (!entry) return null + return this.#fromHalsEntry(entry) + } + + #fromHalsEntry(entry: BoardInfo): BoardBuildInfo { + const info: BoardBuildInfo = { source: 'hals', compiler: entry.compiler } + if (entry.core) info.core = entry.core + if (entry.platform) info.platform = entry.platform + if (entry.board_manager_url) info.boardManagerUrl = entry.board_manager_url + if (entry.source) info.halSourceFile = join(this.sourcesDirectoryPath, 'hal', entry.source) + const flags = this.#collectFlags(entry.c_flags, entry.cxx_flags, entry.ld_flags) + if (flags) info.compilerFlags = flags + if (entry.define) info.define = entry.define + if (entry.extra_libraries) info.extraArduinoLibraries = entry.extra_libraries + if (entry.max_data_size !== undefined) info.maxDataSize = entry.max_data_size + return info + } + + #tryVppLookup(boardName: string): BoardBuildInfo | null { + for (const pkg of this.packageManager.listInstalled()) { + const manifest = this.packageManager.getInstalledPackageManifest(pkg.packageId) + if (!manifest) continue + const device = manifest.devices.find((d) => d.name === boardName) + if (!device) continue + return this.#fromVppDevice(device, pkg, manifest) + } + return null + } + + #fromVppDevice( + device: PackageManifest['devices'][number], + pkg: InstalledPackage, + manifest: PackageManifest, + ): BoardBuildInfo { + const info: BoardBuildInfo = { + source: 'vpp', + compiler: this.#mapTargetTypeToCompiler(device.target.type), + vppPackageId: manifest.package.id, + vppDeviceId: device.id, + vppPackagePath: pkg.path, + } + if (device.target.core) info.core = device.target.core + if (device.target.platform) info.platform = device.target.platform + if (device.target.boardManagerUrl) info.boardManagerUrl = device.target.boardManagerUrl + if (device.target.platformOptions && device.target.platformOptions.length > 0) { + info.platformOptions = device.target.platformOptions + } + + if (device.hal.source) info.halSourceFile = this.#resolveWithinPackage(pkg.path, device.hal.source) + if (device.hal.pluginEntry) info.pluginEntry = this.#resolveWithinPackage(pkg.path, device.hal.pluginEntry) + if (device.hal.configTemplate) info.configTemplate = this.#resolveWithinPackage(pkg.path, device.hal.configTemplate) + if (device.hal.requirements) info.requirements = this.#resolveWithinPackage(pkg.path, device.hal.requirements) + if (device.hal.libraries) info.localLibrariesDir = this.#resolveWithinPackage(pkg.path, device.hal.libraries) + + const flags = this.#collectFlags( + device.hal.compilerFlags?.c_flags, + device.hal.compilerFlags?.cxx_flags, + device.hal.compilerFlags?.ld_flags, + ) + if (flags) info.compilerFlags = flags + if (device.hal.define) info.define = device.hal.define + if (device.hal.extraArduinoLibraries) info.extraArduinoLibraries = device.hal.extraArduinoLibraries + + if (device.hal.pluginType === 'python' || device.hal.pluginType === 'native') { + info.pluginType = device.hal.pluginType + } + return info + } + + #mapTargetTypeToCompiler(targetType: string): string { + if (targetType === 'arduino-cli') return 'arduino-cli' + if (targetType === 'runtime-v4') return 'openplc-compiler' + return targetType + } + + #collectFlags(c?: string[], cxx?: string[], ld?: string[]): BoardBuildInfo['compilerFlags'] | undefined { + if (!c && !cxx && !ld) return undefined + const out: NonNullable = {} + if (c) out.c_flags = c + if (cxx) out.cxx_flags = cxx + if (ld) out.ld_flags = ld + return out + } + + /** + * Resolve a manifest-relative path to absolute, with a guard against + * traversal attempts (e.g. `../../etc/passwd`). + */ + #resolveWithinPackage(packagePath: string, relPath: string): string { + const root = resolve(packagePath) + const candidate = resolve(root, relPath) + if (candidate !== root && !candidate.startsWith(root + sep)) { + throw new Error(`Path "${relPath}" escapes package directory ${packagePath}`) + } + return candidate + } +} diff --git a/src/backend/editor/hardware/hardware-module.ts b/src/backend/editor/hardware/hardware-module.ts index 57bfd9e78..e3d4ac064 100644 --- a/src/backend/editor/hardware/hardware-module.ts +++ b/src/backend/editor/hardware/hardware-module.ts @@ -10,6 +10,7 @@ import { produce } from 'immer' import { assertPathContained } from '../utils/path-containment' import { PackageManagerModule } from '../package-manager' import { logger } from '../services/logger-service' +import { type BoardBuildInfo, BoardInfoResolver } from './board-info-resolver' import type { AvailableBoards, HalsFile, SerialPort } from './types' // interface MethodsResult { @@ -136,6 +137,20 @@ class HardwareModule { } } + /** + * Resolve compile/upload info for `boardName` from either hals.json or + * an installed VPP package. Compiler module should call this instead + * of reading hals.json directly. + */ + async getBoardBuildInfo(boardName: string): Promise { + const resolver = new BoardInfoResolver( + join(this.sourcesDirectoryPath, 'boards', 'hals.json'), + this.sourcesDirectoryPath, + new PackageManagerModule(), + ) + return resolver.resolve(boardName) + } + async getAvailableBoards(): Promise { // Construct the path to the hals.json file const halsFilePath = join(this.sourcesDirectoryPath, 'boards', 'hals.json') @@ -265,6 +280,14 @@ class HardwareModule { defaultAin: device.defaults?.pins?.defaultAin, defaultAout: device.defaults?.pins?.defaultAout, }, + // Forward platformOptions only when the manifest actually declares + // some; the UI keys off `platformOptions?.length` to decide whether + // to render the variant dropdown, so leaving it undefined for + // boards that don't expose variants keeps the JSX gate tight. + platformOptions: + device.target.platformOptions && device.target.platformOptions.length > 0 + ? device.target.platformOptions + : undefined, vpp: { packageId: manifest.package.id, deviceId: device.id, diff --git a/src/backend/editor/hardware/index.ts b/src/backend/editor/hardware/index.ts index 3d673b0f3..ca53224b7 100644 --- a/src/backend/editor/hardware/index.ts +++ b/src/backend/editor/hardware/index.ts @@ -1,2 +1,3 @@ +export * from './board-info-resolver' export * from './hardware-module' export * from './types' diff --git a/src/backend/editor/hardware/types.ts b/src/backend/editor/hardware/types.ts index ac6156d5f..e40b9ba0a 100644 --- a/src/backend/editor/hardware/types.ts +++ b/src/backend/editor/hardware/types.ts @@ -1,5 +1,7 @@ import { z } from 'zod/v4' +import type { PlatformOption } from '../../../middleware/shared/ports/types' + const SerialPortSchema = z.object({ name: z.string(), address: z.string(), @@ -115,6 +117,9 @@ type AvailableBoards = Map< defaultDout?: string[] } vpp?: VppMetadata + /** VPP-declared FQBN sub-options (e.g. Nano cpu=atmega328old). Absent + * when the manifest doesn't expose variants — see ports/types.ts. */ + platformOptions?: PlatformOption[] } > diff --git a/src/backend/shared/types/PLC/devices/configuration.ts b/src/backend/shared/types/PLC/devices/configuration.ts index 84fb975c7..c9ffc9f9a 100644 --- a/src/backend/shared/types/PLC/devices/configuration.ts +++ b/src/backend/shared/types/PLC/devices/configuration.ts @@ -6,6 +6,11 @@ const deviceConfigurationSchema = z.object({ runtimeIpAddress: z.string().optional(), compileOnly: z.boolean().default(false), vendorScreenData: z.record(z.string(), z.unknown()).optional(), + // User picks from VPP `target.platformOptions` (e.g. Nano cpu=atmega328old). + // Keyed by option `key`, value is the chosen `values[].id`. The compile and + // upload pipelines fall back to each manifest option's `default` when a key + // is missing here. + selectedPlatformOptions: z.record(z.string(), z.string()).default({}), }) type DeviceConfiguration = z.infer diff --git a/src/frontend/hooks/use-store-selectors.ts b/src/frontend/hooks/use-store-selectors.ts index f21d49525..9a535cbab 100644 --- a/src/frontend/hooks/use-store-selectors.ts +++ b/src/frontend/hooks/use-store-selectors.ts @@ -14,14 +14,28 @@ type RemoteDeviceIOPoint = { } // ===================== Device screen selectors. ===================== +// Stable reference for the empty-platform-options fallback. Returning a +// fresh `{}` from the selector on every call would defeat Zustand's +// reference-equality check and trigger an infinite re-render loop in any +// component subscribed to it (manifests as a blank device-configuration +// screen). The slice's merge function is the primary defence (it always +// populates the field on load), but this fallback covers async edge cases +// where the field could transiently be undefined. +const EMPTY_SELECTED_PLATFORM_OPTIONS: Record = Object.freeze({}) as Record + const boardSelectors = { useAvailableBoards: () => useOpenPLCStore((state) => state.deviceAvailableOptions.availableBoards), useAvailableCommunicationPorts: () => useOpenPLCStore((state) => state.deviceAvailableOptions.availableCommunicationPorts), useDeviceBoard: () => useOpenPLCStore((state) => state.deviceDefinitions.configuration.deviceBoard), useCommunicationPort: () => useOpenPLCStore((state) => state.deviceDefinitions.configuration.communicationPort), + useSelectedPlatformOptions: () => + useOpenPLCStore( + (state) => state.deviceDefinitions.configuration.selectedPlatformOptions ?? EMPTY_SELECTED_PLATFORM_OPTIONS, + ), useSetDeviceBoard: () => useOpenPLCStore((state) => state.deviceActions.setDeviceBoard), useSetCommunicationPort: () => useOpenPLCStore((state) => state.deviceActions.setCommunicationPort), + useSetSelectedPlatformOption: () => useOpenPLCStore((state) => state.deviceActions.setSelectedPlatformOption), useSetAvailableOptions: () => useOpenPLCStore((state) => state.deviceActions.setAvailableOptions), } diff --git a/src/frontend/store/__tests__/device-slice.test.ts b/src/frontend/store/__tests__/device-slice.test.ts index b468d3a67..e6b1328e3 100644 --- a/src/frontend/store/__tests__/device-slice.test.ts +++ b/src/frontend/store/__tests__/device-slice.test.ts @@ -731,6 +731,71 @@ describe('createDeviceSlice', () => { expect(store.getState().deviceDefinitions.configuration.deviceBoard).toBe('Arduino Mega') expect(store.getState().deviceUpdated.updated).toBe(true) }) + + it('clears selectedPlatformOptions when the board actually changes', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceBoard('Arduino Nano') + store.getState().deviceActions.setSelectedPlatformOption('cpu', 'atmega328old') + expect(store.getState().deviceDefinitions.configuration.selectedPlatformOptions).toEqual({ + cpu: 'atmega328old', + }) + + store.getState().deviceActions.setDeviceBoard('Arduino Mega') + expect(store.getState().deviceDefinitions.configuration.selectedPlatformOptions).toEqual({}) + }) + + it('preserves selectedPlatformOptions when setDeviceBoard is called with the same board', () => { + // No-op board reassignment shouldn't trash user's option picks. + const store = makeStore() + store.getState().deviceActions.setDeviceBoard('Arduino Nano') + store.getState().deviceActions.setSelectedPlatformOption('cpu', 'atmega328old') + + store.getState().deviceActions.setDeviceBoard('Arduino Nano') + expect(store.getState().deviceDefinitions.configuration.selectedPlatformOptions).toEqual({ + cpu: 'atmega328old', + }) + }) + }) + + describe('setSelectedPlatformOption', () => { + it('stores a single key/value and marks updated', () => { + const store = makeStore() + store.getState().deviceActions.setSelectedPlatformOption('cpu', 'atmega328old') + expect(store.getState().deviceDefinitions.configuration.selectedPlatformOptions).toEqual({ + cpu: 'atmega328old', + }) + expect(store.getState().deviceUpdated.updated).toBe(true) + }) + + it('merges multiple keys without clobbering siblings', () => { + const store = makeStore() + store.getState().deviceActions.setSelectedPlatformOption('cpu', 'atmega328old') + store.getState().deviceActions.setSelectedPlatformOption('upload_speed', '57600') + expect(store.getState().deviceDefinitions.configuration.selectedPlatformOptions).toEqual({ + cpu: 'atmega328old', + upload_speed: '57600', + }) + }) + + it('overwrites the value when called twice with the same key', () => { + const store = makeStore() + store.getState().deviceActions.setSelectedPlatformOption('cpu', 'atmega328') + store.getState().deviceActions.setSelectedPlatformOption('cpu', 'atmega328old') + expect(store.getState().deviceDefinitions.configuration.selectedPlatformOptions).toEqual({ + cpu: 'atmega328old', + }) + }) + }) + + describe('clearSelectedPlatformOptions', () => { + it('wipes the record and marks updated', () => { + const store = makeStore() + store.getState().deviceActions.setSelectedPlatformOption('cpu', 'atmega328old') + store.getState().deviceActions.setSelectedPlatformOption('upload_speed', '57600') + store.getState().deviceActions.clearSelectedPlatformOptions() + expect(store.getState().deviceDefinitions.configuration.selectedPlatformOptions).toEqual({}) + expect(store.getState().deviceUpdated.updated).toBe(true) + }) }) // ----------------------------------------------------------------------- diff --git a/src/frontend/store/slices/device/data/types.ts b/src/frontend/store/slices/device/data/types.ts index 213b89d19..db96e8973 100644 --- a/src/frontend/store/slices/device/data/types.ts +++ b/src/frontend/store/slices/device/data/types.ts @@ -5,4 +5,5 @@ export const defaultDeviceConfiguration: DeviceConfiguration = { communicationPort: '', runtimeIpAddress: '', compileOnly: false, + selectedPlatformOptions: {}, } diff --git a/src/frontend/store/slices/device/slice.ts b/src/frontend/store/slices/device/slice.ts index 27621848d..a0c2bc128 100644 --- a/src/frontend/store/slices/device/slice.ts +++ b/src/frontend/store/slices/device/slice.ts @@ -332,10 +332,37 @@ const createDeviceSlice: StateCreator = (s setState( produce(({ deviceDefinitions, deviceUpdated }: DeviceSlice) => { deviceUpdated.updated = true + // Wipe platformOption selections when the board changes — they're + // declared per-board in the VPP manifest, so a `cpu=atmega328old` + // choice from a previous Nano session shouldn't bleed into a fresh + // Mega/Opta/etc. setup. Compile-time code falls back to each + // manifest's `default` when the record is empty. + if (deviceDefinitions.configuration.deviceBoard !== deviceBoard) { + deviceDefinitions.configuration.selectedPlatformOptions = {} + } deviceDefinitions.configuration.deviceBoard = deviceBoard }), ) }, + setSelectedPlatformOption: (key, value): void => { + setState( + produce(({ deviceDefinitions, deviceUpdated }: DeviceSlice) => { + deviceUpdated.updated = true + if (!deviceDefinitions.configuration.selectedPlatformOptions) { + deviceDefinitions.configuration.selectedPlatformOptions = {} + } + deviceDefinitions.configuration.selectedPlatformOptions[key] = value + }), + ) + }, + clearSelectedPlatformOptions: (): void => { + setState( + produce(({ deviceDefinitions, deviceUpdated }: DeviceSlice) => { + deviceUpdated.updated = true + deviceDefinitions.configuration.selectedPlatformOptions = {} + }), + ) + }, setCommunicationPort: (communicationPort): void => { setState( produce(({ deviceDefinitions, deviceUpdated }: DeviceSlice) => { @@ -497,6 +524,11 @@ function mergeDeviceConfigWithDefaults( runtimeIpAddress: provided.runtimeIpAddress ?? defaults.runtimeIpAddress, compileOnly: provided.compileOnly ?? defaults.compileOnly, vendorScreenData: provided.vendorScreenData ?? defaults.vendorScreenData, + // Must merge — otherwise loading a project whose configuration.json + // predates platformOptions leaves the field undefined in the store, and + // every selector falling back to `?? {}` returns a fresh literal that + // triggers an infinite Zustand re-render loop (blank device screen). + selectedPlatformOptions: provided.selectedPlatformOptions ?? defaults.selectedPlatformOptions, } } diff --git a/src/frontend/store/slices/device/types.ts b/src/frontend/store/slices/device/types.ts index 346183c8f..2a989e1a2 100644 --- a/src/frontend/store/slices/device/types.ts +++ b/src/frontend/store/slices/device/types.ts @@ -110,6 +110,12 @@ export type DeviceActions = { removePin: () => void updatePin: (updatedData: Partial) => PinUpdateResponse setDeviceBoard: (board: string) => void + /** Set a single platformOption key/value pair (e.g. cpu→atmega328old). + * Marks the device as updated to trigger config persistence. */ + setSelectedPlatformOption: (key: string, value: string) => void + /** Wipe all platformOption selections. Called automatically on board + * change, can also be invoked by UI to reset to manifest defaults. */ + clearSelectedPlatformOptions: () => void setCommunicationPort: (port: string) => void setCompileOnly: (compileOnly: boolean) => void setRuntimeIpAddress: (ipAddress: string) => void diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index ade62096f..a480fcf8a 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -570,6 +570,29 @@ import type { DebuggerTransport, TargetCapabilities } from '../utils/target-capa export type { DebuggerTransport, TargetCapabilities } +/** + * VPP-declared FQBN sub-option (e.g. Nano `cpu=atmega328old`). Shared + * shape between the manifest wire type, the resolved BoardBuildInfo, the + * boards Map exposed to the renderer, and the BoardInfo IPC payload — + * keeping it as a single exported interface so adding a field (say, + * `condition` for conditional visibility) doesn't drift across the four + * sites that reference it. See CompilerModule.applyPlatformOptions for + * how the editor turns a value pick into an FQBN segment. + */ +export interface PlatformOptionValue { + id: string + label: string + help?: string +} + +export interface PlatformOption { + key: string + label: string + default: string + help?: string + values: PlatformOptionValue[] +} + export interface BoardInfo { compiler: CompilerType | (string & {}) core: string @@ -587,6 +610,14 @@ export interface BoardInfo { * field (back-compat for pre-migration data). */ capabilities?: Partial vpp?: VppMetadata + /** + * Mirrors the VPP manifest's `target.platformOptions`. Surfaced on the + * flat BoardInfo (rather than only inside `vpp`) so the device-screen UI + * can decide whether to render the variant dropdown without reaching + * into VPP-specific metadata. Builtins (Simulator / Runtime v3/v4) never + * declare it. + */ + platformOptions?: PlatformOption[] } // --------------------------------------------------------------------------- @@ -670,6 +701,15 @@ export interface PackageManifest { type: string platform?: string core?: string + boardManagerUrl?: string + /** + * User-selectable FQBN sub-options for arduino-cli targets. The editor + * renders a dropdown per entry (next to the board picker) and appends + * `:=` to `platform` at compile and upload time — + * mirroring arduino-cli's boards.txt menu mechanism. See + * manifest.schema.json for the canonical field documentation. + */ + platformOptions?: PlatformOption[] } specs?: Record hal: { @@ -679,6 +719,14 @@ export interface PackageManifest { configTemplate?: string requirements?: string source?: string + compilerFlags?: { + c_flags?: string[] + cxx_flags?: string[] + ld_flags?: string[] + } + define?: string | string[] + extraArduinoLibraries?: string[] + libraries?: string } defaults?: { runtimeIpAddress?: string @@ -762,6 +810,17 @@ export interface DeviceConfiguration { runtimeIpAddress?: string compileOnly: boolean vendorScreenData?: Record + /** + * User's choices for the board's `target.platformOptions` (VPP-declared + * FQBN sub-options like processor variant, USB type, clock speed). + * Keyed by option `key`, value is the chosen `values[].id`. Missing keys + * fall back to the manifest's `default` at compile/upload time. Cleared + * automatically when the selected board changes — platformOptions are + * board-specific and a `cpu=atmega328old` choice on Nano makes no sense + * for Mega. Optional for back-compat with project configs saved before + * this field existed. + */ + selectedPlatformOptions?: Record } // --------------------------------------------------------------------------- diff --git a/src/types/PLC/devices/configuration.ts b/src/types/PLC/devices/configuration.ts index df3523424..3a82dfe33 100644 --- a/src/types/PLC/devices/configuration.ts +++ b/src/types/PLC/devices/configuration.ts @@ -6,6 +6,10 @@ const deviceConfigurationSchema = z.object({ runtimeIpAddress: z.string().optional(), compileOnly: z.boolean().default(false), vendorScreenData: z.record(z.string(), z.unknown()).optional(), + // Mirror of backend/shared schema — keep both in sync. See sibling file + // for the longer explainer; this duplicate exists because the IPC contract + // and the parse pipeline reference distinct schema files today. + selectedPlatformOptions: z.record(z.string(), z.string()).default({}), }) type DeviceConfiguration = z.infer From f22a90ef429186ae4b9c1156426eb8b37566f23f Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Sat, 23 May 2026 15:06:52 +0200 Subject: [PATCH 02/61] feat(compile): isolate strucpp behind a precompiled archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compile every strucpp-touching TU under /src/ with the board's toolchain at -std=gnu++17 before arduino-cli runs, then ask arduino-cli to compile the rest of the sketch normally and link against the resulting libOpenPLCUserLib.a. Rationale: mbed/Renesas/STM32 cores default to gnu++14 with exceptions disabled; strucpp requires gnu++17 with exceptions. Forcing gnu++17 globally through arduino-cli's compiler.cpp.extra_flags poisons the core's own variant.cpp compile because Arduino.h macros (abs, round, min, max) collide with C++ stdlib headers pulled in transitively. Splitting strucpp into a precompiled library keeps the gnu++17 + exceptions surface contained. Compiler module =============== New methods on CompilerModule: - parseShowPropertiesOutput / extractToolchainProperties: extract recipe templates from arduino-cli --show-properties without actually compiling, cached per FQBN. - applyPlatformOptions: compose effective FQBN from VPP selectors in menu-declaration order so arduino-cli's build cache stays warm. - ensureResponseFileStubs: create empty stubs for @response_file references the recipe embeds but arduino-cli only generates lazily during a real compile (ESP32 build_opt.h/file_opts, STM32duino build.opt). Without these stubs gcc treats a missing @path as a literal positional argument and fails with "cannot specify '-o' with '-c' ... with multiple files". Regex matches both POSIX and Windows-style absolute paths. - handlePrecompileUserLib: pre-compile loop. Reads src/*.cpp except arduino.cpp, builds each through the resolved recipe with trailing -std=gnu++17/-fno-rtti plus VPP cxx_flags, ar them into libOpenPLCUserLib.a, then moves sources into precompile/sources/ so arduino-cli won't recompile them. Object files are listed in source order to keep the archive deterministic. Throws an actionable error when compiler.path or compiler.ar.cmd is missing from --show-properties. - installAsArduinoLibrary: stage the archive as an Arduino library under os.tmpdir() with precompiled=full. Path is space-free because arduino-cli tokenises --build-property values on whitespace, and pid-suffixed so concurrent compiles of the same board across processes do not delete each other's staging. handleCompileArduinoProgram now resolves BoardBuildInfo via BoardInfoResolver, composes the effective FQBN with applyPlatformOptions, runs the pre-compile + library install, then asks arduino-cli to compile with --fqbn pinning the variant and --build-property compiler.libraries. ldflags=-L -lOpenPLCUserLib (arduino-cli does not auto-emit -L/-l for precompiled=full libs). VPP cxx_flags propagate to both the pre-compile and arduino-cli paths so ModbusSlave (still ridden by arduino-cli) sees a consistent compile environment; internal -std=gnu++17/-fno-rtti stays pre-compile-only. The detection for the bundled AVR libstdcpp include covers arduino:megaavr alongside arduino:avr — both share avr-gcc and lack . handleGenerateCBlocksCode now writes the dynamic c_blocks_code.cpp into /src/ regardless of runtime so the pre-compile pipeline picks it up with gnu++17. The boardRuntime parameter is preserved on the signature (renamed _boardRuntime) so caller orchestrators keep a stable API; a future runtime divergence may reactivate it. Baremetal refactor ================== ModbusSlave.cpp used to include strucpp's debug_dispatch.hpp directly, pulling C++17 templates into a TU arduino-cli compiles in the core's default standard. The strucpp invocations now live in arduino_runtime_glue.cpp behind five extern "C" wrappers (openplc_debug_array_count, openplc_debug_elem_count, openplc_debug_size, openplc_debug_read, openplc_debug_set). ModbusSlave speaks plain C against a stable ABI and parses in any standard. The static c_blocks_code.cpp baseline drops its strucpp includes; when the project declares C/C++ POUs the dynamic version is emitted into /src/ where the pre-compile pipeline handles it with gnu++17. ModbusSlave.h drops a duplicate scan_counter declaration that clashed with the C-linkage one already in arduino_runtime_glue.h. Baremetal.ino includes OpenPLCUserLib.h so arduino-cli's library discovery picks up the precompiled archive. show_properties_dummy.ino (introduced in the previous commit) is the stub sketch arduino-cli compiles against so {var} interpolations in platform.txt/boards.txt resolve without needing real project sources. Tests cover parseShowPropertiesOutput, applyPlatformOptions (defaults, user overrides, menu order), extractToolchainProperties (cache hit + incomplete recipe error), handlePrecompileUserLib (source filtering, archive order, missing toolchain props, includes substitution), installAsArduinoLibrary (layout + pid isolation), and ensureResponseFileStubs (regex on POSIX/Windows paths, dedup, existing-file preservation, relative-path rejection). Co-Authored-By: Claude Opus 4.7 (1M context) --- resources/sources/Baremetal/Baremetal.ino | 11 +- resources/sources/Baremetal/ModbusSlave.cpp | 26 +- resources/sources/Baremetal/ModbusSlave.h | 8 +- resources/sources/Baremetal/c_blocks_code.cpp | 21 +- .../sources/arduino/arduino_runtime_glue.cpp | 33 ++ .../sources/arduino/arduino_runtime_glue.h | 19 + .../editor/compiler/compiler-module.spec.ts | 469 ++++++++++++++++++ .../editor/compiler/compiler-module.ts | 451 +++++++++++++++-- src/backend/editor/compiler/types.ts | 19 +- 9 files changed, 998 insertions(+), 59 deletions(-) diff --git a/resources/sources/Baremetal/Baremetal.ino b/resources/sources/Baremetal/Baremetal.ino index 63bb837fc..64e232e13 100644 --- a/resources/sources/Baremetal/Baremetal.ino +++ b/resources/sources/Baremetal/Baremetal.ino @@ -21,6 +21,11 @@ #undef abs #undef round +// Triggers arduino-cli's library discovery for the OpenPLCUserLib precompiled +// archive. Without this include arduino-cli still finds the library on disk +// but skips linking against the .a (no header match in the sketch). +#include + #include "openplc.h" #include "defines.h" #include "arduino_runtime_glue.h" @@ -39,12 +44,16 @@ #endif // --------------------------------------------------------------------------- -// AVR: provide sized operator delete (virtual destructors generate this) +// AVR: provide sized operator delete (virtual destructors generate this). +// Non-AVR libstdc++ already declares operator delete(void*, size_t) noexcept; +// redeclaring here causes a signature mismatch on ARM/mbed cores. // --------------------------------------------------------------------------- +#ifdef __AVR__ void operator delete(void* ptr, unsigned int) { free(ptr); } +#endif // --------------------------------------------------------------------------- // I/O Buffer definitions (declared extern in openplc.h, must be defined diff --git a/resources/sources/Baremetal/ModbusSlave.cpp b/resources/sources/Baremetal/ModbusSlave.cpp index cc699d24b..9343700de 100644 --- a/resources/sources/Baremetal/ModbusSlave.cpp +++ b/resources/sources/Baremetal/ModbusSlave.cpp @@ -4,7 +4,13 @@ Copyright (C) 2022 OpenPLC - Thiago Alves */ #include "ModbusSlave.h" -#include "debug_dispatch.hpp" // Phase 4 debugger — strucpp::debug::handle_* +// Debug surface comes via the extern "C" shims in arduino_runtime_glue.h +// (openplc_debug_*) so this TU stays free of strucpp template-heavy headers +// and compiles cleanly in arduino-cli's path with the core's default C++ +// standard (gnu++14 on mbed and others). The shims forward to +// strucpp::debug::handle_* inside arduino_runtime_glue.cpp, which is part +// of the precompiled OpenPLCUserLib archive built with -std=gnu++17. +#include "arduino_runtime_glue.h" //Global Modbus vars struct MBinfo modbus; @@ -1073,7 +1079,7 @@ void writeMultipleCoils(uint16_t startreg, uint16_t numoutputs, uint16_t bytecou // Response: [FC, arrCount, STATUS_OK, (count×arrCount as u16 BE)] void debugInfo() { - uint8_t arrCount = strucpp::debug::handle_array_count(); + uint8_t arrCount = openplc_debug_array_count(); // Cap at what the Modbus frame can hold: 3 header bytes + 2 bytes/array. // Realistic projects have <=10 arrays, so this is never a real limit. @@ -1086,7 +1092,7 @@ void debugInfo() uint16_t pos = 4; for (uint8_t i = 0; i < arrCount; i++) { - uint16_t c = strucpp::debug::handle_elem_count(i); + uint16_t c = openplc_debug_elem_count(i); mb_frame[pos++] = (uint8_t)(c >> 8); mb_frame[pos++] = (uint8_t)(c & 0xFF); } @@ -1128,8 +1134,8 @@ void debugSetTrace(uint8_t arr, uint16_t elem, uint8_t flag, return; } - uint8_t status = strucpp::debug::handle_set( - arr, elem, (bool)flag, (const uint8_t *)value, len); + uint8_t status = openplc_debug_set( + arr, elem, (uint8_t)flag, (const uint8_t *)value, len); mb_frame_len = 3; mb_frame[1] = MB_FC_DEBUG_SET; @@ -1162,7 +1168,7 @@ void debugSetTrace(uint8_t arr, uint16_t elem, uint8_t flag, // size_hi, size_lo, data...] void debugGetTrace(uint8_t arr, uint16_t startidx, uint16_t endidx) { - uint16_t arrCount = strucpp::debug::handle_elem_count(arr); + uint16_t arrCount = openplc_debug_elem_count(arr); if (arrCount == 0 || startidx >= arrCount || endidx >= arrCount || startidx > endidx) { @@ -1178,7 +1184,7 @@ void debugGetTrace(uint8_t arr, uint16_t startidx, uint16_t endidx) for (uint16_t elem = startidx; elem <= endidx; elem++) { - uint16_t varSize = strucpp::debug::handle_size(arr, elem); + uint16_t varSize = openplc_debug_size(arr, elem); // Bounds check — stop packing if this one won't fit. if ((11 + responseSize + varSize) > MAX_MB_FRAME) break; if (varSize == 0) { @@ -1187,7 +1193,7 @@ void debugGetTrace(uint8_t arr, uint16_t startidx, uint16_t endidx) lastElemIdx = elem; continue; } - uint16_t n = strucpp::debug::handle_read(arr, elem, responsePtr); + uint16_t n = openplc_debug_read(arr, elem, responsePtr); if (n == 0) { lastElemIdx = elem; continue; @@ -1272,7 +1278,7 @@ void debugGetTraceList(uint16_t numIndexes, uint8_t *indexArray) uint16_t elem = (uint16_t)localIndex[i * 3 + 1] << 8 | (uint16_t)localIndex[i * 3 + 2]; - uint16_t varSize = strucpp::debug::handle_size(arr, elem); + uint16_t varSize = openplc_debug_size(arr, elem); if (varSize == 0) { // Out-of-bounds or string stub — skip gracefully. @@ -1281,7 +1287,7 @@ void debugGetTraceList(uint16_t numIndexes, uint8_t *indexArray) } if ((response_idx + varSize) > MAX_MB_FRAME) break; - uint16_t n = strucpp::debug::handle_read(arr, elem, &mb_frame[response_idx]); + uint16_t n = openplc_debug_read(arr, elem, &mb_frame[response_idx]); if (n == 0) { lastReqIdx = i; diff --git a/resources/sources/Baremetal/ModbusSlave.h b/resources/sources/Baremetal/ModbusSlave.h index 1177cabce..47236126d 100644 --- a/resources/sources/Baremetal/ModbusSlave.h +++ b/resources/sources/Baremetal/ModbusSlave.h @@ -67,10 +67,10 @@ Copyright (C) 2022 OpenPLC - Thiago Alves #include "Controllino.h" #endif -// Scan-cycle counter defined by the Arduino sketch — reported in -// DEBUG_GET / DEBUG_GET_LIST responses so the editor can detect cycle -// boundaries. -extern uint32_t scan_counter; +// scan_counter is declared in arduino_runtime_glue.h with C linkage; the +// .cpp includes that header to bring the declaration into scope, so this +// file deliberately does NOT redeclare it (a second declaration would +// conflict with the C-linkage one and break the build). // Status codes (match strucpp::debug::STATUS_* in debug_dispatch.hpp, kept // as macros here so the Modbus layer doesn't have to include the C++ diff --git a/resources/sources/Baremetal/c_blocks_code.cpp b/resources/sources/Baremetal/c_blocks_code.cpp index 0df5dab0f..1ffff3097 100644 --- a/resources/sources/Baremetal/c_blocks_code.cpp +++ b/resources/sources/Baremetal/c_blocks_code.cpp @@ -11,19 +11,14 @@ #undef max #endif -// STruC++ runtime types — IEC_BOOL/IEC_INT/.../IEC_REAL all live under -// `namespace strucpp` as IECVar wrappers. The auto-generated POU -// struct (emitted just below this preamble at compile time) refers to -// them as `strucpp::IEC_*` so the user's `*name = 5` write routes -// through `IECVar::operator=` and respects forcing on the IEC side. -// -// The user's setup() / loop() bodies meanwhile keep the historical -// raw-type aliases at file scope for any user-local variables -// (e.g. `IEC_INT my_temp = 0;` stays a plain int16_t). The struct -// field's `strucpp::IEC_INT*` resolves separately and never collides -// with these typedefs. -#include "iec_var.hpp" -#include "iec_string.hpp" +// Static baseline — compiled by arduino-cli in the core's native C++ +// standard (gnu++11 on AVR, gnu++14 on mbed/Renesas, etc.), so it MUST +// stay free of strucpp template-heavy headers. The typedefs below are +// plain C — no namespace, no templates — and parse in every supported +// standard. When the user's project declares C/C++ blocks, the editor +// instead emits the dynamic version under //src/, where +// the pre-compile pipeline picks it up with -std=gnu++17 and links it +// into the precompiled OpenPLCUserLib archive. /*********************/ /* IEC Types defs */ diff --git a/resources/sources/arduino/arduino_runtime_glue.cpp b/resources/sources/arduino/arduino_runtime_glue.cpp index c318a9c0e..7863186aa 100644 --- a/resources/sources/arduino/arduino_runtime_glue.cpp +++ b/resources/sources/arduino/arduino_runtime_glue.cpp @@ -17,6 +17,7 @@ #include "arduino_runtime_glue.h" #include "openplc.h" #include "generated.hpp" +#include "debug_dispatch.hpp" // --------------------------------------------------------------------------- // Storage @@ -171,3 +172,35 @@ void runtime_plc_cycle() strucpp::__CURRENT_TIME_NS += (int64_t)base_tick_ns; } + +// --------------------------------------------------------------------------- +// Debug dispatch shims — C-linkage wrappers around strucpp::debug::handle_*. +// Declared in arduino_runtime_glue.h; ModbusSlave.cpp calls these by name so +// it never has to include the strucpp template-heavy debug_dispatch.hpp. +// --------------------------------------------------------------------------- + +extern "C" uint8_t openplc_debug_array_count() +{ + return strucpp::debug::handle_array_count(); +} + +extern "C" uint16_t openplc_debug_elem_count(uint8_t arr) +{ + return strucpp::debug::handle_elem_count(arr); +} + +extern "C" uint16_t openplc_debug_size(uint8_t arr, uint16_t elem) +{ + return strucpp::debug::handle_size(arr, elem); +} + +extern "C" uint16_t openplc_debug_read(uint8_t arr, uint16_t elem, uint8_t* dest) +{ + return strucpp::debug::handle_read(arr, elem, dest); +} + +extern "C" uint8_t openplc_debug_set(uint8_t arr, uint16_t elem, uint8_t forcing, + const uint8_t* bytes, uint16_t len) +{ + return strucpp::debug::handle_set(arr, elem, forcing != 0, bytes, len); +} diff --git a/resources/sources/arduino/arduino_runtime_glue.h b/resources/sources/arduino/arduino_runtime_glue.h index 7f05ebf58..9f45771ef 100644 --- a/resources/sources/arduino/arduino_runtime_glue.h +++ b/resources/sources/arduino/arduino_runtime_glue.h @@ -42,6 +42,25 @@ void runtime_discover_tasks(); // Per-cycle helpers (call once per scan cycle from scheduler()/loop()). void runtime_plc_cycle(); +// --------------------------------------------------------------------------- +// Debug dispatch shims — extern "C" wrappers around strucpp::debug::handle_*. +// +// ModbusSlave.cpp used to include `debug_dispatch.hpp` directly to reach +// these calls, but that pulled the strucpp template-heavy headers into the +// sketch's TU (compiled by arduino-cli with the core's default C++ standard +// — typically gnu++14 on mbed). The strucpp runtime needs C++17, so the +// direct include broke every non-AVR build. Wrapping the surface here lets +// ModbusSlave.cpp speak plain C against a stable ABI while the actual +// strucpp invocations stay in arduino_runtime_glue.cpp, which is compiled +// into the precompiled OpenPLCUserLib archive with -std=gnu++17. +// --------------------------------------------------------------------------- + +uint8_t openplc_debug_array_count(void); +uint16_t openplc_debug_elem_count(uint8_t arr); +uint16_t openplc_debug_size(uint8_t arr, uint16_t elem); +uint16_t openplc_debug_read(uint8_t arr, uint16_t elem, uint8_t* dest); +uint8_t openplc_debug_set(uint8_t arr, uint16_t elem, uint8_t forcing, const uint8_t* bytes, uint16_t len); + #ifdef __cplusplus } #endif diff --git a/src/backend/editor/compiler/compiler-module.spec.ts b/src/backend/editor/compiler/compiler-module.spec.ts index b2224bb1f..2b80874e4 100644 --- a/src/backend/editor/compiler/compiler-module.spec.ts +++ b/src/backend/editor/compiler/compiler-module.spec.ts @@ -1,4 +1,9 @@ +import { cp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + import { CompilerModule } from './compiler-module' +import type { ToolchainProperties } from './types' jest.mock('electron', () => ({ app: { @@ -18,6 +23,40 @@ jest.mock('electron', () => ({ jest.mock('electron/main', () => ({}), { virtual: true }) +// Stub `cp` from node:fs/promises so handleGenerateArduinoCppFile doesn't +// actually touch disk during tests. Other fs/promises members keep their +// real implementation. +jest.mock('node:fs/promises', () => { + const actual = jest.requireActual('node:fs/promises') + return { ...actual, cp: jest.fn().mockResolvedValue(undefined) } +}) + +// Mock node:child_process so individual tests can swap the exec impl. The +// real `exec` carries a promisify.custom symbol that makes `promisify(exec)` +// resolve with `{ stdout, stderr }` instead of a single value — we replicate +// that here so the production code path through promisify behaves identically. +const execImpl: { + current: (cmd: string) => Promise<{ stdout: string; stderr: string }> +} = { + current: async () => ({ stdout: '', stderr: '' }), +} +jest.mock('node:child_process', () => { + const { promisify } = jest.requireActual('node:util') as typeof import('node:util') + const exec = ( + cmd: string, + _opts: unknown, + cb: (err: Error | null, val?: { stdout: string; stderr: string }) => void, + ) => { + execImpl + .current(cmd) + .then((val) => cb(null, val)) + .catch((err: Error) => cb(err)) + return { kill: () => undefined } + } + ;(exec as unknown as { [k: symbol]: unknown })[promisify.custom] = (cmd: string) => execImpl.current(cmd) + return { exec, spawn: jest.fn() } +}) + // CompilerModule uses process.resourcesPath (Electron-specific) when not in dev mode. // In Jest, NODE_ENV is 'test', so DEVELOPMENT_MODE is false. Provide a fallback. ;(process as unknown as { resourcesPath: string }).resourcesPath ??= process.cwd() @@ -59,4 +98,434 @@ describe('CompilerModule', () => { expect(info).toContain('Operating System') expect(info).toContain('Logical CPU Cores') }) + + describe('applyPlatformOptions (VPP target.platformOptions → FQBN)', () => { + const nanoOptions = [ + { + key: 'cpu', + label: 'Processor', + default: 'atmega328', + values: [ + { id: 'atmega328', label: 'New Bootloader' }, + { id: 'atmega328old', label: 'Old Bootloader' }, + ], + }, + ] + + it('returns the platform unchanged when no platformOptions are declared', () => { + expect(CompilerModule.applyPlatformOptions('arduino:avr:mega', undefined, undefined)).toBe('arduino:avr:mega') + expect(CompilerModule.applyPlatformOptions('arduino:avr:mega', [], { cpu: 'whatever' })).toBe('arduino:avr:mega') + }) + + it('uses the option default when no user selection is provided', () => { + expect(CompilerModule.applyPlatformOptions('arduino:avr:nano', nanoOptions, undefined)).toBe( + 'arduino:avr:nano:cpu=atmega328', + ) + expect(CompilerModule.applyPlatformOptions('arduino:avr:nano', nanoOptions, {})).toBe( + 'arduino:avr:nano:cpu=atmega328', + ) + }) + + it('honours a user selection over the default', () => { + expect(CompilerModule.applyPlatformOptions('arduino:avr:nano', nanoOptions, { cpu: 'atmega328old' })).toBe( + 'arduino:avr:nano:cpu=atmega328old', + ) + }) + + it('falls back to default for missing keys when multiple options exist', () => { + const multiOpt = [ + ...nanoOptions, + { + key: 'upload_speed', + label: 'Upload Speed', + default: '115200', + values: [ + { id: '115200', label: '115200' }, + { id: '57600', label: '57600' }, + ], + }, + ] + // Only cpu is overridden — upload_speed should use its default. + expect(CompilerModule.applyPlatformOptions('arduino:avr:nano', multiOpt, { cpu: 'atmega328old' })).toBe( + 'arduino:avr:nano:cpu=atmega328old:upload_speed=115200', + ) + }) + + it('preserves option declaration order in the resulting FQBN', () => { + // arduino-cli expects sub-options concatenated in their menu-declaration + // order — swapping would change the cache key and miss the warm cache. + const ordered = [ + { key: 'a', label: 'A', default: 'a1', values: [{ id: 'a1', label: 'a1' }] }, + { key: 'b', label: 'B', default: 'b1', values: [{ id: 'b1', label: 'b1' }] }, + { key: 'c', label: 'C', default: 'c1', values: [{ id: 'c1', label: 'c1' }] }, + ] + expect(CompilerModule.applyPlatformOptions('foo:bar:baz', ordered, { c: 'cX', a: 'aY' })).toBe( + 'foo:bar:baz:a=aY:b=b1:c=cX', + ) + }) + }) + + describe('parseShowPropertiesOutput (pre-compile pipeline foundation)', () => { + it('parses key=value lines into a flat record', () => { + const stdout = ['build.arch=MBED_OPTA', 'build.board=OPTA', 'compiler.cpp.cmd=arm-none-eabi-g++', ''].join('\n') + expect(CompilerModule.parseShowPropertiesOutput(stdout)).toEqual({ + 'build.arch': 'MBED_OPTA', + 'build.board': 'OPTA', + 'compiler.cpp.cmd': 'arm-none-eabi-g++', + }) + }) + + it('preserves "=" in values (e.g. -DARDUINO=10607)', () => { + const stdout = 'compiler.define=-DARDUINO=\nbuild.extra_flags=-DCM4=0x60000000\n' + expect(CompilerModule.parseShowPropertiesOutput(stdout)).toEqual({ + 'compiler.define': '-DARDUINO=', + 'build.extra_flags': '-DCM4=0x60000000', + }) + }) + + it('captures empty values without dropping the key', () => { + const stdout = 'compiler.cpp.extra_flags=\nbuild.usb_flags=' + expect(CompilerModule.parseShowPropertiesOutput(stdout)).toEqual({ + 'compiler.cpp.extra_flags': '', + 'build.usb_flags': '', + }) + }) + + it('captures the full recipe.cpp.o.pattern with embedded quotes and placeholders', () => { + // Real recipe shape from arduino:mbed_opta@4.5.0 + const recipe = + '"/path/to/arm-none-eabi-g++" -c -nostdlib "@/path/with spaces/defines.txt" ' + + '-DARDUINO=10607 {includes} "{source_file}" -o "{object_file}"' + const stdout = `recipe.cpp.o.pattern=${recipe}\n` + const props = CompilerModule.parseShowPropertiesOutput(stdout) + expect(props['recipe.cpp.o.pattern']).toBe(recipe) + }) + }) + + describe('installAsArduinoLibrary (precompiled library layout)', () => { + const fs = jest.requireActual('node:fs') as typeof import('node:fs') + const fsPromises = jest.requireActual('node:fs/promises') as typeof import('node:fs/promises') + const cpMock = cp as jest.MockedFunction + let tempCompilationPath: string + let dummyArchivePath: string + + beforeEach(() => { + tempCompilationPath = fs.mkdtempSync(join(tmpdir(), 'openplc-precompile-spec-')) + dummyArchivePath = join(tempCompilationPath, 'precompile', 'libOpenPLCUserLib.a') + fs.mkdirSync(join(tempCompilationPath, 'precompile'), { recursive: true }) + fs.writeFileSync(dummyArchivePath, '!\n', 'utf-8') + cpMock.mockImplementation(fsPromises.cp) + }) + + afterEach(() => { + fs.rmSync(tempCompilationPath, { recursive: true, force: true }) + cpMock.mockReset().mockResolvedValue(undefined) + }) + + it('stages the library under os.tmpdir() (path must be space-free for the linker -L flag)', async () => { + const { libraryDir, archDir } = await compilerModule.installAsArduinoLibrary({ + compilationPath: tempCompilationPath, + archivePath: dummyArchivePath, + toolchainArch: 'cortex-m7', + }) + expect(libraryDir.startsWith(jest.requireActual('node:os').tmpdir())).toBe(true) + expect(libraryDir).not.toMatch(/\s/) + expect(archDir).toBe(join(libraryDir, 'src', 'cortex-m7')) + expect(fs.existsSync(join(libraryDir, 'library.properties'))).toBe(true) + expect(fs.existsSync(join(libraryDir, 'src', 'OpenPLCUserLib.h'))).toBe(true) + expect(fs.existsSync(join(archDir, 'libOpenPLCUserLib.a'))).toBe(true) + }) + + it('marks the library as precompiled=full so arduino-cli skips source compilation', async () => { + const { libraryDir } = await compilerModule.installAsArduinoLibrary({ + compilationPath: tempCompilationPath, + archivePath: dummyArchivePath, + toolchainArch: 'avr', + }) + const props = fs.readFileSync(join(libraryDir, 'library.properties'), 'utf-8') + expect(props).toMatch(/^precompiled=full$/m) + expect(props).toMatch(/^name=OpenPLCUserLib$/m) + expect(props).toMatch(/^architectures=\*$/m) + }) + + it('writes a stub header that documents its purpose without redeclaring symbols', async () => { + const { libraryDir } = await compilerModule.installAsArduinoLibrary({ + compilationPath: tempCompilationPath, + archivePath: dummyArchivePath, + toolchainArch: 'cortex-m7', + }) + const header = fs.readFileSync(join(libraryDir, 'src', 'OpenPLCUserLib.h'), 'utf-8') + expect(header).toContain('#pragma once') + expect(header).toContain('stub') + expect(header).not.toMatch(/^extern\s+/m) + }) + + it('isolates concurrent same-board compiles by suffixing the staging path with process.pid', async () => { + const { libraryDir } = await compilerModule.installAsArduinoLibrary({ + compilationPath: tempCompilationPath, + archivePath: dummyArchivePath, + toolchainArch: 'cortex-m4', + }) + // Reset-on-stage-collision is documented in the method; the pid suffix + // is what prevents a concurrent process from deleting our staging dir + // mid-build (md5 alone would collide for the same compilationPath). + expect(libraryDir).toMatch(new RegExp(`-${process.pid}/OpenPLCUserLib$`)) + }) + }) + + describe('ensureResponseFileStubs (ESP32/STM32duino response-file workaround)', () => { + // Method is `private static` — exposed for direct testing via a typed + // façade so the regex and EEXIST handling can be exercised in isolation + // without going through the full pre-compile path. + const ensureStubs = ( + CompilerModule as unknown as { + ensureResponseFileStubs(cmd: string, log: (s: string) => void): Promise + } + ).ensureResponseFileStubs.bind(CompilerModule) + const fs = jest.requireActual('node:fs') as typeof import('node:fs') + const noopLog = jest.fn() + let workDir: string + + beforeEach(() => { + noopLog.mockClear() + workDir = fs.mkdtempSync(join(tmpdir(), 'openplc-stubs-spec-')) + }) + + afterEach(() => { + fs.rmSync(workDir, { recursive: true, force: true }) + }) + + it('creates an empty stub for a quoted POSIX @-file the recipe references but does not exist', async () => { + const missing = join(workDir, 'sub', 'build_opt.h') + const cmd = `arm-none-eabi-g++ -c "@${missing}" -DARDUINO=10607 -o foo.o` + await ensureStubs(cmd, noopLog) + expect(fs.existsSync(missing)).toBe(true) + expect(fs.statSync(missing).size).toBe(0) + expect(noopLog).toHaveBeenCalledWith(expect.stringContaining(`Stubbed empty response file: ${missing}`), 'info') + }) + + it('matches Windows-style @C:\\... and @C:/... absolute paths from the recipe', async () => { + // Windows paths can't actually be created on POSIX hosts, so we assert + // via the side-effect: the regex must extract them so the mkdir/writeFile + // attempt happens (and would surface a mkdir error). + const winBackslash = 'C:\\Users\\dev\\AppData\\arduino\\sketches\\hash\\file_opts' + const winSlash = 'C:/Users/dev/AppData/arduino/sketches/hash/build_opt.h' + const cmd = `arm-zephyr-eabi-g++ -c "@${winBackslash}" "@${winSlash}" -o foo.o` + const originalCwd = process.cwd() + process.chdir(workDir) + try { + await ensureStubs(cmd, noopLog).catch(() => { + /* mkdir of "C:" on POSIX can fail — regex match still asserted via the log */ + }) + } finally { + process.chdir(originalCwd) + } + const logCalls = noopLog.mock.calls.flat().join('\n') + expect(logCalls).toContain(winBackslash) + expect(logCalls).toContain(winSlash) + }) + + it('does not overwrite existing response files', async () => { + const existing = join(workDir, 'preexisting.txt') + fs.writeFileSync(existing, 'real flags here', 'utf-8') + const cmd = `g++ -c "@${existing}" foo.cpp` + await ensureStubs(cmd, noopLog) + expect(fs.readFileSync(existing, 'utf-8')).toBe('real flags here') + expect(noopLog).not.toHaveBeenCalled() + }) + + it('deduplicates repeated @-references so a path is stubbed at most once', async () => { + const target = join(workDir, 'shared.opt') + const cmd = `g++ -c "@${target}" "@${target}" "@${target}"` + await ensureStubs(cmd, noopLog) + expect(fs.existsSync(target)).toBe(true) + expect(noopLog).toHaveBeenCalledTimes(1) + }) + + it('ignores @-tokens with relative paths (not absolute → not a response file we own)', async () => { + // Relative-path @-args either reference workspace-local files (which + // we shouldn't touch) or are non-path arguments — the regex deliberately + // only matches absolute paths. + const relative = 'subdir/file.txt' + const cmd = `g++ -c "@${relative}" foo.cpp` + await ensureStubs(cmd, noopLog) + expect(noopLog).not.toHaveBeenCalled() + }) + }) + + describe('extractToolchainProperties (recipe extraction)', () => { + it('caches successful results so a second call for the same FQBN skips arduino-cli', async () => { + let execCallCount = 0 + execImpl.current = async () => { + execCallCount += 1 + return { + stdout: [ + 'recipe.cpp.o.pattern=avr-g++ {source_file} -o {object_file}', + 'recipe.c.o.pattern=avr-gcc {source_file} -o {object_file}', + 'recipe.ar.pattern=avr-ar rcs {archive_file_path} {object_file}', + 'compiler.path=/avr/', + 'compiler.ar.cmd=avr-ar', + ].join('\n'), + stderr: '', + } + } + const first = await compilerModule.extractToolchainProperties('arduino:avr:uno') + const second = await compilerModule.extractToolchainProperties('arduino:avr:uno') + expect(first).toBe(second) // same reference — cache hit, not re-parsed + expect(execCallCount).toBe(1) + }) + + it('throws a descriptive error when arduino-cli returns an incomplete recipe set', async () => { + // Missing recipe.c.o.pattern and recipe.ar.pattern — usually signals + // that the core for this FQBN isn't installed. + execImpl.current = async () => ({ + stdout: 'recipe.cpp.o.pattern=g++ {source_file} -o {object_file}\n', + stderr: '', + }) + await expect(compilerModule.extractToolchainProperties('unknown:vendor:board')).rejects.toThrow( + /incomplete recipe set.*core for this board is not installed/s, + ) + }) + }) + + describe('handlePrecompileUserLib (pre-compile loop)', () => { + const fs = jest.requireActual('node:fs') as typeof import('node:fs') + const noopLog = jest.fn() + let buildDir: string + let srcDir: string + let extractSpy: jest.SpyInstance + + const cannedProps: ToolchainProperties = { + fqbn: 'arduino:avr:uno', + properties: { + 'compiler.path': '/fake/avr/bin/', + 'compiler.ar.cmd': 'avr-ar', + 'compiler.ar.flags': 'rcs', + 'build.arch': 'AVR', + }, + recipeCpp: 'avr-g++ -c {source_file} {includes} {includes} -o {object_file}', + recipeC: 'avr-gcc -c {source_file} {includes} -o {object_file}', + recipeAr: 'avr-ar rcs {archive_file_path} {object_file}', + } + + beforeEach(() => { + noopLog.mockClear() + buildDir = fs.mkdtempSync(join(tmpdir(), 'openplc-precompile-loop-')) + srcDir = join(buildDir, 'src') + fs.mkdirSync(srcDir, { recursive: true }) + extractSpy = jest + .spyOn(compilerModule, 'extractToolchainProperties') + .mockResolvedValue(cannedProps as unknown as ToolchainProperties) + }) + + afterEach(() => { + extractSpy.mockRestore() + fs.rmSync(buildDir, { recursive: true, force: true }) + }) + + it('throws when src/ contains no compilable TUs (only the board HAL arduino.cpp would be excluded)', async () => { + fs.writeFileSync(join(srcDir, 'arduino.cpp'), '// HAL\n', 'utf-8') + await expect( + compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:avr:uno', + handleOutputData: noopLog, + }), + ).rejects.toThrow(/no \.cpp sources found under/) + }) + + it('excludes arduino.cpp from the compile set so the board HAL stays with arduino-cli', async () => { + fs.writeFileSync(join(srcDir, 'arduino.cpp'), '// HAL\n', 'utf-8') + fs.writeFileSync(join(srcDir, 'pou_MAIN.cpp'), '// pou\n', 'utf-8') + fs.writeFileSync(join(srcDir, 'configuration.cpp'), '// config\n', 'utf-8') + + const execCalls: string[] = [] + execImpl.current = async (cmd) => { + execCalls.push(cmd) + return { stdout: '', stderr: '' } + } + + await compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:avr:uno', + handleOutputData: noopLog, + }) + + // Two compile invocations + one ar invocation = 3 exec calls. + expect(execCalls).toHaveLength(3) + const compileCmds = execCalls.slice(0, 2).join('\n') + expect(compileCmds).toContain('pou_MAIN.cpp') + expect(compileCmds).toContain('configuration.cpp') + expect(compileCmds).not.toContain('arduino.cpp') + }) + + it('substitutes every {includes} occurrence (recipes that interpolate it twice must not leak literals)', async () => { + fs.writeFileSync(join(srcDir, 'pou_MAIN.cpp'), '// pou\n', 'utf-8') + + const execCalls: string[] = [] + execImpl.current = async (cmd) => { + execCalls.push(cmd) + return { stdout: '', stderr: '' } + } + + await compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:avr:uno', + handleOutputData: noopLog, + }) + + // recipeCpp had `{includes} {includes}` (double occurrence). After + // substitution there must be ZERO literal `{includes}` left. + expect(execCalls[0]).not.toContain('{includes}') + }) + + it('preserves source-file order in the ar archive members (deterministic build output)', async () => { + // Three sources to verify ordering; the pre-compile builds objectFiles + // synchronously from the source list so order is stable regardless of + // concurrent compile resolution timing. + fs.writeFileSync(join(srcDir, 'a_first.cpp'), '// a\n', 'utf-8') + fs.writeFileSync(join(srcDir, 'm_middle.cpp'), '// m\n', 'utf-8') + fs.writeFileSync(join(srcDir, 'z_last.cpp'), '// z\n', 'utf-8') + + let arCmd = '' + execImpl.current = async (cmd) => { + if (cmd.includes('avr-ar')) arCmd = cmd + return { stdout: '', stderr: '' } + } + + await compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:avr:uno', + handleOutputData: noopLog, + }) + + const aPos = arCmd.indexOf('a_first.o') + const mPos = arCmd.indexOf('m_middle.o') + const zPos = arCmd.indexOf('z_last.o') + expect(aPos).toBeGreaterThan(-1) + expect(mPos).toBeGreaterThan(aPos) + expect(zPos).toBeGreaterThan(mPos) + }) + + it('throws an actionable error when compiler.path or compiler.ar.cmd is missing from --show-properties', async () => { + extractSpy.mockResolvedValue({ + ...cannedProps, + properties: { + /* compiler.path & compiler.ar.cmd intentionally absent */ + }, + } as unknown as ToolchainProperties) + + fs.writeFileSync(join(srcDir, 'pou_MAIN.cpp'), '// pou\n', 'utf-8') + + execImpl.current = async () => ({ stdout: '', stderr: '' }) + + await expect( + compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:avr:uno', + handleOutputData: noopLog, + }), + ).rejects.toThrow(/compiler\.path \+ compiler\.ar\.cmd.*core is likely not installed/s) + }) + }) }) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index b29d6a34d..7551664e7 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -104,16 +104,17 @@ 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 type { CompileLibraryResult, PlatformOption } 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' import type { PackageManifest } from '../package-manager' +import { BoardInfoResolver } from '../hardware' import { PackageManagerModule } from '../package-manager' import { CreateXMLFile } from '../utils' -import type { ArduinoCoreControl, HalsFile } from './types' +import type { ArduinoCoreControl, HalsFile, ToolchainProperties } from './types' interface MethodsResult { success: boolean @@ -170,6 +171,11 @@ class CompilerModule { strucppRuntimeDir: string + // Memoised arduino-cli `--show-properties=expanded` output keyed by FQBN. + // Resetting requires a fresh CompilerModule instance — adequate for the + // MVP where the editor recreates the module per compile session. + #toolchainPropsCache: Map = new Map() + // ############################################################################ // =========================== Static properties ============================== // ############################################################################ @@ -231,6 +237,45 @@ class CompilerModule { return JSON.parse(data) as T } + /** + * Append user-selected (or default) FQBN sub-options to the base platform + * string. Used by handleCompileArduinoProgram and the orchestrator's + * upload step to apply the VPP-declared `target.platformOptions` choices + * the user made on the device screen (e.g. Nano `cpu=atmega328old`). + * + * Pure / deterministic: every key in `platformOptions` becomes a segment + * `:=` appended in declaration order. Missing entries in + * `selected` fall back to each option's `default`. Returns the input + * `platform` verbatim when the manifest declares no platformOptions. + */ + static applyPlatformOptions( + platform: string, + platformOptions: PlatformOption[] | undefined, + selected: Record | undefined, + ): string { + if (!platformOptions || platformOptions.length === 0) return platform + const segments: string[] = [] + for (const opt of platformOptions) { + const chosen = selected?.[opt.key] ?? opt.default + segments.push(`${opt.key}=${chosen}`) + } + return `${platform}:${segments.join(':')}` + } + + // Pure parser for `arduino-cli compile --show-properties=expanded` stdout. + // Values can contain '=' (e.g. -DARDUINO=10607) so we split on the FIRST '=' + // only. Empty lines and lines without '=' are silently skipped. + static parseShowPropertiesOutput(stdout: string): Record { + const properties: Record = {} + for (const line of stdout.split('\n')) { + if (!line) continue + const eqIdx = line.indexOf('=') + if (eqIdx < 0) continue + properties[line.slice(0, eqIdx)] = line.slice(eqIdx + 1) + } + return properties + } + // ############################################################################ // =========================== Private methods ================================ // ############################################################################ @@ -317,6 +362,14 @@ class CompilerModule { return join(electronApp.getAppPath(), 'node_modules', 'strucpp', 'src', 'runtime', 'include') } + // Path to the empty sketch arduino-cli compiles against when extracting + // toolchain properties via `--show-properties=expanded`. The sketch itself + // is never linked — its only role is to give arduino-cli a valid sketch + // structure so the recipe templates resolve. + #constructShowPropertiesDummyPath(): string { + return join(this.sourceDirectoryPath, 'show_properties_dummy') + } + /** * Resolve a board target to the arduino-cli core ID * (`arduino-cli core install` target — e.g. `arduino:avr`). @@ -334,6 +387,24 @@ class CompilerModule { return halsFileContent[board]?.['core'] ?? null } + /** + * Pull the user's platformOption selections out of a project's + * devices/configuration.json. Returns `{}` on any read/parse error — + * a missing file or stale config without the field means the user + * never touched the dropdown, so the compile path should fall back to + * each manifest option's `default`. + */ + async #readSelectedPlatformOptions(projectPath: string): Promise> { + const configPath = join(projectPath, 'devices', 'configuration.json') + try { + const raw = await readFile(configPath, 'utf-8') + const parsed = JSON.parse(raw) as { selectedPlatformOptions?: Record } + return parsed.selectedPlatformOptions ?? {} + } catch { + return {} + } + } + async #getBoardRuntime(board: string) { const halsFileContent = await CompilerModule.readJSONFile(this.halsFilePath) if (halsFileContent[board]) { @@ -458,6 +529,55 @@ class CompilerModule { return installedLibraries } + /** + * Ask arduino-cli to resolve every recipe property for a given FQBN and + * return it as a typed struct. Backbone of the pre-compile pipeline: + * because `recipe.cpp.o.pattern` / `recipe.c.o.pattern` / `recipe.ar.pattern` + * arrive fully expanded (every {build.*} / {compiler.*} / {runtime.*} + * already substituted), the editor can drive the toolchain directly with + * only the per-TU placeholders (`{source_file}`, `{object_file}`, + * `{includes}`, `{archive_file_path}`) left to fill in. + * + * Results are memoised in-process per FQBN — show-properties takes ~300 ms + * on a warm arduino-cli and the same FQBN is queried multiple times within + * a single compile session. + */ + async extractToolchainProperties(fqbn: string): Promise { + const cached = this.#toolchainPropsCache.get(fqbn) + if (cached) return cached + + let binaryPath = this.arduinoCliBinaryPath + if (CompilerModule.HOST_PLATFORM === 'win32') binaryPath += '.exe' + + const dummySketchPath = this.#constructShowPropertiesDummyPath() + const baseArgs = this.arduinoCliBaseParameters.map((p) => `"${p}"`).join(' ') + const execAsync = promisify(exec) + + // `--show-properties=expanded` tells arduino-cli to evaluate every + // `{var}` interpolation in `platform.txt` / `boards.txt` before printing + // — without `=expanded`, recipes come back with raw `{compiler.path}` + // placeholders that would be useless for direct toolchain invocation. + const cmd = `"${binaryPath}" compile --fqbn "${fqbn}" --show-properties=expanded "${dummySketchPath}" ${baseArgs}` + + const { stdout } = await execAsync(cmd, { maxBuffer: 8 * 1024 * 1024 }) + + const properties = CompilerModule.parseShowPropertiesOutput(stdout) + const recipeCpp = properties['recipe.cpp.o.pattern'] + const recipeC = properties['recipe.c.o.pattern'] + const recipeAr = properties['recipe.ar.pattern'] + if (!recipeCpp || !recipeC || !recipeAr) { + throw new Error( + `arduino-cli --show-properties for "${fqbn}" returned an incomplete recipe set ` + + `(cpp=${Boolean(recipeCpp)}, c=${Boolean(recipeC)}, ar=${Boolean(recipeAr)}). ` + + `This usually means the core for this board is not installed.`, + ) + } + + const props: ToolchainProperties = { fqbn, properties, recipeCpp, recipeC, recipeAr } + this.#toolchainPropsCache.set(fqbn, props) + return props + } + // ++ =========================== Defines.h methods ==========================++ async createMD5Hash(content: string): Promise { const crypto = await import('node:crypto') @@ -1184,7 +1304,11 @@ class CompilerModule { async handleGenerateCBlocksCode( projectData: ProjectDataWithCppPous, compilationPath: string, - boardRuntime: string, + // Reserved on the signature so caller orchestrators (Arduino vs Runtime + // v4) keep a stable API surface; both runtimes share /src/ today + // because both need gnu++17 for the strucpp IECVar wrappers, but a + // future runtime might branch off this discriminator again. + _boardRuntime: string, handleOutputData: HandleOutputDataCallback, ) { const originalCppPous = projectData.originalCppPous || [] @@ -1195,17 +1319,12 @@ class CompilerModule { } const cppPous = originalCppPous - // generateCBlocksCode now emits the full file (baseline + per-POU - // wrappers + user code), so we overwrite rather than append. The - // static Baremetal/c_blocks_code.cpp baseline is now redundant for - // projects with C++ POUs but stays as a benign empty unit for - // Arduino projects without any. + // Written into /src/ so the pre-compile loop picks it up with + // -std=gnu++17. The static Baremetal/c_blocks_code.cpp baseline stays + // strucpp-free and is compiled by arduino-cli in the core's native + // standard. const codeContent = generateCBlocksCode(cppPous) - - const codeFilePath = - boardRuntime === 'openplc-compiler' - ? join(compilationPath, 'src', 'c_blocks_code.cpp') - : join(compilationPath, 'examples', 'Baremetal', 'c_blocks_code.cpp') + const codeFilePath = join(compilationPath, 'src', 'c_blocks_code.cpp') try { await writeFile(codeFilePath, codeContent, { encoding: 'utf8' }) @@ -1265,7 +1384,237 @@ class CompilerModule { }) } + // Stub empty files for `@response_file` paths a recipe references but + // that arduino-cli would only generate during a real compile (ESP32 + + // STM32duino). GCC treats missing `@file` as a literal positional + // argument → "cannot specify '-o' with '-c' ... with multiple files". + // Empty is the canonical default arduino-cli itself writes when no + // per-project build_opt customization exists. + private static async ensureResponseFileStubs( + cmd: string, + handleOutputData: HandleOutputDataCallback, + ): Promise { + // Match `@` (POSIX `/...` or Windows `C:\...` / `C:/...`), + // with optional surrounding quote from the recipe substitution. + const responseFileRe = /["']?@([A-Za-z]:[\\/][^"'\s]+|\/[^"'\s]+)/g + const paths = new Set() + let match: RegExpExecArray | null + while ((match = responseFileRe.exec(cmd)) !== null) { + paths.add(match[1]) + } + for (const responsePath of paths) { + if (existsSync(responsePath)) continue + await mkdir(path.dirname(responsePath), { recursive: true }) + try { + await writeFile(responsePath, '', { flag: 'wx' }) + handleOutputData(`[precompile] Stubbed empty response file: ${responsePath}`, 'info') + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err + } + } + } + + // Pre-compile every .cpp under `/src/` (excluding the + // board HAL `arduino.cpp`) with the board's toolchain at -std=gnu++17 and + // archive into `libOpenPLCUserLib.a`. Keeps the gnu++17 + exceptions + // surface contained — arduino-cli compiles the core and sketch in + // whatever standard the core ships with. + async handlePrecompileUserLib({ + compilationPath, + fqbn, + extraCxxFlags = [], + handleOutputData, + }: { + compilationPath: string + fqbn: string + extraCxxFlags?: string[] + handleOutputData: HandleOutputDataCallback + }): Promise<{ archivePath: string; toolchainArch: string; objectFiles: string[] }> { + const tcProps = await this.extractToolchainProperties(fqbn) + + const srcDir = join(compilationPath, 'src') + const baremetalDir = join(compilationPath, 'examples', 'Baremetal') + + // arduino.cpp is the board HAL; arduino-cli must compile it so it sees + // the core's external libraries (Ethernet, SPI, ...) it discovers via + // sketch-tree includes. + const allEntries = await readdir(srcDir) + const sources = allEntries + .filter((name) => name.endsWith('.cpp') && name !== 'arduino.cpp') + .map((name) => join(srcDir, name)) + + if (sources.length === 0) { + throw new Error(`handlePrecompileUserLib: no .cpp sources found under ${srcDir}`) + } + + const objDir = join(compilationPath, 'precompile', 'obj') + await mkdir(objDir, { recursive: true }) + + const includes = [`"-I${srcDir}"`, `"-I${baremetalDir}"`].join(' ') + + // Appended after the recipe so the last `-std=` wins over the core's + // implicit gnu++14. extraCxxFlags carries VPP per-board cxx_flags. + const trailingFlags = ['-std=gnu++17', '-fno-rtti', ...extraCxxFlags].join(' ') + + const execAsync = promisify(exec) + const execMaxBuffer = 16 * 1024 * 1024 + + handleOutputData( + `[precompile] Compiling ${sources.length} TU(s) with toolchain for ${fqbn}...`, + 'info', + ) + + // Build the .o path list synchronously up-front so the archive members + // land in source-file order regardless of the concurrent compile result. + const objectFiles = sources.map((sourcePath) => + join(objDir, path.basename(sourcePath).replace(/\.cpp$/, '.o')), + ) + + const compilePromises = sources.map(async (sourcePath, idx) => { + const objectPath = objectFiles[idx] + + const cmd = + tcProps.recipeCpp + .replaceAll('{source_file}', sourcePath) + .replaceAll('{object_file}', objectPath) + .replaceAll('{includes}', includes) + + ' ' + + trailingFlags + + await CompilerModule.ensureResponseFileStubs(cmd, handleOutputData) + + try { + const { stdout, stderr } = await execAsync(cmd, { maxBuffer: execMaxBuffer }) + // gcc emits warnings on stderr even on success — both streams logged as info. + if (stdout) handleOutputData(stdout, 'info') + if (stderr) handleOutputData(stderr, 'info') + handleOutputData(`[precompile] ✓ ${path.basename(sourcePath)}`, 'info') + } catch (err) { + const reason = err instanceof Error ? err.message : String(err) + handleOutputData(`[precompile] ✗ ${path.basename(sourcePath)}: ${reason}`, 'error') + throw new Error(`Pre-compile failed for ${path.basename(sourcePath)}: ${reason}`) + } + }) + + await Promise.all(compilePromises) + + // Build the ar command manually instead of using recipe.ar.pattern — + // cores disagree on placeholder semantics: mbed uses `{archive_file_path}` + // (full path, usable) while AVR uses `{archive_file}` (bare filename with + // build cache dir baked into the recipe, which would write to the wrong place). + const archivePath = join(compilationPath, 'precompile', 'libOpenPLCUserLib.a') + const quotedObjects = objectFiles.map((p) => `"${p}"`).join(' ') + const compilerPath = tcProps.properties['compiler.path'] + const arName = tcProps.properties['compiler.ar.cmd'] + if (!compilerPath || !arName) { + throw new Error( + `Toolchain archive invocation requires compiler.path + compiler.ar.cmd ` + + `from arduino-cli --show-properties for "${fqbn}" ` + + `(got compiler.path="${compilerPath ?? ''}", compiler.ar.cmd="${arName ?? ''}"). ` + + `The board's core is likely not installed.`, + ) + } + const arFlags = tcProps.properties['compiler.ar.flags'] ?? 'rcs' + const arExtraFlags = tcProps.properties['compiler.ar.extra_flags'] ?? '' + const archiverBin = `"${compilerPath}${arName}"` + const archiveCmd = `${archiverBin} ${arFlags} ${arExtraFlags} "${archivePath}" ${quotedObjects}` + + handleOutputData( + `[precompile] Archiving ${objectFiles.length} object(s) into libOpenPLCUserLib.a...`, + 'info', + ) + await execAsync(archiveCmd, { maxBuffer: execMaxBuffer }) + + // Move pre-compiled sources out of src/ so arduino-cli library discovery + // doesn't recompile them; preserved under precompile/sources/ for debug. + const sourcesStash = join(compilationPath, 'precompile', 'sources') + await mkdir(sourcesStash, { recursive: true }) + for (const sourcePath of sources) { + const stashedPath = join(sourcesStash, path.basename(sourcePath)) + await fs.rename(sourcePath, stashedPath) + } + handleOutputData( + `[precompile] Moved ${sources.length} compiled source(s) to precompile/sources/ (won't be recompiled by arduino-cli)`, + 'info', + ) + + // build.architecture (mbed: cortex-m7, lowercase) preferred over build.arch + // (AVR/SAMD: uppercased). Lowercase is the Arduino precompiled-lib convention. + const toolchainArch = ( + tcProps.properties['build.architecture'] ?? + tcProps.properties['build.arch'] ?? + 'unknown' + ).toLowerCase() + + handleOutputData( + `[precompile] Pre-compile complete (${objectFiles.length} TUs → libOpenPLCUserLib.a, arch=${toolchainArch})`, + 'info', + ) + + return { archivePath, toolchainArch, objectFiles } + } + + // Wrap the precompiled archive as an Arduino library so arduino-cli's + // library discovery picks it up via `#include ` and + // links the archive without recompiling anything inside. Staged under + // os.tmpdir() because arduino-cli's --build-property tokenises on + // whitespace and ignores quotes, so a build path with spaces (e.g. + // "Arduino Mega") would break the -L flag and link input list. + async installAsArduinoLibrary({ + compilationPath, + archivePath, + toolchainArch, + }: { + compilationPath: string + archivePath: string + toolchainArch: string + }): Promise<{ libraryDir: string; archDir: string }> { + // Hash isolates concurrent compiles of different boards; pid suffix + // isolates concurrent compiles of the SAME board across processes so + // the rm-then-mkdir reset below never deletes another process's stage. + const buildHash = createHash('md5').update(compilationPath).digest('hex').slice(0, 12) + const stagingRoot = join(os.tmpdir(), `openplc-precompile-${buildHash}-${process.pid}`) + const libraryDir = join(stagingRoot, 'OpenPLCUserLib') + const srcDir = join(libraryDir, 'src') + const archDir = join(srcDir, toolchainArch) + + // Wipe leftover from a previous compile so a stale .a doesn't shadow a + // fresh one (e.g. when the board switches between toolchains). + await fs.rm(stagingRoot, { recursive: true, force: true }) + await mkdir(archDir, { recursive: true }) + + const targetArchive = join(archDir, 'libOpenPLCUserLib.a') + await cp(archivePath, targetArchive) + + const propsContent = [ + 'name=OpenPLCUserLib', + 'version=1.0.0', + 'author=OpenPLC Editor', + 'maintainer=OpenPLC Editor ', + 'sentence=Pre-compiled OpenPLC user code archive', + 'paragraph=Pre-compiled gnu++17 archive of generated PLC code, isolated from arduino-cli core compilation.', + 'category=Other', + 'architectures=*', + 'precompiled=full', + '', + ].join('\n') + await writeFile(join(libraryDir, 'library.properties'), propsContent, 'utf-8') + + const headerContent = [ + '// Auto-generated stub for OpenPLCUserLib.', + '// Real declarations come via arduino_runtime_glue.h in /src/.', + '// This file exists solely to trigger arduino-cli library discovery for the', + '// precompiled archive in this directory.', + '#pragma once', + '', + ].join('\n') + await writeFile(join(srcDir, 'OpenPLCUserLib.h'), headerContent, 'utf-8') + + return { libraryDir, archDir } + } + async handleCompileArduinoProgram({ + boardTarget, boardHalsContent, compilationPath, handleOutputData, @@ -1277,23 +1626,60 @@ class CompilerModule { handleOutputData('Clean build requested — arduino-cli cache will be invalidated.', 'info') } - // The AVR toolchain doesn't ship a C++ stdlib; we bundle a - // freestanding port at resources/sources/avr-libstdcpp/include - // and pass it via -I. Electron's user-data dir on macOS is - // `~/Library/Application Support//`, and arduino-cli's - // recipe substitution gets confused by quoted paths with embedded - // spaces — so mirror the headers into a no-space cache directory - // on first compile. Versioned cache key self-invalidates on - // editor upgrades that ship new headers. - const avrLibStdCppInclude = boardHalsContent['core']?.startsWith('arduino:avr') - ? await this.ensureAvrLibStdCppCache() - : undefined - - // Shared with openplc-web's compiler-adapter — single source of - // truth for arduino-cli compile argv composition. Editor passes - // `-j 0` (parallel: default true) to saturate cores on developer - // machines; web passes parallel: false because compiler-service - // multiplexes many clients in nsjail sandboxes. + // Resolve unified board info (VPP-aware, falls back to hals.json) so the + // pre-compile + arduino-cli paths see the same compilerFlags/platformOptions. + const resolver = new BoardInfoResolver(this.halsFilePath, this.sourceDirectoryPath, new PackageManagerModule()) + const info = await resolver.resolve(boardTarget) + if (!info.platform) { + throw new Error(`Board "${boardTarget}" does not declare a platform (FQBN)`) + } + + // Compose effective FQBN by appending platformOptions selected by the user + // (or each option's manifest default). projectPath is derived from + // compilationPath (always `/build/`). + const projectPath = path.dirname(path.dirname(compilationPath)) + const selectedPlatformOptions = await this.#readSelectedPlatformOptions(projectPath) + const effectiveFqbn = CompilerModule.applyPlatformOptions( + info.platform, + info.platformOptions, + selectedPlatformOptions, + ) + + // The AVR/megaavr toolchain ships but no C++ wrappers; we + // bundle a freestanding port at resources/sources/avr-libstdcpp/. + // Electron's user-data dir on macOS has spaces, which break arduino-cli's + // compiler.cpp.extra_flags substitution — mirror to a no-space cache. + const avrLibStdCppInclude = + info.core?.startsWith('arduino:avr') || info.core?.startsWith('arduino:megaavr') + ? await this.ensureAvrLibStdCppCache() + : undefined + + // Pre-compile strucpp-touching TUs at -std=gnu++17 into libOpenPLCUserLib.a. + // Flag policy: VPP cxx_flags + AVR libstdcpp -I flow into BOTH the pre-compile + // and the arduino-cli pass (ModbusSlave still rides arduino-cli); internal + // -std=gnu++17/-fno-rtti stays pre-compile-only. + const cxxFlags: string[] = info.compilerFlags?.cxx_flags ? [...info.compilerFlags.cxx_flags] : [] + if (avrLibStdCppInclude) cxxFlags.push(`-I${avrLibStdCppInclude}`) + + const { archivePath, toolchainArch } = await this.handlePrecompileUserLib({ + compilationPath, + fqbn: effectiveFqbn, + extraCxxFlags: cxxFlags, + handleOutputData, + }) + const { archDir: precompiledArchDir } = await this.installAsArduinoLibrary({ + compilationPath, + archivePath, + toolchainArch, + }) + + // Shared with openplc-web's compiler-adapter — single source of truth for + // arduino-cli compile argv composition. We append our overrides after: + // --fqbn (effective with platformOptions), VPP-resolved cxx_flags into + // compiler.cpp.extra_flags, and compiler.libraries.ldflags injecting + // -L -lOpenPLCUserLib (arduino-cli doesn't auto-emit -L/-l for + // libraries marked precompiled=full). + const cxxFlagsArg = cxxFlags.length > 0 ? ['--build-property', `compiler.cpp.extra_flags=${cxxFlags.join(' ')}`] : [] const buildProjectFlags = [ ...buildArduinoCliCompileArgs(boardHalsContent, { sketchPath: join(baremetalPath, 'Baremetal.ino'), @@ -1301,6 +1687,11 @@ class CompilerModule { avrLibStdCppInclude, cleanBuild, }), + '--fqbn', + effectiveFqbn, + ...cxxFlagsArg, + '--build-property', + `compiler.libraries.ldflags=-L${precompiledArchDir} -lOpenPLCUserLib`, ...this.arduinoCliBaseParameters, ] diff --git a/src/backend/editor/compiler/types.ts b/src/backend/editor/compiler/types.ts index a8fc5f24f..6e774d0a9 100644 --- a/src/backend/editor/compiler/types.ts +++ b/src/backend/editor/compiler/types.ts @@ -51,6 +51,23 @@ const HalsFileSchema = z.record(z.string(), BoardInfoSchema) type HalsFile = z.infer +/** + * Subset of `arduino-cli compile --show-properties=expanded` output captured + * by CompilerModule.extractToolchainProperties. We keep the full property map + * for forward compatibility but surface the three recipes the pre-compile + * pipeline actually consumes (cpp/c/ar). Both `recipeCpp` and `recipeAr` come + * fully token-expanded by arduino-cli — only `{source_file}`, `{object_file}`, + * `{archive_file_path}`, and `{includes}` remain unresolved, and those are + * filled in by the editor when it invokes the toolchain directly. + */ +type ToolchainProperties = { + fqbn: string + properties: Record + recipeCpp: string + recipeC: string + recipeAr: string +} + export { ArduinoCliConfigSchema, ArduinoCoreControlSchema, BoardInfoSchema, HalsFileSchema } -export type { ArduinoCliConfig, ArduinoCoreControl, BoardInfo, HalsFile } +export type { ArduinoCliConfig, ArduinoCoreControl, BoardInfo, HalsFile, ToolchainProperties } From 16b1151ae324e6158593b870a103b3a6f05e3957 Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Sat, 23 May 2026 15:25:02 +0200 Subject: [PATCH 03/61] fix(compile): pass --library precompiledLibDir for arduino-cli discovery handleCompileArduinoProgram was setting compiler.libraries.ldflags (-L -lOpenPLCUserLib) but missed `--library `. arduino-cli's library discovery is header-based: without the staging directory on its library search path, the `#include ` in Baremetal.ino resolves to nothing ("Alternatives for OpenPLCUserLib.h: []"), so the .a never enters the link line and the build fails with "fatal error: OpenPLCUserLib.h: No such file or directory". The ldflags property addresses the link side; --library addresses the discovery side. Both are required. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/backend/editor/compiler/compiler-module.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 7551664e7..6f07e695c 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -1667,7 +1667,7 @@ class CompilerModule { extraCxxFlags: cxxFlags, handleOutputData, }) - const { archDir: precompiledArchDir } = await this.installAsArduinoLibrary({ + const { libraryDir: precompiledLibDir, archDir: precompiledArchDir } = await this.installAsArduinoLibrary({ compilationPath, archivePath, toolchainArch, @@ -1676,9 +1676,11 @@ class CompilerModule { // Shared with openplc-web's compiler-adapter — single source of truth for // arduino-cli compile argv composition. We append our overrides after: // --fqbn (effective with platformOptions), VPP-resolved cxx_flags into - // compiler.cpp.extra_flags, and compiler.libraries.ldflags injecting - // -L -lOpenPLCUserLib (arduino-cli doesn't auto-emit -L/-l for - // libraries marked precompiled=full). + // compiler.cpp.extra_flags, --library pointing at the staged precompiled + // OpenPLCUserLib (so arduino-cli's discovery finds the header via + // Baremetal.ino's #include ), and compiler.libraries. + // ldflags injecting -L -lOpenPLCUserLib (arduino-cli doesn't + // auto-emit -L/-l for libraries marked precompiled=full). const cxxFlagsArg = cxxFlags.length > 0 ? ['--build-property', `compiler.cpp.extra_flags=${cxxFlags.join(' ')}`] : [] const buildProjectFlags = [ ...buildArduinoCliCompileArgs(boardHalsContent, { @@ -1690,6 +1692,8 @@ class CompilerModule { '--fqbn', effectiveFqbn, ...cxxFlagsArg, + '--library', + precompiledLibDir, '--build-property', `compiler.libraries.ldflags=-L${precompiledArchDir} -lOpenPLCUserLib`, ...this.arduinoCliBaseParameters, From 2a4aa11644b2740602ae34330e023247500bd258 Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Sat, 23 May 2026 18:47:06 +0200 Subject: [PATCH 04/61] fix(compile): route Arduino HAL + upload paths through BoardInfoResolver and stage the precompiled archive per-core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coupled issues surfaced when actually building VPP-installed boards end-to-end. Each is fixed below. VPP boards crash on direct hals.json lookups ============================================ handleGenerateArduinoCppFile read halsFileContent[boardTarget]['source'] directly. Post-VPP migration hals.json only carries Simulator and the two Runtime targets, so every other board returns undefined and "Cannot read properties of undefined (reading 'source')" surfaces during Step 11. The compile and upload steps had the same shape: handleCompileArduinoProgram passed halsContent[boardTarget] (undefined) into buildArduinoCliCompileArgs, and the post-compile flow used halsContent[boardTarget]['platform'] for both the simulator HEX path and arduino-cli's --fqbn argument. All three sites now consult BoardInfoResolver: - handleGenerateArduinoCppFile copies from info.halSourceFile (resolver picks hals.json or the installed VPP manifest). - handleCompileArduinoProgram synthesises the BoardHalsCompileEntry from info inside the method. boardHalsContent stays on the signature for API stability but is no longer the data source. - compileProgram resolves info once via a lazy getResolvedBoardInfo helper and feeds info.platform into the simulator HEX path derivation and into handleUploadProgram. Precompiled archive subdir varies per core ========================================== arduino-cli's precompiled-lib resolver picks the archive subdir from a different platform property depending on the core: build.mcu for AVR ("atmega2560"), build.architecture for mbed ("cortex-m7"), build.arch for everything else. Writing only to src// worked on mbed but missed on Mega ("Precompiled library in .../src/atmega2560 not found"). handlePrecompileUserLib now returns archCandidates (a deduped lowercase list of every property that could name the subdir), and installAsArduinoLibrary lays the .a under every candidate. The first candidate doubles as the canonical archDir used for compiler.libraries. ldflags -L injection. The compileEntry derived from info skips the toolchainArch single-string return value entirely. Simulator pin mapping should not depend on the project state ============================================================ The simulator HAL expects PINMASK_DIN/DOUT/AIN/AOUT defined, but the values are a property of the virtual device — not of the user's project. The UI already hides the pin-mapping table when Simulator is selected; defines.h generation needed the same rule so a stale "D0" saved when the user was previously testing a real board would not poison the build with "'D0' was not declared in this scope". New static CompilerModule.synthesizeSimulatorPinMapping(boardEntry) parses the comma-separated default_* strings from hals.json into typed DevicePin entries. handleGenerateDefinitionsFile substitutes this for the on-disk devicePinMapping when boardRuntime === 'simulator'. pin-mapping.json on disk stays untouched so a board switch back to Mega/Nano restores the user's wiring. Tests cover synthesizeSimulatorPinMapping (parse + empty + trailing comma) and installAsArduinoLibrary's archCandidates layout (multiple subdirs written for AVR-style boards). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/compiler/compiler-module.spec.ts | 65 ++++++- .../editor/compiler/compiler-module.ts | 180 ++++++++++++++---- 2 files changed, 201 insertions(+), 44 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.spec.ts b/src/backend/editor/compiler/compiler-module.spec.ts index 2b80874e4..4bdb6b46a 100644 --- a/src/backend/editor/compiler/compiler-module.spec.ts +++ b/src/backend/editor/compiler/compiler-module.spec.ts @@ -202,6 +202,49 @@ describe('CompilerModule', () => { }) }) + describe('synthesizeSimulatorPinMapping (Simulator pin layout from hals.json)', () => { + it('parses the comma-separated default_* strings into typed DevicePin entries', () => { + const pins = CompilerModule.synthesizeSimulatorPinMapping({ + default_din: '62, 63, 64, 65', + default_dout: '14, 15, 16', + default_ain: 'A0, A1', + default_aout: '2, 3', + }) + // Order: digitalInput, analogInput, digitalOutput, analogOutput + expect(pins.map((p) => p.pin)).toEqual(['62', '63', '64', '65', 'A0', 'A1', '14', '15', '16', '2', '3']) + expect(pins.map((p) => p.pinType)).toEqual([ + 'digitalInput', + 'digitalInput', + 'digitalInput', + 'digitalInput', + 'analogInput', + 'analogInput', + 'digitalOutput', + 'digitalOutput', + 'digitalOutput', + 'analogOutput', + 'analogOutput', + ]) + }) + + it('skips empty/whitespace entries so a trailing comma does not produce a blank pin', () => { + const pins = CompilerModule.synthesizeSimulatorPinMapping({ + default_din: '62, ,63,', + default_dout: '', + default_ain: '', + default_aout: '', + }) + expect(pins).toEqual([ + { pin: '62', pinType: 'digitalInput', address: '', alias: '' }, + { pin: '63', pinType: 'digitalInput', address: '', alias: '' }, + ]) + }) + + it('returns an empty list when the board declares no pin defaults', () => { + expect(CompilerModule.synthesizeSimulatorPinMapping({})).toEqual([]) + }) + }) + describe('installAsArduinoLibrary (precompiled library layout)', () => { const fs = jest.requireActual('node:fs') as typeof import('node:fs') const fsPromises = jest.requireActual('node:fs/promises') as typeof import('node:fs/promises') @@ -226,7 +269,7 @@ describe('CompilerModule', () => { const { libraryDir, archDir } = await compilerModule.installAsArduinoLibrary({ compilationPath: tempCompilationPath, archivePath: dummyArchivePath, - toolchainArch: 'cortex-m7', + archCandidates: ['cortex-m7'], }) expect(libraryDir.startsWith(jest.requireActual('node:os').tmpdir())).toBe(true) expect(libraryDir).not.toMatch(/\s/) @@ -236,11 +279,25 @@ describe('CompilerModule', () => { expect(fs.existsSync(join(archDir, 'libOpenPLCUserLib.a'))).toBe(true) }) + it('lays the archive under every candidate subdir so arduino-cli finds it regardless of per-core convention', async () => { + const { libraryDir } = await compilerModule.installAsArduinoLibrary({ + compilationPath: tempCompilationPath, + archivePath: dummyArchivePath, + // AVR Mega exposes build.mcu=atmega2560 + build.arch=AVR; arduino-cli + // picks atmega2560 for the precompiled-lib subdir on this core, while + // mbed cores pick build.architecture (e.g. cortex-m7). Writing to both + // dirs sidesteps the per-core mapping. + archCandidates: ['atmega2560', 'avr'], + }) + expect(fs.existsSync(join(libraryDir, 'src', 'atmega2560', 'libOpenPLCUserLib.a'))).toBe(true) + expect(fs.existsSync(join(libraryDir, 'src', 'avr', 'libOpenPLCUserLib.a'))).toBe(true) + }) + it('marks the library as precompiled=full so arduino-cli skips source compilation', async () => { const { libraryDir } = await compilerModule.installAsArduinoLibrary({ compilationPath: tempCompilationPath, archivePath: dummyArchivePath, - toolchainArch: 'avr', + archCandidates: ['avr'], }) const props = fs.readFileSync(join(libraryDir, 'library.properties'), 'utf-8') expect(props).toMatch(/^precompiled=full$/m) @@ -252,7 +309,7 @@ describe('CompilerModule', () => { const { libraryDir } = await compilerModule.installAsArduinoLibrary({ compilationPath: tempCompilationPath, archivePath: dummyArchivePath, - toolchainArch: 'cortex-m7', + archCandidates: ['cortex-m7'], }) const header = fs.readFileSync(join(libraryDir, 'src', 'OpenPLCUserLib.h'), 'utf-8') expect(header).toContain('#pragma once') @@ -264,7 +321,7 @@ describe('CompilerModule', () => { const { libraryDir } = await compilerModule.installAsArduinoLibrary({ compilationPath: tempCompilationPath, archivePath: dummyArchivePath, - toolchainArch: 'cortex-m4', + archCandidates: ['cortex-m4'], }) // Reset-on-stage-collision is documented in the method; the pid suffix // is what prevents a concurrent process from deleting our staging dir diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 6f07e695c..339f15b71 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -111,7 +111,7 @@ import type { MessagePortMain } from 'electron/main' import JSZip from 'jszip' import type { PackageManifest } from '../package-manager' -import { BoardInfoResolver } from '../hardware' +import { type BoardBuildInfo, BoardInfoResolver } from '../hardware' import { PackageManagerModule } from '../package-manager' import { CreateXMLFile } from '../utils' import type { ArduinoCoreControl, HalsFile, ToolchainProperties } from './types' @@ -276,6 +276,33 @@ class CompilerModule { return properties } + // Synthesise the simulator's pin mapping from its hals.json default_* + // strings. The simulator HAL (simulator.cpp) expects PINMASK_DIN/DOUT/AIN/AOUT + // populated, but those values are a property of the virtual device — not + // of the user's project. The UI already hides the pin table when Simulator + // is selected; this override makes the compile path follow the same rule + // so a stale pin saved from a previous board doesn't poison the build. + static synthesizeSimulatorPinMapping(boardEntry: { + default_din?: string + default_dout?: string + default_ain?: string + default_aout?: string + }): DevicePin[] { + const parse = (s: string | undefined): string[] => + s + ? s + .split(',') + .map((p) => p.trim()) + .filter(Boolean) + : [] + const pins: DevicePin[] = [] + for (const pin of parse(boardEntry.default_din)) pins.push({ pin, pinType: 'digitalInput', address: '', alias: '' }) + for (const pin of parse(boardEntry.default_ain)) pins.push({ pin, pinType: 'analogInput', address: '', alias: '' }) + for (const pin of parse(boardEntry.default_dout)) pins.push({ pin, pinType: 'digitalOutput', address: '', alias: '' }) + for (const pin of parse(boardEntry.default_aout)) pins.push({ pin, pinType: 'analogOutput', address: '', alias: '' }) + return pins + } + // ############################################################################ // =========================== Private methods ================================ // ############################################################################ @@ -1173,11 +1200,20 @@ class CompilerModule { // INFO: If null, only the define value // 3.3. IO Config defines DEFINES_CONTENT += '//IO Config\n' + // Simulator overrides the project's pin-mapping with the board's canonical + // hals.json default_* layout. The pin-mapping table is hidden in the UI for + // this target, but the file persists so switching back to a real board + // preserves the user's wiring. Sourcing from hals.json here keeps the + // compile aligned with what the UI advertises. + const effectivePinMapping = + boardRuntime === 'simulator' && boardEntry + ? CompilerModule.synthesizeSimulatorPinMapping(boardEntry) + : devicePinMapping // 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') + const digitalInputPins = effectivePinMapping.filter((pin) => pin.pinType === 'digitalInput') + const analogInputPins = effectivePinMapping.filter((pin) => pin.pinType === 'analogInput') + const digitalOutputPins = effectivePinMapping.filter((pin) => pin.pinType === 'digitalOutput') + const analogOutputPins = effectivePinMapping.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` @@ -1257,15 +1293,20 @@ class CompilerModule { async handleGenerateArduinoCppFile(projectPath: string, boardTarget: string) { let result: MethodsResult = { success: false } - const halsFileContent = await CompilerModule.readJSONFile(this.halsFilePath) - - const boardSourceFile = halsFileContent[boardTarget]['source'] + // Source the HAL .cpp from BoardInfoResolver so the same code path works + // for legacy hals.json entries and installed VPP packages (where only + // Simulator / Runtime v3 / Runtime v4 remain in hals.json; every Arduino + // board lives in a VPP). + const resolver = new BoardInfoResolver(this.halsFilePath, this.sourceDirectoryPath, new PackageManagerModule()) + const info = await resolver.resolve(boardTarget) + if (!info.halSourceFile) { + throw new Error(`Board "${boardTarget}" does not declare a HAL source file`) + } - const boardSourceFilePath = join(this.sourceDirectoryPath, 'hal', boardSourceFile) const arduinoCppFilePath = join(projectPath, 'build', boardTarget, 'src', 'arduino.cpp') try { - await cp(boardSourceFilePath, arduinoCppFilePath, { recursive: true }) + await cp(info.halSourceFile, arduinoCppFilePath, { recursive: true }) result = { success: true, data: arduinoCppFilePath } } catch (error) { throw new Error(`Error copying Arduino source file: ${(error as Error).message}`) @@ -1429,7 +1470,7 @@ class CompilerModule { fqbn: string extraCxxFlags?: string[] handleOutputData: HandleOutputDataCallback - }): Promise<{ archivePath: string; toolchainArch: string; objectFiles: string[] }> { + }): Promise<{ archivePath: string; archCandidates: string[]; objectFiles: string[] }> { const tcProps = await this.extractToolchainProperties(fqbn) const srcDir = join(compilationPath, 'src') @@ -1538,20 +1579,32 @@ class CompilerModule { 'info', ) - // build.architecture (mbed: cortex-m7, lowercase) preferred over build.arch - // (AVR/SAMD: uppercased). Lowercase is the Arduino precompiled-lib convention. - const toolchainArch = ( - tcProps.properties['build.architecture'] ?? - tcProps.properties['build.arch'] ?? - 'unknown' - ).toLowerCase() + // arduino-cli's precompiled-lib resolution picks ONE subdir per core, + // and the convention varies: AVR uses build.mcu ("atmega2560"), mbed + // uses build.architecture ("cortex-m7"), others fall back to build.arch. + // We collect every candidate so installAsArduinoLibrary can lay the + // archive under all of them — duplicating a few-hundred-KB file in the + // /tmp staging is cheaper than maintaining a per-core mapping. The + // first entry doubles as the canonical `archDir` used for -L injection. + const archCandidates = Array.from( + new Set( + [ + tcProps.properties['build.mcu'], + tcProps.properties['build.architecture'], + tcProps.properties['build.arch'], + ] + .filter((s): s is string => Boolean(s)) + .map((s) => s.toLowerCase()), + ), + ) + if (archCandidates.length === 0) archCandidates.push('unknown') handleOutputData( - `[precompile] Pre-compile complete (${objectFiles.length} TUs → libOpenPLCUserLib.a, arch=${toolchainArch})`, + `[precompile] Pre-compile complete (${objectFiles.length} TUs → libOpenPLCUserLib.a, archs=${archCandidates.join(',')})`, 'info', ) - return { archivePath, toolchainArch, objectFiles } + return { archivePath, archCandidates, objectFiles } } // Wrap the precompiled archive as an Arduino library so arduino-cli's @@ -1563,12 +1616,16 @@ class CompilerModule { async installAsArduinoLibrary({ compilationPath, archivePath, - toolchainArch, + archCandidates, }: { compilationPath: string archivePath: string - toolchainArch: string + archCandidates: string[] }): Promise<{ libraryDir: string; archDir: string }> { + if (archCandidates.length === 0) { + throw new Error('installAsArduinoLibrary: archCandidates must contain at least one entry') + } + // Hash isolates concurrent compiles of different boards; pid suffix // isolates concurrent compiles of the SAME board across processes so // the rm-then-mkdir reset below never deletes another process's stage. @@ -1576,15 +1633,22 @@ class CompilerModule { const stagingRoot = join(os.tmpdir(), `openplc-precompile-${buildHash}-${process.pid}`) const libraryDir = join(stagingRoot, 'OpenPLCUserLib') const srcDir = join(libraryDir, 'src') - const archDir = join(srcDir, toolchainArch) // Wipe leftover from a previous compile so a stale .a doesn't shadow a // fresh one (e.g. when the board switches between toolchains). await fs.rm(stagingRoot, { recursive: true, force: true }) - await mkdir(archDir, { recursive: true }) - const targetArchive = join(archDir, 'libOpenPLCUserLib.a') - await cp(archivePath, targetArchive) + // Lay the archive under every candidate subdir — arduino-cli's + // precompiled-lib resolver picks ONE based on a per-core convention + // (build.mcu for AVR, build.architecture for mbed, etc.). The first + // candidate is treated as canonical for the returned archDir, which is + // what -L points to via compiler.libraries.ldflags. + const archDir = join(srcDir, archCandidates[0]) + for (const arch of archCandidates) { + const candidateDir = join(srcDir, arch) + await mkdir(candidateDir, { recursive: true }) + await cp(archivePath, join(candidateDir, 'libOpenPLCUserLib.a')) + } const propsContent = [ 'name=OpenPLCUserLib', @@ -1661,7 +1725,7 @@ class CompilerModule { const cxxFlags: string[] = info.compilerFlags?.cxx_flags ? [...info.compilerFlags.cxx_flags] : [] if (avrLibStdCppInclude) cxxFlags.push(`-I${avrLibStdCppInclude}`) - const { archivePath, toolchainArch } = await this.handlePrecompileUserLib({ + const { archivePath, archCandidates } = await this.handlePrecompileUserLib({ compilationPath, fqbn: effectiveFqbn, extraCxxFlags: cxxFlags, @@ -1670,20 +1734,35 @@ class CompilerModule { const { libraryDir: precompiledLibDir, archDir: precompiledArchDir } = await this.installAsArduinoLibrary({ compilationPath, archivePath, - toolchainArch, + archCandidates, }) // Shared with openplc-web's compiler-adapter — single source of truth for - // arduino-cli compile argv composition. We append our overrides after: - // --fqbn (effective with platformOptions), VPP-resolved cxx_flags into - // compiler.cpp.extra_flags, --library pointing at the staged precompiled - // OpenPLCUserLib (so arduino-cli's discovery finds the header via - // Baremetal.ino's #include ), and compiler.libraries. - // ldflags injecting -L -lOpenPLCUserLib (arduino-cli doesn't - // auto-emit -L/-l for libraries marked precompiled=full). + // arduino-cli compile argv composition. The compile entry is synthesised + // from BoardInfoResolver's BoardBuildInfo (covers legacy hals.json AND + // VPP boards uniformly); the boardHalsContent argument stays on the + // signature for backward compat but is no longer the data source — for + // VPP-installed boards it would be undefined. + // + // After the shared helper composes its baseline args we append: + // --fqbn (effective with platformOptions applied), + // compiler.cpp.extra_flags (VPP cxx_flags), + // --library (so arduino-cli's discovery finds the + // header via Baremetal.ino's #include ), + // compiler.libraries.ldflags=-L -lOpenPLCUserLib (arduino-cli + // doesn't auto-emit -L/-l for libraries marked precompiled=full). + const compileEntry = { + platform: info.platform, + core: info.core, + c_flags: info.compilerFlags?.c_flags, + cxx_flags: info.compilerFlags?.cxx_flags, + ld_flags: info.compilerFlags?.ld_flags, + max_data_size: info.maxDataSize, + } + void boardHalsContent // accepted for signature compat; data comes from `info` const cxxFlagsArg = cxxFlags.length > 0 ? ['--build-property', `compiler.cpp.extra_flags=${cxxFlags.join(' ')}`] : [] const buildProjectFlags = [ - ...buildArduinoCliCompileArgs(boardHalsContent, { + ...buildArduinoCliCompileArgs(compileEntry, { sketchPath: join(baremetalPath, 'Baremetal.ino'), libraryPath: join(compilationPath, 'src'), avrLibStdCppInclude, @@ -2433,6 +2512,18 @@ class CompilerModule { const boardRuntime = await this.#getBoardRuntime(boardTarget) // Get the board runtime from the hals.json file const halsContent = await CompilerModule.readJSONFile(this.halsFilePath) + // Resolve unified board info upfront so upload-step lookups work for VPP + // boards too (hals.json only contains Simulator / Runtime v3 / Runtime v4 + // after the VPP migration; every Arduino board lives in a VPP manifest). + // Done lazily — only computed when boardTarget is known to be an + // arduino-cli target downstream; runtime-v4 / simulator paths don't need it. + let resolvedBoardInfo: BoardBuildInfo | null = null + const getResolvedBoardInfo = async (): Promise => { + if (resolvedBoardInfo) return resolvedBoardInfo + const resolver = new BoardInfoResolver(this.halsFilePath, this.sourceDirectoryPath, new PackageManagerModule()) + resolvedBoardInfo = await resolver.resolve(boardTarget) + return resolvedBoardInfo + } const normalizedProjectPath = projectPath.replace('project.json', '') @@ -3161,9 +3252,14 @@ class CompilerModule { 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(':', '.') + // Derive the build sub-directory from the resolved platform FQBN (e.g. + // "arduino:avr:mega" → "arduino.avr.mega"). The resolver covers both + // legacy hals.json entries and VPP boards. + const simulatorInfo = await getResolvedBoardInfo() + if (!simulatorInfo.platform) { + throw new Error(`Board "${boardTarget}" does not declare a platform (FQBN)`) + } + const fqbnSubDir = simulatorInfo.platform.replaceAll(':', '.') const hexPath = join(compilationPath, 'examples', 'Baremetal', 'build', fqbnSubDir, 'Baremetal.ino.hex') _mainProcessPort.postMessage({ logLevel: 'info', @@ -3180,9 +3276,13 @@ class CompilerModule { if (!compileOnly) { _mainProcessPort.postMessage({ logLevel: 'info', message: 'Uploading program to board...' }) try { + const uploadInfo = await getResolvedBoardInfo() + if (!uploadInfo.platform) { + throw new Error(`Board "${boardTarget}" does not declare a platform (FQBN)`) + } await this.handleUploadProgram({ projectPath: normalizedProjectPath, - arduinoPlatform: halsContent[boardTarget]['platform'], + arduinoPlatform: uploadInfo.platform, compilationPath, handleOutputData: (data, logLevel) => { _mainProcessPort.postMessage({ logLevel, message: data }) From 8ef8f03dfe86845fc4f39c0ea2f6a02fc8ae2bd7 Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Mon, 25 May 2026 12:12:49 +0200 Subject: [PATCH 05/61] refactor(board-info): unify BoardInfoSchema in hardware/types as the canonical declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two divergent Zod schemas described `hals.json` entries: - `compiler/types.ts`: `compiler: z.enum([...])`, required `updatedAt` / `version`, missing `preview` and `specs`. - `hardware/types.ts`: `compiler: z.string()`, required `preview` and `specs`, missing `updatedAt` / `version` / `arch`. Neither was ever `.parse()`'d — they served as type-only contracts — so the divergence had been silently growing. Real `hals.json` entries today carry `preview` + `specs` but not `updatedAt` / `version` / `arch`. Consolidates the canonical schema in `hardware/types.ts`: - `compiler` keeps the closed enum (`'arduino-cli' | 'openplc-compiler' | 'simulator'`) so future VPP toolchains have to declare a member here rather than leak through as a free string. - `updatedAt`, `version`, and `arch` become `.optional()` — they were aspirational in the previous compiler/types declaration and the shipped data doesn't carry them. - Every consumer-visible field (preview, specs, all flag arrays, define, user_*, max_data_size, board_manager_url) stays in place. `compiler/types.ts` re-exports `BoardInfoSchema`, `HalsFileSchema`, `BoardInfo`, and `HalsFile` from `../hardware/types` so existing import paths under `backend/editor/compiler` keep working. Tests + tsc green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/backend/editor/compiler/types.ts | 46 +++++----------------------- src/backend/editor/hardware/types.ts | 36 ++++++++++++++-------- 2 files changed, 31 insertions(+), 51 deletions(-) diff --git a/src/backend/editor/compiler/types.ts b/src/backend/editor/compiler/types.ts index 6e774d0a9..5e7c264bc 100644 --- a/src/backend/editor/compiler/types.ts +++ b/src/backend/editor/compiler/types.ts @@ -15,42 +15,6 @@ const ArduinoCoreControlSchema = z.array(z.record(z.string(), z.string())) type ArduinoCoreControl = z.infer -const BoardInfoSchema = z.object({ - compiler: z.enum(['arduino-cli', 'openplc-compiler', 'simulator']), - core: z.string(), - default_ain: z.string(), - default_aout: z.string(), - default_din: z.string(), - default_dout: z.string(), - updatedAt: z.number(), - platform: z.string(), - source: z.string(), - version: z.string(), - board_manager_url: z.string().optional(), - extra_libraries: z.array(z.string()).optional(), - define: z.union([z.string(), z.array(z.string())]).optional(), - user_ain: z.string().optional(), - user_aout: z.string().optional(), - user_din: z.string().optional(), - user_dout: z.string().optional(), - c_flags: z.array(z.string()).optional(), - cxx_flags: z.array(z.string()).optional(), - ld_flags: z.array(z.string()).optional(), - // Overrides arduino-cli's post-link `upload.maximum_data_size` - // check. Required when `ld_flags` extend the linker memory - // map past the canonical SoC RAM (e.g. emulated boards) — - // otherwise the link succeeds but the CLI rejects the binary - // with "data section exceeds available space in board". - max_data_size: z.number().optional(), - arch: z.string().optional(), -}) - -type BoardInfo = z.infer - -const HalsFileSchema = z.record(z.string(), BoardInfoSchema) - -type HalsFile = z.infer - /** * Subset of `arduino-cli compile --show-properties=expanded` output captured * by CompilerModule.extractToolchainProperties. We keep the full property map @@ -68,6 +32,12 @@ type ToolchainProperties = { recipeAr: string } -export { ArduinoCliConfigSchema, ArduinoCoreControlSchema, BoardInfoSchema, HalsFileSchema } +// Re-exported from hardware/types so existing import paths under +// backend/editor/compiler keep working — the schema itself lives next to +// the resolver that owns the hals.json contract. +export { BoardInfoSchema, HalsFileSchema } from '../hardware/types' +export type { BoardInfo, HalsFile } from '../hardware/types' + +export { ArduinoCliConfigSchema, ArduinoCoreControlSchema } -export type { ArduinoCliConfig, ArduinoCoreControl, BoardInfo, HalsFile, ToolchainProperties } +export type { ArduinoCliConfig, ArduinoCoreControl, ToolchainProperties } diff --git a/src/backend/editor/hardware/types.ts b/src/backend/editor/hardware/types.ts index e40b9ba0a..4006d050a 100644 --- a/src/backend/editor/hardware/types.ts +++ b/src/backend/editor/hardware/types.ts @@ -10,22 +10,13 @@ const SerialPortSchema = z.object({ type SerialPort = z.infer const BoardInfoSchema = z.object({ - board_manager_url: z.string().optional(), - compiler: z.string(), + // Toolchain selector. Enum is closed: VPP devices that need a different + // compiler should declare a new entry here rather than passing a free string. + compiler: z.enum(['arduino-cli', 'openplc-compiler', 'simulator']), core: z.string(), - c_flags: z.array(z.string()).optional(), - cxx_flags: z.array(z.string()).optional(), - ld_flags: z.array(z.string()).optional(), - max_data_size: z.number().optional(), - default_ain: z.string(), - default_aout: z.string(), - default_din: z.string(), - default_dout: z.string(), - define: z.string().or(z.array(z.string())).optional(), - extra_libraries: z.array(z.string()).optional(), platform: z.string(), - preview: z.string(), source: z.string(), + preview: z.string(), specs: z.object({ CPU: z.string(), RAM: z.string(), @@ -37,10 +28,29 @@ const BoardInfoSchema = z.object({ Bluetooth: z.string(), Ethernet: z.string(), }), + default_ain: z.string(), + default_aout: z.string(), + default_din: z.string(), + default_dout: z.string(), user_ain: z.string().optional(), user_aout: z.string().optional(), user_din: z.string().optional(), user_dout: z.string().optional(), + board_manager_url: z.string().optional(), + extra_libraries: z.array(z.string()).optional(), + define: z.string().or(z.array(z.string())).optional(), + c_flags: z.array(z.string()).optional(), + cxx_flags: z.array(z.string()).optional(), + ld_flags: z.array(z.string()).optional(), + // Overrides arduino-cli's post-link `upload.maximum_data_size` check — + // required when `ld_flags` extend the linker memory map past canonical + // SoC RAM (e.g. emulated boards). + max_data_size: z.number().optional(), + arch: z.string().optional(), + // Tracking metadata — not present in shipped hals.json today; optional + // so downstream entries that do carry them still validate. + updatedAt: z.number().optional(), + version: z.string().optional(), }) type BoardInfo = z.infer From 3d105f5373582d8bb47a3cc378c0644cef2b9fd8 Mon Sep 17 00:00:00 2001 From: marcone tenorio Date: Mon, 25 May 2026 12:13:08 +0200 Subject: [PATCH 06/61] fix(simulator): drop pin-mapping override that choked the debug-serial handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `synthesizeSimulatorPinMapping` was replacing the project's `devicePinMapping` with the simulator hals.json `default_*` layout whenever the target board was the OpenPLC Simulator. That layout is 68 entries — 24 digital inputs, 8 analog inputs, 24 digital outputs, 12 analog outputs — and the digital output range includes pins 14-19, which on the ATmega2560 are the Serial1 / Serial2 / Serial3 TX/RX pins. `simulator.cpp`'s `hardwareInit()` calls `pinMode()` on every entry in the resulting pinmasks, and `updateInputBuffers()` runs `digitalRead` / `analogRead` over the full input range each scan cycle. On avr8js (roughly an order of magnitude slower than silicon) the cycle budget overflows, `modbusTask()` doesn't get a slot inside the editor's `DEBUG_GET_MD5` retry window, and the debugger fails to connect with "Failed to get MD5 hash after retries". Compile + firmware load succeed end-to-end; the wedge is purely in the runtime cadence. Reverts to using `devicePinMapping` directly — the user's configured pins, typically a small subset — which the simulator handles inside the response budget. Removes the three tests that exercised the helper. Known regression re-opened: when a user switches from a board with named pin labels (Uno's `D0`, `D5`, …) directly to the Simulator without editing pin-mapping, the compile breaks with `'D0' was not declared in this scope` because the Mega2560 HAL only knows numeric identifiers. The pin-mapping table is hidden in the UI for the Simulator target so the user can't fix it from there. A future targeted fix should sanitise stale named entries (or scope the override to only fire when the existing mapping is invalid for the selected board) rather than substituting the entire mapping. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/compiler/compiler-module.spec.ts | 43 ------------------ .../editor/compiler/compiler-module.ts | 44 ++----------------- 2 files changed, 4 insertions(+), 83 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.spec.ts b/src/backend/editor/compiler/compiler-module.spec.ts index 4bdb6b46a..6b75a2b8b 100644 --- a/src/backend/editor/compiler/compiler-module.spec.ts +++ b/src/backend/editor/compiler/compiler-module.spec.ts @@ -202,49 +202,6 @@ describe('CompilerModule', () => { }) }) - describe('synthesizeSimulatorPinMapping (Simulator pin layout from hals.json)', () => { - it('parses the comma-separated default_* strings into typed DevicePin entries', () => { - const pins = CompilerModule.synthesizeSimulatorPinMapping({ - default_din: '62, 63, 64, 65', - default_dout: '14, 15, 16', - default_ain: 'A0, A1', - default_aout: '2, 3', - }) - // Order: digitalInput, analogInput, digitalOutput, analogOutput - expect(pins.map((p) => p.pin)).toEqual(['62', '63', '64', '65', 'A0', 'A1', '14', '15', '16', '2', '3']) - expect(pins.map((p) => p.pinType)).toEqual([ - 'digitalInput', - 'digitalInput', - 'digitalInput', - 'digitalInput', - 'analogInput', - 'analogInput', - 'digitalOutput', - 'digitalOutput', - 'digitalOutput', - 'analogOutput', - 'analogOutput', - ]) - }) - - it('skips empty/whitespace entries so a trailing comma does not produce a blank pin', () => { - const pins = CompilerModule.synthesizeSimulatorPinMapping({ - default_din: '62, ,63,', - default_dout: '', - default_ain: '', - default_aout: '', - }) - expect(pins).toEqual([ - { pin: '62', pinType: 'digitalInput', address: '', alias: '' }, - { pin: '63', pinType: 'digitalInput', address: '', alias: '' }, - ]) - }) - - it('returns an empty list when the board declares no pin defaults', () => { - expect(CompilerModule.synthesizeSimulatorPinMapping({})).toEqual([]) - }) - }) - describe('installAsArduinoLibrary (precompiled library layout)', () => { const fs = jest.requireActual('node:fs') as typeof import('node:fs') const fsPromises = jest.requireActual('node:fs/promises') as typeof import('node:fs/promises') diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 339f15b71..96b633504 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -276,33 +276,6 @@ class CompilerModule { return properties } - // Synthesise the simulator's pin mapping from its hals.json default_* - // strings. The simulator HAL (simulator.cpp) expects PINMASK_DIN/DOUT/AIN/AOUT - // populated, but those values are a property of the virtual device — not - // of the user's project. The UI already hides the pin table when Simulator - // is selected; this override makes the compile path follow the same rule - // so a stale pin saved from a previous board doesn't poison the build. - static synthesizeSimulatorPinMapping(boardEntry: { - default_din?: string - default_dout?: string - default_ain?: string - default_aout?: string - }): DevicePin[] { - const parse = (s: string | undefined): string[] => - s - ? s - .split(',') - .map((p) => p.trim()) - .filter(Boolean) - : [] - const pins: DevicePin[] = [] - for (const pin of parse(boardEntry.default_din)) pins.push({ pin, pinType: 'digitalInput', address: '', alias: '' }) - for (const pin of parse(boardEntry.default_ain)) pins.push({ pin, pinType: 'analogInput', address: '', alias: '' }) - for (const pin of parse(boardEntry.default_dout)) pins.push({ pin, pinType: 'digitalOutput', address: '', alias: '' }) - for (const pin of parse(boardEntry.default_aout)) pins.push({ pin, pinType: 'analogOutput', address: '', alias: '' }) - return pins - } - // ############################################################################ // =========================== Private methods ================================ // ############################################################################ @@ -1200,20 +1173,11 @@ class CompilerModule { // INFO: If null, only the define value // 3.3. IO Config defines DEFINES_CONTENT += '//IO Config\n' - // Simulator overrides the project's pin-mapping with the board's canonical - // hals.json default_* layout. The pin-mapping table is hidden in the UI for - // this target, but the file persists so switching back to a real board - // preserves the user's wiring. Sourcing from hals.json here keeps the - // compile aligned with what the UI advertises. - const effectivePinMapping = - boardRuntime === 'simulator' && boardEntry - ? CompilerModule.synthesizeSimulatorPinMapping(boardEntry) - : devicePinMapping // INFO: This approach assumes that the pins are sorted. - const digitalInputPins = effectivePinMapping.filter((pin) => pin.pinType === 'digitalInput') - const analogInputPins = effectivePinMapping.filter((pin) => pin.pinType === 'analogInput') - const digitalOutputPins = effectivePinMapping.filter((pin) => pin.pinType === 'digitalOutput') - const analogOutputPins = effectivePinMapping.filter((pin) => pin.pinType === 'analogOutput') + 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` From 691b61cdd701725abb0e948d8ca1ea88128014eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Wed, 27 May 2026 19:53:40 +0200 Subject: [PATCH 07/61] fix(compiler): drive toolchain via argv + lint precompile boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the four exec(string) call sites that drove the arduino-cli toolchain recipe through a host shell (extractToolchainProperties, the precompile compile loop, the ar archive step, and the bonus checkArduinoCliAvailability case) with spawn-based argv invocations via execFile. The previous form passed arduino-cli's POSIX-quoted recipe — e.g. `'-DUSB_MANUFACTURER="Unknown"'` — through cmd.exe on Windows, which does not consume single quotes, so the quote characters reached avr-g++ as part of the argument and gcc treated the flag as a missing file. The fix covers the whole defect class: USB descriptors on Leonardo/Micro/MKR, paths under `Program Files (x86)`, and ESP32 `@responsefile` arguments. New `recipe-exec.ts` module provides the tokenizer, placeholder substitution and argv exec — pure, testable, no shell involvement. The ad-hoc tokenizer covers the POSIX subset arduino-cli emits (single-quoted, double-quoted, mixed segments, response-file tokens); no new dependency on shell-quote needed. 18 unit tests cover the parser including the Leonardo recipe shape end-to-end. `ensureResponseFileStubs` now takes the tokenized argv directly. The regex extraction was moved to a public `extractResponseFilesFromArgv` helper so it can be unit-tested without filesystem side effects (the prior test produced different behaviour on POSIX vs Windows hosts, depending on whether `C:\...` was treated as a literal directory name or an absolute path). The new `precompile-boundary-invariant.test.ts` codifies the C-linkage boundary the precompile pipeline depends on: - The five headers in resources/sources/arduino/ exposed to both sides of the precompile/arduino-cli line (arduino_runtime_glue.h, openplc.h, Arduino_OpenPLC.h, c_blocks.h, debug.h) must stay free of ``, `strucpp::`, and strucpp template includes. - Every TU compiled by arduino-cli (resources/sources/Baremetal/, resources/sources/hal/) must stay free of `strucpp::` and strucpp template includes. 108 assertions, pure static text scan, runs deterministically without mocking a board core. Future regressions of the isolation invariant fail the CI build instead of relying on human review. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../precompile-boundary-invariant.test.ts | 130 ++++++++++++++ .../compiler/__tests__/recipe-exec.test.ts | 149 ++++++++++++++++ .../editor/compiler/compiler-module.spec.ts | 90 ++++++---- .../editor/compiler/compiler-module.ts | 112 ++++++++---- src/backend/editor/compiler/recipe-exec.ts | 164 ++++++++++++++++++ 5 files changed, 575 insertions(+), 70 deletions(-) create mode 100644 src/backend/editor/compiler/__tests__/precompile-boundary-invariant.test.ts create mode 100644 src/backend/editor/compiler/__tests__/recipe-exec.test.ts create mode 100644 src/backend/editor/compiler/recipe-exec.ts diff --git a/src/backend/editor/compiler/__tests__/precompile-boundary-invariant.test.ts b/src/backend/editor/compiler/__tests__/precompile-boundary-invariant.test.ts new file mode 100644 index 000000000..94fe1165f --- /dev/null +++ b/src/backend/editor/compiler/__tests__/precompile-boundary-invariant.test.ts @@ -0,0 +1,130 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, resolve } from 'node:path' + +// Static lint protecting the invariant documented in +// `resources/sources/arduino/arduino_runtime_glue.h` (lines 19-22) and +// expanded in the project's design discussion: +// +// No TU on either side of the precompile/arduino-cli boundary may +// see flags or macros from the other side. +// +// Concretely: +// - boundary headers (included by BOTH the precompiled gnu++17 archive +// AND the arduino-cli-compiled sketch) must stay C-safe — no +// , no `strucpp::`, no strucpp template includes. +// - files compiled by arduino-cli with the core's default C++ std +// (Baremetal/, hal/) must stay free of `strucpp::` and strucpp +// template includes, otherwise the std-mismatch ABI break the +// precompile pipeline was built to prevent leaks back in. +// +// Pure static text scan. Does not compile the files; does not depend on +// a specific Arduino core; runs deterministically on every host. + +const REPO_ROOT = resolve(__dirname, '..', '..', '..', '..', '..') +const SOURCES_DIR = join(REPO_ROOT, 'resources', 'sources') + +// Files shipped in `resources/sources/arduino/` that may legitimately be +// included from BOTH the precompiled gnu++17 archive AND the +// arduino-cli-compiled sketch (via arduino_runtime_glue.h and openplc.h +// transitively). They share the strict C-safe contract. +const BOUNDARY_HEADERS: ReadonlyArray = [ + 'arduino/arduino_runtime_glue.h', + 'arduino/openplc.h', + 'arduino/Arduino_OpenPLC.h', + 'arduino/c_blocks.h', + 'arduino/debug.h', +] + +// Directories whose source files are compiled by arduino-cli with the +// board core's default C++ standard. They must never reference strucpp. +const ARDUINO_CLI_SIDE_DIRS: ReadonlyArray = ['Baremetal', 'hal'] + +const ARDUINO_CLI_SIDE_EXTS: ReadonlyArray = ['.cpp', '.h', '.hpp', '.ino', '.c'] + +const ARDUINO_HEADER_INCLUDE = /#\s*include\s*[<"]Arduino\.h[>"]/ +const STRUCPP_NAMESPACE = /\b(namespace\s+strucpp\b|strucpp\s*::)/ +const STRUCPP_TEMPLATE_INCLUDE = + /#\s*include\s*[<"](generated(?:_debug)?|debug_dispatch|iec_[A-Za-z_0-9]+|IECVar|strucpp_runtime\/[^"<>]+)\.h(?:pp)?[>"]/ + +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '') +} + +function collectFilesRecursive(dir: string, allowedExts: ReadonlyArray): string[] { + const out: string[] = [] + const walk = (current: string) => { + for (const entry of readdirSync(current)) { + const full = join(current, entry) + if (statSync(full).isDirectory()) { + walk(full) + } else if (allowedExts.some((ext) => entry.endsWith(ext))) { + out.push(full) + } + } + } + walk(dir) + return out.sort() +} + +function relFromSources(absolutePath: string): string { + return absolutePath.substring(SOURCES_DIR.length + 1).replace(/\\/g, '/') +} + +describe('precompile/arduino-cli boundary invariants', () => { + describe('boundary headers stay C-safe (no Arduino.h, no strucpp leak)', () => { + for (const rel of BOUNDARY_HEADERS) { + const absolute = join(SOURCES_DIR, rel) + + it(`${rel} must not include `, () => { + const code = stripComments(readFileSync(absolute, 'utf-8')) + expect(code).not.toMatch(ARDUINO_HEADER_INCLUDE) + }) + + it(`${rel} must not reference the strucpp namespace`, () => { + const code = stripComments(readFileSync(absolute, 'utf-8')) + expect(code).not.toMatch(STRUCPP_NAMESPACE) + }) + + it(`${rel} must not include strucpp template headers`, () => { + const code = stripComments(readFileSync(absolute, 'utf-8')) + expect(code).not.toMatch(STRUCPP_TEMPLATE_INCLUDE) + }) + } + }) + + describe('arduino-cli-side TUs stay strucpp-free', () => { + for (const subdir of ARDUINO_CLI_SIDE_DIRS) { + const dirAbs = join(SOURCES_DIR, subdir) + const files = collectFilesRecursive(dirAbs, ARDUINO_CLI_SIDE_EXTS) + + it(`${subdir}/ has at least one source file to scan (guards against silent path drift)`, () => { + expect(files.length).toBeGreaterThan(0) + }) + + for (const file of files) { + const rel = relFromSources(file) + + it(`${rel} must not reference the strucpp namespace`, () => { + const code = stripComments(readFileSync(file, 'utf-8')) + expect(code).not.toMatch(STRUCPP_NAMESPACE) + }) + + it(`${rel} must not include strucpp template headers`, () => { + const code = stripComments(readFileSync(file, 'utf-8')) + expect(code).not.toMatch(STRUCPP_TEMPLATE_INCLUDE) + }) + } + } + }) + + describe('invariant documentation in arduino_runtime_glue.h survives edits', () => { + it('preserves the "MUST stay free of" warning that codifies the rule for future readers', () => { + const path = join(SOURCES_DIR, 'arduino', 'arduino_runtime_glue.h') + const raw = readFileSync(path, 'utf-8') + // Don't strip comments here — this assertion checks the comment block itself. + expect(raw).toMatch(/MUST stay free of/i) + expect(raw).toMatch(/namespace strucpp/) + expect(raw).toMatch(/generated\.hpp|iec_\*\.hpp|iec_\.\*\.hpp/) + }) + }) +}) diff --git a/src/backend/editor/compiler/__tests__/recipe-exec.test.ts b/src/backend/editor/compiler/__tests__/recipe-exec.test.ts new file mode 100644 index 000000000..6208b7eb9 --- /dev/null +++ b/src/backend/editor/compiler/__tests__/recipe-exec.test.ts @@ -0,0 +1,149 @@ +import { substitutePlaceholders, tokenizeRecipe } from '../recipe-exec' + +describe('tokenizeRecipe', () => { + it('splits plain whitespace-separated tokens', () => { + expect(tokenizeRecipe('a b c')).toEqual(['a', 'b', 'c']) + }) + + it('treats multiple whitespace runs (spaces, tabs, newlines) as one separator', () => { + expect(tokenizeRecipe('a b\tc\nd')).toEqual(['a', 'b', 'c', 'd']) + }) + + it('strips a wrapping single-quote pair without altering contents', () => { + expect(tokenizeRecipe("'foo bar'")).toEqual(['foo bar']) + }) + + it('strips a wrapping double-quote pair without altering contents', () => { + expect(tokenizeRecipe('"foo bar"')).toEqual(['foo bar']) + }) + + it('preserves embedded double quotes when wrapped in single quotes (Leonardo USB descriptor)', () => { + // Real arduino-cli output for Leonardo: '-DUSB_MANUFACTURER="Unknown"' '-DUSB_PRODUCT="Arduino Leonardo"' + const input = '\'-DUSB_MANUFACTURER="Unknown"\' \'-DUSB_PRODUCT="Arduino Leonardo"\'' + expect(tokenizeRecipe(input)).toEqual([ + '-DUSB_MANUFACTURER="Unknown"', + '-DUSB_PRODUCT="Arduino Leonardo"', + ]) + }) + + it('concatenates quoted and unquoted segments inside the same token', () => { + expect(tokenizeRecipe('-DFOO="bar baz"')).toEqual(['-DFOO=bar baz']) + }) + + it('handles Windows-style absolute paths in double quotes (with backslashes)', () => { + const input = '"C:\\Program Files (x86)\\Arduino\\hardware\\arduino-cli.exe" -c "C:\\Path With Spaces\\file.cpp"' + expect(tokenizeRecipe(input)).toEqual([ + 'C:\\Program Files (x86)\\Arduino\\hardware\\arduino-cli.exe', + '-c', + 'C:\\Path With Spaces\\file.cpp', + ]) + }) + + it('keeps `@responsefile` paths as single tokens (ESP32 cflags shape)', () => { + expect(tokenizeRecipe('-c @/build/.tmp/build_opt.h foo.cpp')).toEqual([ + '-c', + '@/build/.tmp/build_opt.h', + 'foo.cpp', + ]) + }) + + it('returns an empty array for an empty or whitespace-only recipe', () => { + expect(tokenizeRecipe('')).toEqual([]) + expect(tokenizeRecipe(' \t \n ')).toEqual([]) + }) + + it('throws on an unterminated single quote', () => { + expect(() => tokenizeRecipe("foo 'bar")).toThrow(/unterminated single quote/) + }) + + it('throws on an unterminated double quote', () => { + expect(() => tokenizeRecipe('foo "bar')).toThrow(/unterminated double quote/) + }) + + it('parses a representative AVR recipe end-to-end (Leonardo shape)', () => { + // Compacted reproduction of the failing arduino:avr:leonardo recipe. + const recipe = + '"C:\\avr-gcc\\bin\\avr-g++" -c -g -Os -w -std=gnu++11 -fpermissive ' + + '-DUSB_VID=0x2341 -DUSB_PID=0x8036 \'-DUSB_MANUFACTURER="Unknown"\' ' + + '\'-DUSB_PRODUCT="Arduino Leonardo"\' "-IC:\\build\\src" ' + + '"C:\\build\\src\\arduino_runtime_glue.cpp" -o "C:\\build\\obj\\arduino_runtime_glue.o"' + + const argv = tokenizeRecipe(recipe) + + expect(argv).toEqual([ + 'C:\\avr-gcc\\bin\\avr-g++', + '-c', + '-g', + '-Os', + '-w', + '-std=gnu++11', + '-fpermissive', + '-DUSB_VID=0x2341', + '-DUSB_PID=0x8036', + '-DUSB_MANUFACTURER="Unknown"', + '-DUSB_PRODUCT="Arduino Leonardo"', + '-IC:\\build\\src', + 'C:\\build\\src\\arduino_runtime_glue.cpp', + '-o', + 'C:\\build\\obj\\arduino_runtime_glue.o', + ]) + }) +}) + +describe('substitutePlaceholders', () => { + it('replaces an exact-match scalar placeholder', () => { + const result = substitutePlaceholders(['gcc', '-c', '{source_file}', '-o', '{object_file}'], { + '{source_file}': '/abs/foo.cpp', + '{object_file}': '/abs/foo.o', + }) + expect(result).toEqual(['gcc', '-c', '/abs/foo.cpp', '-o', '/abs/foo.o']) + }) + + it('expands an exact-match array placeholder into multiple argv entries', () => { + const result = substitutePlaceholders(['gcc', '{includes}', 'foo.cpp'], { + '{includes}': ['-I/srcDir', '-I/baremetalDir'], + }) + expect(result).toEqual(['gcc', '-I/srcDir', '-I/baremetalDir', 'foo.cpp']) + }) + + it('substitutes a placeholder embedded as substring inside a larger token (scalar only)', () => { + const result = substitutePlaceholders(['-o{object_file}.tmp'], { + '{object_file}': '/abs/foo.o', + }) + expect(result).toEqual(['-o/abs/foo.o.tmp']) + }) + + it('throws when an array placeholder appears as substring (would silently corrupt argv)', () => { + expect(() => + substitutePlaceholders(['x{includes}y'], { '{includes}': ['-Ia', '-Ib'] }), + ).toThrow(/Array expansion is only safe for exact-match tokens/) + }) + + it('leaves tokens unchanged when no placeholder matches', () => { + expect(substitutePlaceholders(['gcc', '-c'], { '{source_file}': '/abs' })).toEqual(['gcc', '-c']) + }) + + it('integrates with tokenizeRecipe to produce a runnable argv for the Leonardo recipe', () => { + const recipe = + '"avr-g++" -c -DUSB_VID=0x2341 \'-DUSB_PRODUCT="Arduino Leonardo"\' ' + + '{includes} "{source_file}" -o "{object_file}"' + + const argv = substitutePlaceholders(tokenizeRecipe(recipe), { + '{source_file}': 'C:\\build\\src\\glue.cpp', + '{object_file}': 'C:\\build\\obj\\glue.o', + '{includes}': ['-IC:\\build\\src', '-IC:\\build\\examples\\Baremetal'], + }) + + expect(argv).toEqual([ + 'avr-g++', + '-c', + '-DUSB_VID=0x2341', + '-DUSB_PRODUCT="Arduino Leonardo"', + '-IC:\\build\\src', + '-IC:\\build\\examples\\Baremetal', + 'C:\\build\\src\\glue.cpp', + '-o', + 'C:\\build\\obj\\glue.o', + ]) + }) +}) diff --git a/src/backend/editor/compiler/compiler-module.spec.ts b/src/backend/editor/compiler/compiler-module.spec.ts index 6b75a2b8b..c47f5ba8f 100644 --- a/src/backend/editor/compiler/compiler-module.spec.ts +++ b/src/backend/editor/compiler/compiler-module.spec.ts @@ -31,17 +31,25 @@ jest.mock('node:fs/promises', () => { return { ...actual, cp: jest.fn().mockResolvedValue(undefined) } }) -// Mock node:child_process so individual tests can swap the exec impl. The -// real `exec` carries a promisify.custom symbol that makes `promisify(exec)` -// resolve with `{ stdout, stderr }` instead of a single value — we replicate -// that here so the production code path through promisify behaves identically. +// Mock node:child_process so individual tests can swap the exec impl. Both +// `exec` (legacy callsites still going through promisify(exec) in this +// module's call graph) AND `execFile` (the new path used by recipe-exec.ts) +// route through the same `execImpl.current` dispatcher so tests inspect +// invocations uniformly. For execFile we synthesize a printable cmd string +// from (command, args) so existing `expect(cmd).toContain('pou_MAIN.cpp')` +// assertions still work — bare argv entries get rendered with surrounding +// quotes only if they contain whitespace, matching the eye-grep shape the +// tests were written against. const execImpl: { current: (cmd: string) => Promise<{ stdout: string; stderr: string }> } = { current: async () => ({ stdout: '', stderr: '' }), } +const renderArgvAsCmd = (command: string, args: ReadonlyArray): string => + [command, ...args].map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(' ') jest.mock('node:child_process', () => { const { promisify } = jest.requireActual('node:util') as typeof import('node:util') + const exec = ( cmd: string, _opts: unknown, @@ -54,7 +62,23 @@ jest.mock('node:child_process', () => { return { kill: () => undefined } } ;(exec as unknown as { [k: symbol]: unknown })[promisify.custom] = (cmd: string) => execImpl.current(cmd) - return { exec, spawn: jest.fn() } + + const execFile = ( + command: string, + args: ReadonlyArray, + _opts: unknown, + cb: (err: Error | null, val?: { stdout: string; stderr: string }) => void, + ) => { + execImpl + .current(renderArgvAsCmd(command, args)) + .then((val) => cb(null, val)) + .catch((err: Error) => cb(err)) + return { kill: () => undefined } + } + ;(execFile as unknown as { [k: symbol]: unknown })[promisify.custom] = (command: string, args: ReadonlyArray) => + execImpl.current(renderArgvAsCmd(command, args)) + + return { exec, execFile, spawn: jest.fn() } }) // CompilerModule uses process.resourcesPath (Electron-specific) when not in dev mode. @@ -290,10 +314,12 @@ describe('CompilerModule', () => { describe('ensureResponseFileStubs (ESP32/STM32duino response-file workaround)', () => { // Method is `private static` — exposed for direct testing via a typed // façade so the regex and EEXIST handling can be exercised in isolation - // without going through the full pre-compile path. + // without going through the full pre-compile path. Takes already-tokenized + // argv (post-`tokenizeRecipe`) — response-file tokens arrive without + // surrounding quote chars. const ensureStubs = ( CompilerModule as unknown as { - ensureResponseFileStubs(cmd: string, log: (s: string) => void): Promise + ensureResponseFileStubs(argv: ReadonlyArray, log: (s: string) => void): Promise } ).ensureResponseFileStubs.bind(CompilerModule) const fs = jest.requireActual('node:fs') as typeof import('node:fs') @@ -309,49 +335,46 @@ describe('CompilerModule', () => { fs.rmSync(workDir, { recursive: true, force: true }) }) - it('creates an empty stub for a quoted POSIX @-file the recipe references but does not exist', async () => { + it('creates an empty stub for a POSIX @-file the recipe references but does not exist', async () => { const missing = join(workDir, 'sub', 'build_opt.h') - const cmd = `arm-none-eabi-g++ -c "@${missing}" -DARDUINO=10607 -o foo.o` - await ensureStubs(cmd, noopLog) + const argv = ['arm-none-eabi-g++', '-c', `@${missing}`, '-DARDUINO=10607', '-o', 'foo.o'] + await ensureStubs(argv, noopLog) expect(fs.existsSync(missing)).toBe(true) expect(fs.statSync(missing).size).toBe(0) expect(noopLog).toHaveBeenCalledWith(expect.stringContaining(`Stubbed empty response file: ${missing}`), 'info') }) - it('matches Windows-style @C:\\... and @C:/... absolute paths from the recipe', async () => { - // Windows paths can't actually be created on POSIX hosts, so we assert - // via the side-effect: the regex must extract them so the mkdir/writeFile - // attempt happens (and would surface a mkdir error). + it('matches Windows-style @C:\\... and @C:/... absolute paths in argv tokens', () => { + // Pure regex assertion against the public extractor — observing + // extraction via filesystem side-effects (mkdir/writeFile) is + // platform-fragile (POSIX accepts "C:" as a literal directory + // name; Windows actually writes under C:\). The extractor is the + // authoritative subject, so we test it directly. const winBackslash = 'C:\\Users\\dev\\AppData\\arduino\\sketches\\hash\\file_opts' const winSlash = 'C:/Users/dev/AppData/arduino/sketches/hash/build_opt.h' - const cmd = `arm-zephyr-eabi-g++ -c "@${winBackslash}" "@${winSlash}" -o foo.o` - const originalCwd = process.cwd() - process.chdir(workDir) - try { - await ensureStubs(cmd, noopLog).catch(() => { - /* mkdir of "C:" on POSIX can fail — regex match still asserted via the log */ - }) - } finally { - process.chdir(originalCwd) - } - const logCalls = noopLog.mock.calls.flat().join('\n') - expect(logCalls).toContain(winBackslash) - expect(logCalls).toContain(winSlash) + const argv = ['arm-zephyr-eabi-g++', '-c', `@${winBackslash}`, `@${winSlash}`, '-o', 'foo.o'] + + const extracted = (CompilerModule as unknown as { + extractResponseFilesFromArgv(argv: ReadonlyArray): string[] + }).extractResponseFilesFromArgv(argv) + + expect(extracted).toContain(winBackslash) + expect(extracted).toContain(winSlash) }) it('does not overwrite existing response files', async () => { const existing = join(workDir, 'preexisting.txt') fs.writeFileSync(existing, 'real flags here', 'utf-8') - const cmd = `g++ -c "@${existing}" foo.cpp` - await ensureStubs(cmd, noopLog) + const argv = ['g++', '-c', `@${existing}`, 'foo.cpp'] + await ensureStubs(argv, noopLog) expect(fs.readFileSync(existing, 'utf-8')).toBe('real flags here') expect(noopLog).not.toHaveBeenCalled() }) it('deduplicates repeated @-references so a path is stubbed at most once', async () => { const target = join(workDir, 'shared.opt') - const cmd = `g++ -c "@${target}" "@${target}" "@${target}"` - await ensureStubs(cmd, noopLog) + const argv = ['g++', '-c', `@${target}`, `@${target}`, `@${target}`] + await ensureStubs(argv, noopLog) expect(fs.existsSync(target)).toBe(true) expect(noopLog).toHaveBeenCalledTimes(1) }) @@ -360,9 +383,8 @@ describe('CompilerModule', () => { // Relative-path @-args either reference workspace-local files (which // we shouldn't touch) or are non-path arguments — the regex deliberately // only matches absolute paths. - const relative = 'subdir/file.txt' - const cmd = `g++ -c "@${relative}" foo.cpp` - await ensureStubs(cmd, noopLog) + const argv = ['g++', '-c', '@subdir/file.txt', 'foo.cpp'] + await ensureStubs(argv, noopLog) expect(noopLog).not.toHaveBeenCalled() }) }) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 31e51f9e0..98c682e30 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -1,4 +1,4 @@ -import { exec, spawn } from 'node:child_process' +import { spawn } from 'node:child_process' import crypto, { createHash } from 'node:crypto' import { existsSync, promises as fs } from 'node:fs' import { cp, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises' @@ -7,7 +7,8 @@ import https from 'node:https' import os from 'node:os' import path from 'node:path' import { join } from 'node:path' -import { promisify } from 'node:util' + +import { execRecipeArgv, substitutePlaceholders, tokenizeRecipe } from './recipe-exec' // strucpp is loaded lazily because it uses ESM features (import.meta) that are // incompatible with Jest's CJS transform — see `backend/shared/library/strucpp-runtime`. @@ -467,7 +468,6 @@ class CompilerModule { async checkArduinoCliAvailability(): Promise> { let binaryPath = this.arduinoCliBinaryPath const [flag, configFilePath] = this.arduinoCliBaseParameters - const executeCommand = promisify(exec) if (CompilerModule.HOST_PLATFORM === 'win32') { // INFO: On Windows, we need to add the .exe extension to the binary path. @@ -475,7 +475,7 @@ class CompilerModule { } // INFO: We use the version command to check if the arduino-cli is available. // INFO: If the command is not available, it will throw an error. - const { stdout, stderr } = await executeCommand(`"${binaryPath}" version ${flag} "${configFilePath}" --json`) + const { stdout, stderr } = await execRecipeArgv([binaryPath, 'version', flag, configFilePath, '--json']) if (stderr) { throw new Error(`Arduino CLI not available: ${stderr}`) } @@ -550,16 +550,28 @@ class CompilerModule { if (CompilerModule.HOST_PLATFORM === 'win32') binaryPath += '.exe' const dummySketchPath = this.#constructShowPropertiesDummyPath() - const baseArgs = this.arduinoCliBaseParameters.map((p) => `"${p}"`).join(' ') - const execAsync = promisify(exec) // `--show-properties=expanded` tells arduino-cli to evaluate every // `{var}` interpolation in `platform.txt` / `boards.txt` before printing // — without `=expanded`, recipes come back with raw `{compiler.path}` // placeholders that would be useless for direct toolchain invocation. - const cmd = `"${binaryPath}" compile --fqbn "${fqbn}" --show-properties=expanded "${dummySketchPath}" ${baseArgs}` + // + // Spawned via execFile (no shell) so paths containing spaces or shell + // metacharacters (`Program Files (x86)`, `Arduino IDE` etc.) reach + // arduino-cli intact on every host. Going through cmd.exe on Windows + // would corrupt the argv exactly the way the recipe-driven compile + // path used to break for the Leonardo USB descriptors. + const argv = [ + binaryPath, + 'compile', + '--fqbn', + fqbn, + '--show-properties=expanded', + dummySketchPath, + ...this.arduinoCliBaseParameters, + ] - const { stdout } = await execAsync(cmd, { maxBuffer: 8 * 1024 * 1024 }) + const { stdout } = await execRecipeArgv(argv, { maxBuffer: 8 * 1024 * 1024 }) const properties = CompilerModule.parseShowPropertiesOutput(stdout) const recipeCpp = properties['recipe.cpp.o.pattern'] @@ -1379,25 +1391,37 @@ class CompilerModule { }) } + // Extract every absolute `@` response-file reference from a + // tokenized recipe (post-`tokenizeRecipe`). Only POSIX `/...` and + // Windows `C:\...`/`C:/...` qualify — relative `@-` tokens are + // workspace-local files the editor must not touch. Pure function so + // the regex can be unit-tested without filesystem side effects. + static extractResponseFilesFromArgv(argv: ReadonlyArray): string[] { + const responseFileRe = /^@([A-Za-z]:[\\/].+|\/.+)$/ + const seen = new Set() + for (const token of argv) { + const match = responseFileRe.exec(token) + if (match) seen.add(match[1]) + } + return Array.from(seen) + } + // Stub empty files for `@response_file` paths a recipe references but // that arduino-cli would only generate during a real compile (ESP32 + // STM32duino). GCC treats missing `@file` as a literal positional // argument → "cannot specify '-o' with '-c' ... with multiple files". // Empty is the canonical default arduino-cli itself writes when no // per-project build_opt customization exists. + // + // Takes the already-tokenized argv (post-`tokenizeRecipe`) so the + // surrounding-quote concern from the legacy regex form goes away — + // quotes are stripped by tokenization and the response-file token + // arrives as `@` cleanly. private static async ensureResponseFileStubs( - cmd: string, + argv: ReadonlyArray, handleOutputData: HandleOutputDataCallback, ): Promise { - // Match `@` (POSIX `/...` or Windows `C:\...` / `C:/...`), - // with optional surrounding quote from the recipe substitution. - const responseFileRe = /["']?@([A-Za-z]:[\\/][^"'\s]+|\/[^"'\s]+)/g - const paths = new Set() - let match: RegExpExecArray | null - while ((match = responseFileRe.exec(cmd)) !== null) { - paths.add(match[1]) - } - for (const responsePath of paths) { + for (const responsePath of CompilerModule.extractResponseFilesFromArgv(argv)) { if (existsSync(responsePath)) continue await mkdir(path.dirname(responsePath), { recursive: true }) try { @@ -1445,15 +1469,23 @@ class CompilerModule { const objDir = join(compilationPath, 'precompile', 'obj') await mkdir(objDir, { recursive: true }) - const includes = [`"-I${srcDir}"`, `"-I${baremetalDir}"`].join(' ') + // -I arguments are passed as bare argv entries (no extra quoting) — + // execFile delivers them literally to the toolchain on every host. + const includeArgs = [`-I${srcDir}`, `-I${baremetalDir}`] // Appended after the recipe so the last `-std=` wins over the core's // implicit gnu++14. extraCxxFlags carries VPP per-board cxx_flags. - const trailingFlags = ['-std=gnu++17', '-fno-rtti', ...extraCxxFlags].join(' ') + const trailingFlags = ['-std=gnu++17', '-fno-rtti', ...extraCxxFlags] - const execAsync = promisify(exec) const execMaxBuffer = 16 * 1024 * 1024 + // Tokenize the raw recipe once — placeholders stay intact and are + // substituted per-TU below. Going through tokenizeRecipe up-front + // means POSIX-quoted segments like `'-DUSB_PRODUCT="Arduino Leonardo"'` + // collapse to a single argv entry with the literal `"…"` preserved, + // regardless of host shell. + const recipeTokens = tokenizeRecipe(tcProps.recipeCpp) + handleOutputData( `[precompile] Compiling ${sources.length} TU(s) with toolchain for ${fqbn}...`, 'info', @@ -1468,18 +1500,19 @@ class CompilerModule { const compilePromises = sources.map(async (sourcePath, idx) => { const objectPath = objectFiles[idx] - const cmd = - tcProps.recipeCpp - .replaceAll('{source_file}', sourcePath) - .replaceAll('{object_file}', objectPath) - .replaceAll('{includes}', includes) + - ' ' + - trailingFlags + const argv = [ + ...substitutePlaceholders(recipeTokens, { + '{source_file}': sourcePath, + '{object_file}': objectPath, + '{includes}': includeArgs, + }), + ...trailingFlags, + ] - await CompilerModule.ensureResponseFileStubs(cmd, handleOutputData) + await CompilerModule.ensureResponseFileStubs(argv, handleOutputData) try { - const { stdout, stderr } = await execAsync(cmd, { maxBuffer: execMaxBuffer }) + const { stdout, stderr } = await execRecipeArgv(argv, { maxBuffer: execMaxBuffer }) // gcc emits warnings on stderr even on success — both streams logged as info. if (stdout) handleOutputData(stdout, 'info') if (stderr) handleOutputData(stderr, 'info') @@ -1498,7 +1531,6 @@ class CompilerModule { // (full path, usable) while AVR uses `{archive_file}` (bare filename with // build cache dir baked into the recipe, which would write to the wrong place). const archivePath = join(compilationPath, 'precompile', 'libOpenPLCUserLib.a') - const quotedObjects = objectFiles.map((p) => `"${p}"`).join(' ') const compilerPath = tcProps.properties['compiler.path'] const arName = tcProps.properties['compiler.ar.cmd'] if (!compilerPath || !arName) { @@ -1509,16 +1541,24 @@ class CompilerModule { `The board's core is likely not installed.`, ) } - const arFlags = tcProps.properties['compiler.ar.flags'] ?? 'rcs' - const arExtraFlags = tcProps.properties['compiler.ar.extra_flags'] ?? '' - const archiverBin = `"${compilerPath}${arName}"` - const archiveCmd = `${archiverBin} ${arFlags} ${arExtraFlags} "${archivePath}" ${quotedObjects}` + const arFlags = (tcProps.properties['compiler.ar.flags'] ?? 'rcs').split(/\s+/).filter(Boolean) + const arExtraFlags = (tcProps.properties['compiler.ar.extra_flags'] ?? '').split(/\s+/).filter(Boolean) + // ar argv: . All paths + // land as plain argv entries so spaces, parentheses, or other shell + // metacharacters in the build path can't break the invocation. + const archiveArgv = [ + `${compilerPath}${arName}`, + ...arFlags, + ...arExtraFlags, + archivePath, + ...objectFiles, + ] handleOutputData( `[precompile] Archiving ${objectFiles.length} object(s) into libOpenPLCUserLib.a...`, 'info', ) - await execAsync(archiveCmd, { maxBuffer: execMaxBuffer }) + await execRecipeArgv(archiveArgv, { maxBuffer: execMaxBuffer }) // Move pre-compiled sources out of src/ so arduino-cli library discovery // doesn't recompile them; preserved under precompile/sources/ for debug. diff --git a/src/backend/editor/compiler/recipe-exec.ts b/src/backend/editor/compiler/recipe-exec.ts new file mode 100644 index 000000000..94a8b1724 --- /dev/null +++ b/src/backend/editor/compiler/recipe-exec.ts @@ -0,0 +1,164 @@ +/** + * Tokenize + execute arduino-cli recipe strings as argv arrays. + * + * Why this exists: arduino-cli's `--show-properties=expanded` returns + * `recipe.cpp.o.pattern` / `recipe.c.o.pattern` / `recipe.ar.pattern` + * as POSIX-shell-quoted command lines (e.g. `'-DUSB_PRODUCT="Arduino + * Leonardo"'`). Passing that to Node's `exec()` works on macOS/Linux + * (where `sh -c` consumes the quoting) but breaks on Windows because + * `cmd.exe` doesn't understand single quotes — the argv reaches gcc + * with literal quote characters and gcc treats it as a missing file. + * + * Tokenizing the recipe to argv up-front and spawning the process + * directly (no shell) makes the invocation platform-agnostic. The + * tokenizer covers the subset of POSIX quoting arduino-cli actually + * emits: whitespace-separated tokens, optional single-quoted segments + * (literal), optional double-quoted segments (literal — arduino-cli + * does not emit backslash escapes inside double quotes), and mixed + * tokens that concatenate quoted and unquoted parts (`-DFOO="bar"`). + */ + +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) + +/** + * Parse a POSIX-shell-quoted recipe into a flat argv array, preserving + * any embedded quote characters that were inside an outer single-quote + * segment (the typical Leonardo / Arduino USB descriptor shape: + * `'-DUSB_PRODUCT="Arduino Leonardo"'` → token + * `-DUSB_PRODUCT="Arduino Leonardo"`). + * + * Throws on unterminated quote. Does NOT support backslash escapes, + * shell operators (`|`, `>`, `&&`), env-var expansion, or subshells — + * arduino-cli recipes contain none of those. + */ +export function tokenizeRecipe(recipe: string): string[] { + const tokens: string[] = [] + let i = 0 + const n = recipe.length + + while (i < n) { + // Skip inter-token whitespace. + while (i < n && isWhitespace(recipe[i])) i++ + if (i >= n) break + + let token = '' + while (i < n && !isWhitespace(recipe[i])) { + const ch = recipe[i] + if (ch === "'") { + // Single-quoted segment: literal until next single quote. + i++ + while (i < n && recipe[i] !== "'") { + token += recipe[i] + i++ + } + if (i >= n) { + throw new Error(`tokenizeRecipe: unterminated single quote in recipe near position ${i}`) + } + i++ // skip closing ' + } else if (ch === '"') { + // Double-quoted segment: literal until next double quote. + i++ + while (i < n && recipe[i] !== '"') { + token += recipe[i] + i++ + } + if (i >= n) { + throw new Error(`tokenizeRecipe: unterminated double quote in recipe near position ${i}`) + } + i++ // skip closing " + } else { + token += ch + i++ + } + } + tokens.push(token) + } + + return tokens +} + +function isWhitespace(ch: string): boolean { + return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' +} + +/** + * Replace placeholder tokens (e.g. `{source_file}`, `{object_file}`, + * `{includes}`) in an argv array with concrete values. A scalar + * replacement substitutes 1-for-1; an array replacement expands into + * multiple argv entries (used for `{includes}` which becomes + * `-I -I`). + * + * Partial matches are honored — `"{source_file}"` arriving as a single + * token after tokenization (when the recipe wrote `"{source_file}"`) + * is treated as the bare placeholder by checking equality first; if + * the token contains the placeholder as a substring (e.g. + * `-o{object_file}.tmp`, which arduino-cli does not emit but is + * theoretically possible), the placeholder is substituted via string + * replace and the token kept as a single entry. Array replacements + * are only allowed for exact-equality matches; substring expansion + * with an array would silently corrupt the argv. + */ +export function substitutePlaceholders( + argv: ReadonlyArray, + replacements: Readonly>>, +): string[] { + const out: string[] = [] + + for (const token of argv) { + let handled = false + + for (const [placeholder, value] of Object.entries(replacements)) { + if (token === placeholder) { + if (Array.isArray(value)) { + out.push(...value) + } else { + out.push(value as string) + } + handled = true + break + } + if (token.includes(placeholder)) { + if (Array.isArray(value)) { + throw new Error( + `substitutePlaceholders: placeholder "${placeholder}" appears as substring in token "${token}", but the replacement is an array. Array expansion is only safe for exact-match tokens.`, + ) + } + out.push(token.split(placeholder).join(value as string)) + handled = true + break + } + } + + if (!handled) { + out.push(token) + } + } + + return out +} + +/** + * Spawn a child process with the given argv (no shell). Resolves with + * captured stdout/stderr; rejects with the standard Node ExecException + * augmented with stdout/stderr on non-zero exit. Same surface as + * `promisify(exec)` so the migration is mechanical at call sites. + * + * `argv[0]` is the executable; the rest are arguments. Both are passed + * literally to the OS — no shell expansion, no quoting concerns. + */ +export async function execRecipeArgv( + argv: ReadonlyArray, + options: { maxBuffer?: number } = {}, +): Promise<{ stdout: string; stderr: string }> { + if (argv.length === 0) { + throw new Error('execRecipeArgv: empty argv') + } + const [command, ...args] = argv + const result = await execFileAsync(command, args, { + maxBuffer: options.maxBuffer ?? 16 * 1024 * 1024, + }) + return { stdout: result.stdout.toString(), stderr: result.stderr.toString() } +} From 65e57b2c673ef41d2e250bba6375c71b6b53e82a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Wed, 27 May 2026 20:00:59 +0200 Subject: [PATCH 08/61] refactor(compiler): stash precompile sources up-front for crash recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the `src/` → `precompile/sources/` rename from after the archive step to before the compile step in `handlePrecompileUserLib`. Compile and archive now read from the stash directory, and the post-archive move block is removed entirely. The previous order had a load-bearing side effect with no rollback: if anything between the first compile and the final rename threw — archive failure, mid-batch compile failure, IO error — the next run saw both `src/` populated and the stash empty (or partial), with a build cache state nothing in the function knew how to reconcile. Stash-before-compile is idempotent by construction. After a failed run the stash holds the source, src/ is empty of strucpp output, and a retry stashes the (now-empty) src/ delta, reads the stash to discover the TU set, and re-runs the pipeline from there. The `fs.rename` overwrite semantics handle the case where strucpp emits a fresh src/ between runs — the src/ version always wins. Test `stashes sources before compile so a failed archive leaves a recoverable state for retry` simulates an `avr-ar` failure mid-run and confirms (a) the stash holds the two strucpp sources with content intact, (b) `src/` retains only `arduino.cpp` (the HAL stays where arduino-cli expects it), and (c) a second invocation completes with the same TU set discovered from the stash. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/compiler/compiler-module.spec.ts | 53 +++++++++++++++ .../editor/compiler/compiler-module.ts | 64 ++++++++++++------- 2 files changed, 94 insertions(+), 23 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.spec.ts b/src/backend/editor/compiler/compiler-module.spec.ts index c47f5ba8f..4cd8b0335 100644 --- a/src/backend/editor/compiler/compiler-module.spec.ts +++ b/src/backend/editor/compiler/compiler-module.spec.ts @@ -563,5 +563,58 @@ describe('CompilerModule', () => { }), ).rejects.toThrow(/compiler\.path \+ compiler\.ar\.cmd.*core is likely not installed/s) }) + + it('stashes sources before compile so a failed archive leaves a recoverable state for retry', async () => { + // Two strucpp-side TUs and the board HAL. After a failed first run + // we expect src/ to retain only arduino.cpp and the stash to hold + // the two pre-compile sources verbatim — a subsequent retry must + // pick them up from the stash and complete successfully. + fs.writeFileSync(join(srcDir, 'arduino.cpp'), '// HAL\n', 'utf-8') + fs.writeFileSync(join(srcDir, 'pou_MAIN.cpp'), '// pou body\n', 'utf-8') + fs.writeFileSync(join(srcDir, 'configuration.cpp'), '// config body\n', 'utf-8') + + const stashDir = join(buildDir, 'precompile', 'sources') + + let failNextArchive = true + execImpl.current = async (cmd) => { + if (cmd.includes('avr-ar') && failNextArchive) { + throw new Error('simulated archive failure') + } + return { stdout: '', stderr: '' } + } + + await expect( + compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:avr:uno', + handleOutputData: noopLog, + }), + ).rejects.toThrow(/simulated archive failure/) + + // Post-failure state: stash holds the strucpp sources, src/ has only + // arduino.cpp — exactly the invariant arduino-cli depends on. + expect(fs.existsSync(join(stashDir, 'pou_MAIN.cpp'))).toBe(true) + expect(fs.existsSync(join(stashDir, 'configuration.cpp'))).toBe(true) + expect(fs.existsSync(join(srcDir, 'pou_MAIN.cpp'))).toBe(false) + expect(fs.existsSync(join(srcDir, 'configuration.cpp'))).toBe(false) + expect(fs.existsSync(join(srcDir, 'arduino.cpp'))).toBe(true) + // Content survived the move untouched (no truncation, no swap). + expect(fs.readFileSync(join(stashDir, 'pou_MAIN.cpp'), 'utf-8')).toBe('// pou body\n') + + // Second run resolves the simulated failure and completes. + failNextArchive = false + const result = await compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:avr:uno', + handleOutputData: noopLog, + }) + + // The TU set discovered from the stash matches the original two + // strucpp sources — order is deterministic (sorted basenames). + expect(result.objectFiles.map((p) => p.split(/[\\/]/).pop())).toEqual([ + 'configuration.o', + 'pou_MAIN.o', + ]) + }) }) }) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 98c682e30..de2a8e9de 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -1453,21 +1453,48 @@ class CompilerModule { const srcDir = join(compilationPath, 'src') const baremetalDir = join(compilationPath, 'examples', 'Baremetal') + const sourcesStash = join(compilationPath, 'precompile', 'sources') + const objDir = join(compilationPath, 'precompile', 'obj') - // arduino.cpp is the board HAL; arduino-cli must compile it so it sees - // the core's external libraries (Ethernet, SPI, ...) it discovers via - // sketch-tree includes. - const allEntries = await readdir(srcDir) - const sources = allEntries - .filter((name) => name.endsWith('.cpp') && name !== 'arduino.cpp') - .map((name) => join(srcDir, name)) + // Stash strucpp-emitted .cpp out of src/ BEFORE compile, then read the + // stash to discover the TU set. Two reasons: + // + // 1. arduino-cli's library discovery walks the sketch tree and will + // recompile any .cpp it finds under src/ with the core's default + // C++ standard. Moving the strucpp TUs out before arduino-cli runs + // keeps the gnu++17 archive's symbols as the only definition. + // + // 2. Recovery from a partial previous run becomes trivial. If a prior + // invocation crashed between compile and archive, the .cpp files + // are already in the stash — a retry stashes the (now empty) src/, + // reads the stash, and re-runs the whole pipeline from there. No + // half-stashed split-brain state. + // + // arduino.cpp (the board HAL) is excluded — arduino-cli must compile + // that one alongside the sketch so it picks up the core's external + // libraries (Ethernet, SPI, …) discovered via sketch-tree includes. + await mkdir(sourcesStash, { recursive: true }) + await mkdir(objDir, { recursive: true }) - if (sources.length === 0) { - throw new Error(`handlePrecompileUserLib: no .cpp sources found under ${srcDir}`) + const srcEntries = await readdir(srcDir) + for (const name of srcEntries) { + if (!name.endsWith('.cpp') || name === 'arduino.cpp') continue + // rename overwrites the stash entry if a previous run left a stale + // copy — the src/ version is the latest strucpp output and wins. + await fs.rename(join(srcDir, name), join(sourcesStash, name)) } - const objDir = join(compilationPath, 'precompile', 'obj') - await mkdir(objDir, { recursive: true }) + // Discover the TU set from the stash so newly-moved files AND any + // leftovers from a previous failed run get picked up uniformly. + // Sorted for deterministic archive-member ordering downstream. + const stashEntries = (await readdir(sourcesStash)).filter((name) => name.endsWith('.cpp')).sort() + const sources = stashEntries.map((name) => join(sourcesStash, name)) + + if (sources.length === 0) { + throw new Error( + `handlePrecompileUserLib: no .cpp sources found under ${srcDir} or ${sourcesStash}`, + ) + } // -I arguments are passed as bare argv entries (no extra quoting) — // execFile delivers them literally to the toolchain on every host. @@ -1560,18 +1587,9 @@ class CompilerModule { ) await execRecipeArgv(archiveArgv, { maxBuffer: execMaxBuffer }) - // Move pre-compiled sources out of src/ so arduino-cli library discovery - // doesn't recompile them; preserved under precompile/sources/ for debug. - const sourcesStash = join(compilationPath, 'precompile', 'sources') - await mkdir(sourcesStash, { recursive: true }) - for (const sourcePath of sources) { - const stashedPath = join(sourcesStash, path.basename(sourcePath)) - await fs.rename(sourcePath, stashedPath) - } - handleOutputData( - `[precompile] Moved ${sources.length} compiled source(s) to precompile/sources/ (won't be recompiled by arduino-cli)`, - 'info', - ) + // Sources were stashed before compile (see `await fs.rename` block at + // the top of this method) so arduino-cli's library discovery doesn't + // see them in src/ at all. No post-archive move step needed. // arduino-cli's precompiled-lib resolution picks ONE subdir per core, // and the convention varies: AVR uses build.mcu ("atmega2560"), mbed From 4caaa6e78d078c330f2537ab8c47eb91559e0c4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Wed, 27 May 2026 20:57:29 +0200 Subject: [PATCH 09/61] perf(compiler): cap precompile parallelism at os.cpus().length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unbounded `sources.map(async …)` in `handlePrecompileUserLib` was dispatching one toolchain spawn per TU simultaneously. A 30-TU strucpp program on a 4-core box used to launch 30 parallel g++ invocations — and on Windows each one drags a cmd.exe shim along, which the OS can't schedule fairly past the physical-core ceiling. Long compile times, swap pressure, and occasional `EAGAIN`/spawn failures on resource-constrained hosts followed. Introduces `runWithConcurrencyLimit(items, limit, fn)` — the classic async worker-pool: spawn `min(limit, items.length)` workers that race for the next index from a shared cursor, preserving input order in the result array and matching Promise.all rejection semantics. The new helper is generic and isolated; unit tests cover ordering, peak-concurrency assertions via an in-flight counter, fail-fast behaviour on first rejection, and defensive normalisation of limit ≤ 0 / non-integer values (the latter matters because `os.cpus()` can return 0 in restricted environments like minimal containers). Integration test `caps concurrent toolchain spawns at the host CPU count (no unbounded parallel exec)` exercises the wiring end-to-end: seeds `cpus().length + 4` TUs, instruments the exec mock with a peak counter, and asserts the peak never exceeds `os.cpus().length` while parallelism > 1 (the cap is observable rather than degenerate). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/run-with-concurrency.test.ts | 106 ++++++++++++++++++ .../editor/compiler/compiler-module.spec.ts | 40 +++++++ .../editor/compiler/compiler-module.ts | 14 ++- .../editor/compiler/run-with-concurrency.ts | 44 ++++++++ 4 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 src/backend/editor/compiler/__tests__/run-with-concurrency.test.ts create mode 100644 src/backend/editor/compiler/run-with-concurrency.ts diff --git a/src/backend/editor/compiler/__tests__/run-with-concurrency.test.ts b/src/backend/editor/compiler/__tests__/run-with-concurrency.test.ts new file mode 100644 index 000000000..1abc845df --- /dev/null +++ b/src/backend/editor/compiler/__tests__/run-with-concurrency.test.ts @@ -0,0 +1,106 @@ +import { runWithConcurrencyLimit } from '../run-with-concurrency' + +describe('runWithConcurrencyLimit', () => { + it('returns an empty array for empty input without invoking fn', async () => { + const fn = jest.fn() + const result = await runWithConcurrencyLimit([], 4, fn) + expect(result).toEqual([]) + expect(fn).not.toHaveBeenCalled() + }) + + it('returns results in input order regardless of completion order', async () => { + // Items finish in reverse order — fastest at the end of the input, + // slowest at the start. Result array must still be in input order. + const items = [50, 30, 10] // ms delays + const result = await runWithConcurrencyLimit(items, 3, async (delay, idx) => { + await new Promise((r) => setTimeout(r, delay)) + return idx + }) + expect(result).toEqual([0, 1, 2]) + }) + + it('passes the original index to fn so callers can correlate input ↔ output', async () => { + const seen: Array<[string, number]> = [] + await runWithConcurrencyLimit(['a', 'b', 'c'], 2, async (item, idx) => { + seen.push([item, idx]) + return null + }) + expect(seen.sort()).toEqual([ + ['a', 0], + ['b', 1], + ['c', 2], + ]) + }) + + it('never exceeds the configured concurrency limit', async () => { + // 20 items, limit of 3 — instrument with an in-flight counter and + // assert the peak never crosses 3. + let inFlight = 0 + let peak = 0 + const limit = 3 + const items = Array.from({ length: 20 }, (_, i) => i) + + await runWithConcurrencyLimit(items, limit, async (i) => { + inFlight += 1 + if (inFlight > peak) peak = inFlight + // Yield a tick so workers actually overlap rather than each + // synchronously enqueueing the next. + await new Promise((r) => setTimeout(r, 5)) + inFlight -= 1 + return i + }) + + expect(peak).toBeLessThanOrEqual(limit) + expect(peak).toBeGreaterThan(1) // sanity: we actually used the slots + }) + + it('spawns fewer workers than the limit when items.length is smaller', async () => { + // Limit 10, items 3 — there should never be more than 3 in flight + // because there are only 3 to process. + let inFlight = 0 + let peak = 0 + await runWithConcurrencyLimit([1, 2, 3], 10, async (n) => { + inFlight += 1 + if (inFlight > peak) peak = inFlight + await new Promise((r) => setTimeout(r, 2)) + inFlight -= 1 + return n + }) + expect(peak).toBe(3) + }) + + it('rejects on the first fn error (Promise.all semantics)', async () => { + const fn = jest.fn(async (n: number) => { + if (n === 1) throw new Error('boom on 1') + await new Promise((r) => setTimeout(r, 10)) + return n + }) + await expect(runWithConcurrencyLimit([0, 1, 2, 3], 2, fn)).rejects.toThrow('boom on 1') + }) + + it('treats limit <= 0 as 1 (defensive against os.cpus() returning 0)', async () => { + let inFlight = 0 + let peak = 0 + await runWithConcurrencyLimit([0, 1, 2], 0, async (n) => { + inFlight += 1 + if (inFlight > peak) peak = inFlight + await new Promise((r) => setTimeout(r, 2)) + inFlight -= 1 + return n + }) + expect(peak).toBe(1) + }) + + it('floors non-integer limits (e.g. 3.7 → 3)', async () => { + let inFlight = 0 + let peak = 0 + await runWithConcurrencyLimit(Array.from({ length: 10 }, (_, i) => i), 3.7, async (n) => { + inFlight += 1 + if (inFlight > peak) peak = inFlight + await new Promise((r) => setTimeout(r, 2)) + inFlight -= 1 + return n + }) + expect(peak).toBe(3) + }) +}) diff --git a/src/backend/editor/compiler/compiler-module.spec.ts b/src/backend/editor/compiler/compiler-module.spec.ts index 4cd8b0335..93855778f 100644 --- a/src/backend/editor/compiler/compiler-module.spec.ts +++ b/src/backend/editor/compiler/compiler-module.spec.ts @@ -564,6 +564,46 @@ describe('CompilerModule', () => { ).rejects.toThrow(/compiler\.path \+ compiler\.ar\.cmd.*core is likely not installed/s) }) + it('caps concurrent toolchain spawns at the host CPU count (no unbounded parallel exec)', async () => { + // Reproduces the unbounded-parallelism failure mode the cap was + // added to prevent: ~30 TUs on a 4-core box used to dispatch 30 + // simultaneous g++ + cmd.exe pairs. Instrument the exec mock with + // an in-flight counter to assert the peak respects the cap. + const os = jest.requireActual('node:os') as typeof import('node:os') + const cpuCount = os.cpus().length + const tuCount = cpuCount + 4 + + for (let i = 0; i < tuCount; i++) { + fs.writeFileSync(join(srcDir, `tu_${String(i).padStart(2, '0')}.cpp`), '// tu\n', 'utf-8') + } + + let inFlight = 0 + let peakInFlight = 0 + execImpl.current = async (cmd) => { + // Archive (avr-ar) is sequential by design — skip it from the count. + if (cmd.includes('avr-ar')) return { stdout: '', stderr: '' } + inFlight += 1 + if (inFlight > peakInFlight) peakInFlight = inFlight + // Yield so workers actually overlap rather than each synchronously + // resolving and pulling the next item before we observe the peak. + await new Promise((r) => setTimeout(r, 10)) + inFlight -= 1 + return { stdout: '', stderr: '' } + } + + await compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:avr:uno', + handleOutputData: noopLog, + }) + + expect(peakInFlight).toBeLessThanOrEqual(cpuCount) + // Sanity: the cap kicked in only because we actually parallelised. + // On a single-core host the assertion would degenerate; skip the + // sanity check there. + if (cpuCount > 1) expect(peakInFlight).toBeGreaterThan(1) + }) + it('stashes sources before compile so a failed archive leaves a recoverable state for retry', async () => { // Two strucpp-side TUs and the board HAL. After a failed first run // we expect src/ to retain only arduino.cpp and the stash to hold diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index de2a8e9de..f0185634b 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -9,6 +9,7 @@ import path from 'node:path' import { join } from 'node:path' import { execRecipeArgv, substitutePlaceholders, tokenizeRecipe } from './recipe-exec' +import { runWithConcurrencyLimit } from './run-with-concurrency' // strucpp is loaded lazily because it uses ESM features (import.meta) that are // incompatible with Jest's CJS transform — see `backend/shared/library/strucpp-runtime`. @@ -1524,7 +1525,16 @@ class CompilerModule { join(objDir, path.basename(sourcePath).replace(/\.cpp$/, '.o')), ) - const compilePromises = sources.map(async (sourcePath, idx) => { + // Cap concurrent toolchain spawns at the host's logical core count. + // An unbounded `sources.map(async …)` was dispatching one g++ per TU + // simultaneously — on Windows each one drags a cmd.exe shim along + // and a 30-TU project would launch 30 parallel processes regardless + // of how many cores the host actually has. `os.cpus().length` is the + // standard ceiling; the floor of 1 inside `runWithConcurrencyLimit` + // covers environments where `os.cpus()` reports zero. + const compileConcurrency = os.cpus().length + + await runWithConcurrencyLimit(sources, compileConcurrency, async (sourcePath, idx) => { const objectPath = objectFiles[idx] const argv = [ @@ -1551,8 +1561,6 @@ class CompilerModule { } }) - await Promise.all(compilePromises) - // Build the ar command manually instead of using recipe.ar.pattern — // cores disagree on placeholder semantics: mbed uses `{archive_file_path}` // (full path, usable) while AVR uses `{archive_file}` (bare filename with diff --git a/src/backend/editor/compiler/run-with-concurrency.ts b/src/backend/editor/compiler/run-with-concurrency.ts new file mode 100644 index 000000000..ff2806a57 --- /dev/null +++ b/src/backend/editor/compiler/run-with-concurrency.ts @@ -0,0 +1,44 @@ +/** + * Bounded-concurrency `Promise.all` over an iterable. + * + * Runs `fn(item, index)` over every entry in `items`, with at most + * `limit` invocations in flight simultaneously. The classic worker- + * pool pattern: spawn `min(limit, items.length)` async workers that + * race for the next index from a shared cursor. + * + * Used by the precompile pipeline to cap concurrent toolchain spawns + * (an unbounded `sources.map(async …)` over a 30-TU strucpp program + * was dispatching 30 parallel g++ processes — well past the host's + * physical cores, and on Windows each process drags a cmd.exe + * shim along, which the OS struggles to schedule fairly). + * + * Results are returned in input order regardless of completion order. + * Failure semantics match `Promise.all`: the first rejection from any + * worker rejects the whole batch (still-pending items never start, + * already-in-flight items run to completion but their results are + * discarded). + * + * `limit <= 0` is normalised to 1 (defensive against `os.cpus()` + * returning 0 in restricted environments). Non-integer limits are + * floored. + */ +export async function runWithConcurrencyLimit( + items: ReadonlyArray, + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + const cap = Math.max(1, Math.floor(limit)) + const results = new Array(items.length) + let next = 0 + + const workerCount = Math.min(cap, items.length) + const workers = Array.from({ length: workerCount }, async () => { + while (next < items.length) { + const i = next++ + results[i] = await fn(items[i], i) + } + }) + + await Promise.all(workers) + return results +} From 65232c2677a463e7f7078f1b50d2255e217d4f0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Wed, 27 May 2026 21:42:57 +0200 Subject: [PATCH 10/61] fix(compiler): hard-fail when toolchain exposes no arch subdir property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the legacy `archCandidates.push('unknown')` fallback in `handlePrecompileUserLib` with an explicit error. When a core's platform.txt exposes none of `build.mcu`, `build.architecture`, or `build.arch`, arduino-cli's precompiled-library resolver cannot pick a subdir to look under, so `libOpenPLCUserLib.a` staged at `/src/unknown/` would silently be ignored and the link step would surface an opaque undefined-symbols error far downstream from the real cause. The new error names the FQBN, lists the three properties that were checked, explains the downstream symptom, and asks the user to file an issue with the FQBN and the offending core's platform.txt so the mapping can be added. Affects new/custom/legacy cores only — every core currently shipped by `com.openplc.arduino` exposes at least one of the three. Test asserts the error message format (FQBN, property names, "file an issue" hint) under a mocked toolchain with all three arch properties intentionally absent. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/compiler/compiler-module.spec.ts | 30 +++++++++++++++++++ .../editor/compiler/compiler-module.ts | 20 ++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/backend/editor/compiler/compiler-module.spec.ts b/src/backend/editor/compiler/compiler-module.spec.ts index 93855778f..18adb54b4 100644 --- a/src/backend/editor/compiler/compiler-module.spec.ts +++ b/src/backend/editor/compiler/compiler-module.spec.ts @@ -656,5 +656,35 @@ describe('CompilerModule', () => { 'pou_MAIN.o', ]) }) + + it('hard-fails with an actionable error when no arch property is exposed by --show-properties', async () => { + // Reproduce a custom/legacy core whose platform.txt exposes none + // of build.mcu / build.architecture / build.arch. The legacy + // fallback to a literal "unknown" subdir silently placed the + // archive somewhere arduino-cli would never look, producing an + // opaque undefined-symbols link error far downstream. The + // refactored path surfaces a loud, FQBN-tagged error instead. + extractSpy.mockResolvedValue({ + ...cannedProps, + properties: { + 'compiler.path': '/fake/avr/bin/', + 'compiler.ar.cmd': 'avr-ar', + 'compiler.ar.flags': 'rcs', + // build.mcu / build.architecture / build.arch intentionally absent + }, + } as unknown as ToolchainProperties) + + fs.writeFileSync(join(srcDir, 'pou_MAIN.cpp'), '// pou\n', 'utf-8') + + execImpl.current = async () => ({ stdout: '', stderr: '' }) + + await expect( + compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'unknown:vendor:weird-board', + handleOutputData: noopLog, + }), + ).rejects.toThrow(/Toolchain arch subdir resolution failed for "unknown:vendor:weird-board".*build\.mcu.*build\.architecture.*build\.arch.*file an issue/s) + }) }) }) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index f0185634b..6fd565adf 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -1606,6 +1606,14 @@ class CompilerModule { // archive under all of them — duplicating a few-hundred-KB file in the // /tmp staging is cheaper than maintaining a per-core mapping. The // first entry doubles as the canonical `archDir` used for -L injection. + // + // Hard-fail when none of the three properties is present. The legacy + // fallback to a literal "unknown" subdir put the archive somewhere + // arduino-cli's resolver would never look, producing an opaque + // undefined-symbols link error far downstream from the real cause. + // A loud error here names the FQBN and the missing properties so the + // user has the exact info to file an issue against the editor or the + // core's platform.txt. const archCandidates = Array.from( new Set( [ @@ -1617,7 +1625,17 @@ class CompilerModule { .map((s) => s.toLowerCase()), ), ) - if (archCandidates.length === 0) archCandidates.push('unknown') + if (archCandidates.length === 0) { + throw new Error( + `Toolchain arch subdir resolution failed for "${fqbn}": arduino-cli ` + + `--show-properties=expanded did not expose any of ` + + `build.mcu, build.architecture, or build.arch. Without one of ` + + `these, arduino-cli's precompiled-library resolver cannot locate ` + + `libOpenPLCUserLib.a and the link step would fail with an opaque ` + + `undefined-symbols error. Please file an issue including the FQBN ` + + `and the core's platform.txt so this can be mapped.`, + ) + } handleOutputData( `[precompile] Pre-compile complete (${objectFiles.length} TUs → libOpenPLCUserLib.a, archs=${archCandidates.join(',')})`, From 97f495666961c91217bb580b0d1740c22aac459a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 28 May 2026 00:18:14 +0200 Subject: [PATCH 11/61] feat(compiler): emit Modbus defines from the VPP screen for Arduino baremetal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the wiring between the per-device Modbus configuration UI and the firmware's MBSERIAL_* / MBTCP_* / MODBUS_ENABLED macros for arduino-cli baremetal targets. The pipeline existed before commit c379c7a9c ("drop communicationConfiguration from device schema"), which removed it pending Arduino's return as VPP packages. VPP packages have since shipped (com.openplc.arduino + com.openplc.arduino-industrial + five other Arduino-family bundles), all sharing `screens/modbus.json`, but the editor's compile-side consumer was never reinstated — `vendorScreenData` was being persisted to no effect for any Arduino board that wasn't the simulator. New `modbus-defines.ts` is a pure function over the screen's persisted state (`{ modbus_rtu, modbus_tcp }` after the sibling openplc-packages fix that unscoped the colliding `persistence` keys). It emits the exact macro set `resources/sources/Baremetal/ModbusSlave.cpp` still expects: MBSERIAL_IFACE / MBSERIAL_BAUD / MBSERIAL_SLAVE / MBSERIAL_TXPIN / MBTCP_MAC / MBTCP_IP / MBTCP_DNS / MBTCP_GATEWAY / MBTCP_SUBNET / MBTCP_SSID / MBTCP_PWD / MBSERIAL / MBTCP / MBTCP_WIFI / MBTCP_ETHERNET / MODBUS_ENABLED. The pre-c379c7a9c emitter's formatters for IP (dotted → comma-separated) and MAC (colon → 0xnn,…) are reproduced inline. Defaults are applied per-field when the persisted state lacks the value. This matters because the VPP form layout (`form-layout.tsx`) only writes back the field the user touches — toggling "Enable Modbus RTU" alone yields `{ enabled: true }` with every other field absent, which would have left MBSERIAL_IFACE / MBSERIAL_BAUD / MBSERIAL_SLAVE undefined and broken `MBSERIAL_IFACE.begin(MBSERIAL_BAUD)` in Baremetal.ino. The RTU_DEFAULTS / TCP_DEFAULTS constants mirror the `default` values declared in `modbus.json` and carry a comment explaining why they're duplicated in code rather than discovered at runtime. The integration in `compiler-module.ts:handleGenerateDefinitionsFile` routes by `boardRuntime`: - `simulator` keeps its hardcoded block (test harness, not a deployment target — the user never reconfigures its Modbus). - `openplc-compiler` (Runtime v3 / v4) keeps its existing `conf/modbus_slave.json` upload-bundle path, not these macros. - Anything else (arduino-cli baremetal) reads `vendorScreenData["modbus_rtu"]` / `["modbus_tcp"]` and emits the helper's output. 16 unit tests cover the matrix: empty state, RTU defaults from the "only enabled" persistence shape, custom RTU values, RS485 EN pin gating, TCP Ethernet + static IP, DHCP gating the static block, Wi-Fi SSID/PWD, optional MAC, RTU+TCP combined (single MODBUS_ENABLED), MAC and IP escape hatches for pre-formatted literals, and trailing-newline contract. Verified end-to-end on Arduino Uno: 414project with modbus_rtu enabled (Serial, 115200, slave 1), TCP disabled. `defines.h` now contains the expected six-line block; arduino-cli builds clean (the prior failure was `MBSERIAL` defined but `MBSERIAL_IFACE` undefined — exactly the defaults-application bug this commit's helper fixes). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../compiler/__tests__/modbus-defines.test.ts | 215 ++++++++++++++++++ .../editor/compiler/compiler-module.ts | 43 +++- src/backend/editor/compiler/modbus-defines.ts | 165 ++++++++++++++ 3 files changed, 414 insertions(+), 9 deletions(-) create mode 100644 src/backend/editor/compiler/__tests__/modbus-defines.test.ts create mode 100644 src/backend/editor/compiler/modbus-defines.ts diff --git a/src/backend/editor/compiler/__tests__/modbus-defines.test.ts b/src/backend/editor/compiler/__tests__/modbus-defines.test.ts new file mode 100644 index 000000000..6b234feca --- /dev/null +++ b/src/backend/editor/compiler/__tests__/modbus-defines.test.ts @@ -0,0 +1,215 @@ +import { generateModbusDefines } from '../modbus-defines' + +describe('generateModbusDefines', () => { + it('returns an empty string when neither RTU nor TCP is enabled', () => { + expect(generateModbusDefines({})).toBe('') + expect(generateModbusDefines({ modbus_rtu: {}, modbus_tcp: {} })).toBe('') + expect(generateModbusDefines({ modbus_rtu: { enabled: false }, modbus_tcp: { enabled: false } })).toBe('') + }) + + it('emits the canonical RTU block with screen defaults explicitly provided', () => { + const out = generateModbusDefines({ + modbus_rtu: { + enabled: true, + rtu_interface: 'Serial', + rtu_baud_rate: '115200', + rtu_slave_id: 1, + }, + }) + expect(out).toBe( + [ + '//Comms Configuration', + '#define MBSERIAL_IFACE Serial', + '#define MBSERIAL_BAUD 115200', + '#define MBSERIAL_SLAVE 1', + '#define MBSERIAL', + '#define MODBUS_ENABLED', + '', + ].join('\n'), + ) + }) + + it('applies RTU schema defaults when only `enabled: true` is persisted (form-layout writes only touched fields)', () => { + // Real-world scenario: user toggles "Enable Modbus RTU" without + // editing baud/interface/slave — form-layout writes only the field + // that changed. ModbusSlave.cpp still expects MBSERIAL_IFACE, + // MBSERIAL_BAUD, MBSERIAL_SLAVE to compile (object reference + + // numeric literals), so the helper must fill them from screen + // defaults rather than leaving them undefined. + const out = generateModbusDefines({ modbus_rtu: { enabled: true } }) + expect(out).toContain('#define MBSERIAL_IFACE Serial') + expect(out).toContain('#define MBSERIAL_BAUD 115200') + expect(out).toContain('#define MBSERIAL_SLAVE 1') + expect(out).toContain('#define MBSERIAL') + expect(out).toContain('#define MODBUS_ENABLED') + }) + + it('applies TCP `tcp_interface` default to Ethernet when only `enabled: true` is persisted', () => { + const out = generateModbusDefines({ modbus_tcp: { enabled: true } }) + expect(out).toContain('#define MBTCP_ETHERNET') + expect(out).not.toContain('MBTCP_WIFI') + }) + + it('honors custom RTU values (non-default baud, slave_id, interface)', () => { + const out = generateModbusDefines({ + modbus_rtu: { + enabled: true, + rtu_interface: 'Serial1', + rtu_baud_rate: '57600', + rtu_slave_id: 42, + }, + }) + expect(out).toContain('#define MBSERIAL_IFACE Serial1') + expect(out).toContain('#define MBSERIAL_BAUD 57600') + expect(out).toContain('#define MBSERIAL_SLAVE 42') + }) + + it('emits MBSERIAL_TXPIN only when the RS485 EN pin checkbox is on AND a pin value is set', () => { + // Pin set but checkbox off → no MBSERIAL_TXPIN (matches screen visibility gate). + const checkboxOff = generateModbusDefines({ + modbus_rtu: { enabled: true, enable_rs485_en_pin: false, rtu_rs485_en_pin: 'D2' }, + }) + expect(checkboxOff).not.toContain('MBSERIAL_TXPIN') + + // Checkbox on AND value set → emitted. + const checkboxOn = generateModbusDefines({ + modbus_rtu: { enabled: true, enable_rs485_en_pin: true, rtu_rs485_en_pin: 'D2' }, + }) + expect(checkboxOn).toContain('#define MBSERIAL_TXPIN D2') + + // Checkbox on but pin empty → skipped (defensive — no garbage #define). + const checkboxOnEmptyPin = generateModbusDefines({ + modbus_rtu: { enabled: true, enable_rs485_en_pin: true, rtu_rs485_en_pin: '' }, + }) + expect(checkboxOnEmptyPin).not.toContain('MBSERIAL_TXPIN') + }) + + it('emits the canonical TCP Ethernet block with static IP', () => { + const out = generateModbusDefines({ + modbus_tcp: { + enabled: true, + tcp_interface: 'Ethernet', + tcp_mac_address: 'de:ad:be:ef:fe:ed', + enable_dhcp: false, + ip_address: '192.168.1.100', + dns: '8.8.8.8', + gateway: '192.168.1.1', + subnet: '255.255.255.0', + }, + }) + expect(out).toContain('#define MBTCP_MAC 0xde, 0xad, 0xbe, 0xef, 0xfe, 0xed') + expect(out).toContain('#define MBTCP_IP 192, 168, 1, 100') + expect(out).toContain('#define MBTCP_DNS 8, 8, 8, 8') + expect(out).toContain('#define MBTCP_GATEWAY 192, 168, 1, 1') + expect(out).toContain('#define MBTCP_SUBNET 255, 255, 255, 0') + expect(out).toContain('#define MBTCP_ETHERNET') + expect(out).toContain('#define MBTCP') + expect(out).toContain('#define MODBUS_ENABLED') + }) + + it('skips static IP defines when DHCP is enabled (MAC + transport selector still emit)', () => { + const out = generateModbusDefines({ + modbus_tcp: { + enabled: true, + tcp_interface: 'Ethernet', + tcp_mac_address: 'de:ad:be:ef:fe:ed', + enable_dhcp: true, + ip_address: '192.168.1.100', + gateway: '192.168.1.1', + subnet: '255.255.255.0', + dns: '8.8.8.8', + }, + }) + expect(out).toContain('#define MBTCP_MAC') + expect(out).toContain('#define MBTCP_ETHERNET') + expect(out).not.toContain('MBTCP_IP') + expect(out).not.toContain('MBTCP_DNS') + expect(out).not.toContain('MBTCP_GATEWAY') + expect(out).not.toContain('MBTCP_SUBNET') + }) + + it('emits Wi-Fi specifics (SSID, PWD, MBTCP_WIFI) and omits MBTCP_ETHERNET when interface is Wi-Fi', () => { + const out = generateModbusDefines({ + modbus_tcp: { + enabled: true, + tcp_interface: 'Wi-Fi', + tcp_wifi_ssid: 'MyNetwork', + tcp_wifi_password: 'super-secret', + enable_dhcp: true, + }, + }) + expect(out).toContain('#define MBTCP_SSID "MyNetwork"') + expect(out).toContain('#define MBTCP_PWD "super-secret"') + expect(out).toContain('#define MBTCP_WIFI') + expect(out).not.toContain('MBTCP_ETHERNET') + }) + + it('omits MBTCP_MAC when the field is empty (boards with built-in MAC)', () => { + const out = generateModbusDefines({ + modbus_tcp: { enabled: true, tcp_interface: 'Ethernet', enable_dhcp: true }, + }) + expect(out).not.toContain('MBTCP_MAC') + expect(out).toContain('#define MBTCP_ETHERNET') + }) + + it('combines RTU + TCP and emits MODBUS_ENABLED exactly once', () => { + const out = generateModbusDefines({ + modbus_rtu: { enabled: true, rtu_interface: 'Serial', rtu_baud_rate: '9600', rtu_slave_id: 5 }, + modbus_tcp: { enabled: true, tcp_interface: 'Ethernet', enable_dhcp: true }, + }) + expect(out).toContain('#define MBSERIAL') + expect(out).toContain('#define MBTCP') + const occurrences = out.match(/#define MODBUS_ENABLED/g) ?? [] + expect(occurrences).toHaveLength(1) + }) + + it('defaults to MBTCP_ETHERNET when tcp_interface is missing', () => { + const out = generateModbusDefines({ + modbus_tcp: { enabled: true, enable_dhcp: true }, + }) + expect(out).toContain('#define MBTCP_ETHERNET') + expect(out).not.toContain('MBTCP_WIFI') + }) + + it('passes pre-formatted MAC literals through untouched (escape hatch for non-standard shapes)', () => { + const out = generateModbusDefines({ + modbus_tcp: { + enabled: true, + tcp_interface: 'Ethernet', + tcp_mac_address: '0xde, 0xad, 0xbe, 0xef, 0xfe, 0xed', + enable_dhcp: true, + }, + }) + expect(out).toContain('#define MBTCP_MAC 0xde, 0xad, 0xbe, 0xef, 0xfe, 0xed') + }) + + it('passes non-dotted IP strings through untouched', () => { + const out = generateModbusDefines({ + modbus_tcp: { + enabled: true, + tcp_interface: 'Ethernet', + enable_dhcp: false, + ip_address: 'host.local', + }, + }) + expect(out).toContain('#define MBTCP_IP host.local') + }) + + it('omits the heading entirely when both transports are explicitly disabled', () => { + // Distinct from "neither block populated" — here we have data shapes but + // the gating booleans are off. Output is still empty so defines.h stays + // clean. + const out = generateModbusDefines({ + modbus_rtu: { enabled: false, rtu_interface: 'Serial', rtu_baud_rate: '115200' }, + modbus_tcp: { enabled: false, tcp_interface: 'Ethernet', enable_dhcp: true }, + }) + expect(out).toBe('') + }) + + it('output always ends with a trailing newline (so callers can concatenate)', () => { + const out = generateModbusDefines({ + modbus_rtu: { enabled: true, rtu_interface: 'Serial', rtu_baud_rate: '115200', rtu_slave_id: 1 }, + }) + expect(out.endsWith('\n')).toBe(true) + }) +}) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 6fd565adf..98d67750d 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -8,6 +8,7 @@ import os from 'node:os' import path from 'node:path' import { join } from 'node:path' +import { generateModbusDefines, type VppModbusScreenState } from './modbus-defines' import { execRecipeArgv, substitutePlaceholders, tokenizeRecipe } from './recipe-exec' import { runWithConcurrencyLimit } from './run-with-concurrency' @@ -1152,17 +1153,27 @@ class CompilerModule { DEFINES_CONTENT += `#define PROGRAM_MD5 "${buildMD5Hash}"` DEFINES_CONTENT += `\n\n` - // 3.2. Simulator communication defines + // 3.2. 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. + // Two sources of `defines.h` Modbus macros: + // + // - Simulator: fixed RTU settings over emulated USART0 (ATmega2560, + // Serial = USART0, avr8js bridges usart0). Hardcoded because the + // user never reconfigures the simulator's Modbus — it's a + // test harness, not a deployment target. + // + // - Arduino-family baremetal (any boardRuntime other than + // 'simulator'/'openplc-compiler'): read the VPP Modbus screen + // state from `vendorScreenData['modbus_rtu' | 'modbus_tcp']` + // and emit the historical MBSERIAL_* / MBTCP_* macros that + // `resources/sources/Baremetal/ModbusSlave.cpp` still consumes. + // `generateModbusDefines` lives in `./modbus-defines.ts` — + // pure function, full test matrix there. + // + // The third runtime ('openplc-compiler' for Runtime v3/v4) routes + // its Modbus configuration through `conf/modbus_slave.json` in the + // upload bundle and consumes none of these macros. 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' @@ -1171,6 +1182,20 @@ class CompilerModule { DEFINES_CONTENT += '#define MBSERIAL\n' DEFINES_CONTENT += '#define MODBUS_ENABLED\n' DEFINES_CONTENT += `\n\n` + } else if (boardRuntime !== 'openplc-compiler') { + const devicesConfigurationFilePath = join(devicesDirectoryPath, 'configuration.json') + const deviceConfig = + await CompilerModule.readJSONFile(devicesConfigurationFilePath) + const vendorScreenData = deviceConfig.vendorScreenData ?? {} + const modbusState: VppModbusScreenState = { + modbus_rtu: vendorScreenData['modbus_rtu'] as VppModbusScreenState['modbus_rtu'], + modbus_tcp: vendorScreenData['modbus_tcp'] as VppModbusScreenState['modbus_tcp'], + } + const modbusBlock = generateModbusDefines(modbusState) + if (modbusBlock.length > 0) { + DEFINES_CONTENT += modbusBlock + DEFINES_CONTENT += '\n\n' + } } // INFO: If null, only the define value diff --git a/src/backend/editor/compiler/modbus-defines.ts b/src/backend/editor/compiler/modbus-defines.ts new file mode 100644 index 000000000..2c4584ac3 --- /dev/null +++ b/src/backend/editor/compiler/modbus-defines.ts @@ -0,0 +1,165 @@ +/** + * Emit the `//Comms Configuration` block in `defines.h` from a board's + * persisted VPP Modbus screen state. + * + * The screen is declared in `packages/com.openplc.arduino/screens/modbus.json` + * (shared across all Arduino-family VPP packages); its values land in + * `DeviceConfiguration.vendorScreenData` under keys `modbus_rtu` and + * `modbus_tcp` (one per `section.id` in the screen JSON, resolved by + * `getSectionPersistenceKey` in `frontend/utils/vpp/persistence-keys.ts`). + * + * The macros emitted here are the same set the historical + * `communicationConfiguration` pipeline used (removed in commit + * c379c7a9c "drop communicationConfiguration from device schema") — + * `MBSERIAL`, `MBSERIAL_IFACE`, `MBSERIAL_BAUD`, `MBSERIAL_SLAVE`, + * `MBSERIAL_TXPIN`, `MBTCP`, `MBTCP_ETHERNET`, `MBTCP_WIFI`, `MBTCP_MAC`, + * `MBTCP_IP`, `MBTCP_DNS`, `MBTCP_GATEWAY`, `MBTCP_SUBNET`, `MBTCP_SSID`, + * `MBTCP_PWD`, `MODBUS_ENABLED`. The consumer (`resources/sources/ + * Baremetal/ModbusSlave.cpp`) was kept intact and still reads these + * exact names. + * + * Pure function — no I/O, no electron, no store. Caller is responsible + * for fishing `modbus_rtu` and `modbus_tcp` out of `vendorScreenData`. + */ + +/** + * Subset of the persisted screen state this emitter reads. Mirrors the + * field IDs declared in `screens/modbus.json` — keep in sync if the + * VPP screen field set evolves. + */ +export interface VppModbusScreenState { + modbus_rtu?: { + enabled?: boolean + rtu_interface?: string + rtu_baud_rate?: string + rtu_slave_id?: number + enable_rs485_en_pin?: boolean + rtu_rs485_en_pin?: string + } + modbus_tcp?: { + enabled?: boolean + tcp_interface?: 'Ethernet' | 'Wi-Fi' + tcp_mac_address?: string + tcp_wifi_ssid?: string + tcp_wifi_password?: string + enable_dhcp?: boolean + ip_address?: string + gateway?: string + subnet?: string + dns?: string + } +} + +/** + * `aa:bb:cc:dd:ee:ff` → `0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff` so it can + * land verbatim in `byte mac[] = { MBTCP_MAC };`. Accepts the canonical + * colon-separated form the screen's `mac-address` field validates; any + * other shape is treated as already-formatted and returned as-is so the + * user can supply a pre-formatted literal if they want. + */ +function formatMacForDefine(raw: string): string { + const colonShape = /^([0-9a-fA-F]{2})(:[0-9a-fA-F]{2}){5}$/ + if (!colonShape.test(raw)) return raw + return raw + .split(':') + .map((b) => `0x${b.toLowerCase()}`) + .join(', ') +} + +/** + * `192.168.1.100` → `192, 168, 1, 100`. Arduino's `IPAddress` macro + * expects the byte-list shape inside parentheses. Returns the raw string + * untouched when it doesn't look like a dotted IPv4 — same defensive + * stance as `formatMacForDefine`. + */ +function formatIpForDefine(raw: string): string { + const dottedShape = /^\d{1,3}(\.\d{1,3}){3}$/ + if (!dottedShape.test(raw)) return raw + return raw.split('.').join(', ') +} + +// Defaults mirror the `default` values declared in the canonical VPP +// Modbus screen (`packages/com.openplc.arduino/screens/modbus.json`). +// They have to live in code rather than be discovered at runtime because +// the form layout (`form-layout.tsx`) only persists fields the user +// touches — toggling "Enable Modbus RTU" alone results in +// `{ enabled: true }` with every other field undefined, but the +// firmware still needs MBSERIAL_IFACE / MBSERIAL_BAUD / MBSERIAL_SLAVE +// to compile (ModbusSlave.cpp uses them as object/literal values). +// Keep these in sync if the screen schema's defaults change. +const RTU_DEFAULTS = { + rtu_interface: 'Serial', + rtu_baud_rate: '115200', + rtu_slave_id: 1, +} as const + +const TCP_DEFAULTS = { + tcp_interface: 'Ethernet' as const, +} + +/** + * Build the `//Comms Configuration` block. Returns an empty string when + * neither RTU nor TCP is enabled so `defines.h` stays clean for boards + * without Modbus configured. + * + * Defaults are applied per-field when the persisted state lacks the + * value (see comment on `RTU_DEFAULTS` above for the rationale). The + * `enable_*` gates remain authoritative — defaults only kick in for + * fields under an active section. + * + * The output always ends with a trailing newline so callers can + * concatenate without adding their own. + */ +export function generateModbusDefines(state: VppModbusScreenState): string { + const rtu = state.modbus_rtu ?? {} + const tcp = state.modbus_tcp ?? {} + const rtuOn = rtu.enabled === true + const tcpOn = tcp.enabled === true + + if (!rtuOn && !tcpOn) return '' + + const lines: string[] = [] + lines.push('//Comms Configuration') + + if (rtuOn) { + const iface = rtu.rtu_interface ?? RTU_DEFAULTS.rtu_interface + const baud = rtu.rtu_baud_rate ?? RTU_DEFAULTS.rtu_baud_rate + const slave = typeof rtu.rtu_slave_id === 'number' ? rtu.rtu_slave_id : RTU_DEFAULTS.rtu_slave_id + lines.push(`#define MBSERIAL_IFACE ${iface}`) + lines.push(`#define MBSERIAL_BAUD ${baud}`) + lines.push(`#define MBSERIAL_SLAVE ${slave}`) + if (rtu.enable_rs485_en_pin === true && rtu.rtu_rs485_en_pin) { + lines.push(`#define MBSERIAL_TXPIN ${rtu.rtu_rs485_en_pin}`) + } + lines.push('#define MBSERIAL') + } + + if (tcpOn) { + if (tcp.tcp_mac_address) lines.push(`#define MBTCP_MAC ${formatMacForDefine(tcp.tcp_mac_address)}`) + if (tcp.enable_dhcp !== true) { + // Static-host block: only emit the macros when actually configured. + // ModbusSlave.cpp guards each with #ifdef, so omission is the canonical + // "fall back to DHCP" signal even though `enable_dhcp=true` is the + // explicit selector in the UI. + if (tcp.ip_address) lines.push(`#define MBTCP_IP ${formatIpForDefine(tcp.ip_address)}`) + if (tcp.dns) lines.push(`#define MBTCP_DNS ${formatIpForDefine(tcp.dns)}`) + if (tcp.gateway) lines.push(`#define MBTCP_GATEWAY ${formatIpForDefine(tcp.gateway)}`) + if (tcp.subnet) lines.push(`#define MBTCP_SUBNET ${formatIpForDefine(tcp.subnet)}`) + } + const iface = tcp.tcp_interface ?? TCP_DEFAULTS.tcp_interface + if (iface === 'Wi-Fi') { + if (tcp.tcp_wifi_ssid) lines.push(`#define MBTCP_SSID "${tcp.tcp_wifi_ssid}"`) + if (tcp.tcp_wifi_password) lines.push(`#define MBTCP_PWD "${tcp.tcp_wifi_password}"`) + lines.push('#define MBTCP_WIFI') + } else { + lines.push('#define MBTCP_ETHERNET') + } + lines.push('#define MBTCP') + } + + // `MODBUS_ENABLED` gates everything Modbus in ModbusSlave.cpp. Emit + // once regardless of which transports are active. + lines.push('#define MODBUS_ENABLED') + + return lines.join('\n') + '\n' +} From 004c3d582be37c00b721df4e576860b74333e881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 28 May 2026 02:13:12 +0200 Subject: [PATCH 12/61] fix(compiler): unbreak Modbus TCP build on VPP Arduino boards (ESP8266/ESP32 etc.) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the previous Modbus VPP wiring (97f495666) surfaced when actually flashing an ESP8266 NodeMCU with Modbus TCP + Wi-Fi: 1. `defines.h` was missing the per-board `BOARD_ESP8266`/`BOARD_ESP32` /`BOARD_WIFININA` macro. `handleGenerateDefinitionsFile` was still pulling the `define` field from the legacy `hals.json`, which no longer carries VPP boards — every Arduino-family board lives in a VPP now, so the macro never made it into the file. ModbusSlave.h's board-detection chain (`#if defined(BOARD_ESP8266) … #elif defined( BOARD_ESP32) … #else #include `) then fell through to the default branch, where `` resolved to WiFiNINA's header on any host that has the WiFiNINA library installed. Compile errored with `'PinStatus' does not name a type` from inside WiFiNINA. Fix: route the lookup through `BoardInfoResolver.resolve()` so VPP `hal.define` and legacy `boardEntry.define` both reach defines.h the same way every other compile site in this module already does (`handleGenerateArduinoCppFile`, `handleCompileArduinoProgram`, etc. all use the resolver). 2. The Modbus emitter was treating `MBTCP_MAC`/`MBTCP_IP`/`MBTCP_DNS` /`MBTCP_GATEWAY`/`MBTCP_SUBNET` as optional — only emitting when the corresponding screen field had a value. But `Baremetal.ino` references all five unconditionally inside `#ifdef MBTCP`: uint8_t mac[] = { MBTCP_MAC }; uint8_t ip[] = { MBTCP_IP }; uint8_t dns[] = { MBTCP_DNS }; uint8_t gateway[] = { MBTCP_GATEWAY }; uint8_t subnet[] = { MBTCP_SUBNET }; and uses `sizeof(arr) < 4` as the compile-time DHCP-vs-static selector that cascades through to `mbconfig_ethernet_iface(mac, …, NULL, NULL, …)`. Omitting a single macro broke the build with "not declared in this scope". This was a latent contract violation the old `communicationConfiguration` schema also tripped (its emitter had the same `if (modbusTCP.tcpMacAddress !== null)` gating) — it just never surfaced because the historical UI always required those fields. Fix: always emit the five MBTCP_* macros when TCP is enabled. Missing values lower to a single-byte `0` so the resulting array has `sizeof == 1`, the cascade's `< 4` test fires, and runtime takes the DHCP/NULL path. The Wi-Fi branch inside `mbconfig_ethernet_iface` ignores the IP/gateway/subnet args entirely on ESP8266/ESP32 (see `ModbusSlave.cpp:199-225`), so the placeholders are harmless there too. End-to-end verified on Arduino Uno (Modbus RTU) and ESP8266 NodeMCU (Modbus TCP + Wi-Fi): both compile clean and the device boots with the configured transports active. (Static-IP-reachability over Wi-Fi is a separate, pre-existing runtime concern — the `WiFi.config(…) → WiFi.begin(…)` sequence in `mbconfig_ethernet_iface` is byte- identical to its 2022 original and outside the scope of this commit.) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../compiler/__tests__/modbus-defines.test.ts | 32 ++++++++++++----- .../editor/compiler/compiler-module.ts | 35 +++++++++++-------- src/backend/editor/compiler/modbus-defines.ts | 35 +++++++++++++------ 3 files changed, 68 insertions(+), 34 deletions(-) diff --git a/src/backend/editor/compiler/__tests__/modbus-defines.test.ts b/src/backend/editor/compiler/__tests__/modbus-defines.test.ts index 6b234feca..cc58945ab 100644 --- a/src/backend/editor/compiler/__tests__/modbus-defines.test.ts +++ b/src/backend/editor/compiler/__tests__/modbus-defines.test.ts @@ -50,6 +50,17 @@ describe('generateModbusDefines', () => { expect(out).not.toContain('MBTCP_WIFI') }) + it('always emits MBTCP_MAC/IP/DNS/GATEWAY/SUBNET when MBTCP is on (Baremetal.ino references them unconditionally)', () => { + // Unset values land as `0` (single-byte arrays) so the sizeof()<4 cascade in + // Baremetal.ino falls through to mbconfig_ethernet_iface(mac, NULL, ...). + const out = generateModbusDefines({ modbus_tcp: { enabled: true, enable_dhcp: true } }) + expect(out).toContain('#define MBTCP_MAC 0') + expect(out).toContain('#define MBTCP_IP 0') + expect(out).toContain('#define MBTCP_DNS 0') + expect(out).toContain('#define MBTCP_GATEWAY 0') + expect(out).toContain('#define MBTCP_SUBNET 0') + }) + it('honors custom RTU values (non-default baud, slave_id, interface)', () => { const out = generateModbusDefines({ modbus_rtu: { @@ -107,25 +118,27 @@ describe('generateModbusDefines', () => { expect(out).toContain('#define MODBUS_ENABLED') }) - it('skips static IP defines when DHCP is enabled (MAC + transport selector still emit)', () => { + it('emits MBTCP_IP/DNS/GATEWAY/SUBNET as `0` placeholders when DHCP is enabled (sizeof<4 → DHCP path in Baremetal.ino)', () => { const out = generateModbusDefines({ modbus_tcp: { enabled: true, tcp_interface: 'Ethernet', tcp_mac_address: 'de:ad:be:ef:fe:ed', enable_dhcp: true, + // The user filled the static-host fields but then flipped DHCP on; the + // static values are intentionally not used. ip_address: '192.168.1.100', gateway: '192.168.1.1', subnet: '255.255.255.0', dns: '8.8.8.8', }, }) - expect(out).toContain('#define MBTCP_MAC') + expect(out).toContain('#define MBTCP_MAC 0xde, 0xad, 0xbe, 0xef, 0xfe, 0xed') + expect(out).toContain('#define MBTCP_IP 0') + expect(out).toContain('#define MBTCP_DNS 0') + expect(out).toContain('#define MBTCP_GATEWAY 0') + expect(out).toContain('#define MBTCP_SUBNET 0') expect(out).toContain('#define MBTCP_ETHERNET') - expect(out).not.toContain('MBTCP_IP') - expect(out).not.toContain('MBTCP_DNS') - expect(out).not.toContain('MBTCP_GATEWAY') - expect(out).not.toContain('MBTCP_SUBNET') }) it('emits Wi-Fi specifics (SSID, PWD, MBTCP_WIFI) and omits MBTCP_ETHERNET when interface is Wi-Fi', () => { @@ -144,11 +157,14 @@ describe('generateModbusDefines', () => { expect(out).not.toContain('MBTCP_ETHERNET') }) - it('omits MBTCP_MAC when the field is empty (boards with built-in MAC)', () => { + it('emits MBTCP_MAC as `0` placeholder when the field is empty (boards with built-in MAC ignore it)', () => { const out = generateModbusDefines({ modbus_tcp: { enabled: true, tcp_interface: 'Ethernet', enable_dhcp: true }, }) - expect(out).not.toContain('MBTCP_MAC') + // Empty MAC → placeholder `0` so the .ino's `uint8_t mac[] = { MBTCP_MAC };` + // compiles. Wi-Fi-equipped boards (ESP8266, ESP32, etc.) ignore the MAC + // inside mbconfig_ethernet_iface, so the placeholder is harmless. + expect(out).toContain('#define MBTCP_MAC 0') expect(out).toContain('#define MBTCP_ETHERNET') }) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 98d67750d..8fd6c9721 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -1113,31 +1113,36 @@ class CompilerModule { const definitionsFilePath = join(buildTargetDirectoryPath, 'src', 'defines.h') // === Files contents that we need === - const halsFileContent = await CompilerModule.readJSONFile(this.halsFilePath) 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] + // Source the board's `define` (the per-target macro like BOARD_ESP8266 / + // BOARD_ESP32 / BOARD_WIFININA) from BoardInfoResolver so VPP-installed + // packages contribute the same way legacy hals.json entries used to. + // Without this, `ModbusSlave.h`'s board-detection chain + // (`#if defined(BOARD_ESP8266) … #elif defined(BOARD_ESP32) …`) falls + // through to the WiFiNINA fallback for every ESP8266/ESP32 board the + // editor only knows about through a VPP. + const resolver = new BoardInfoResolver(this.halsFilePath, this.sourceDirectoryPath, new PackageManagerModule()) + const boardInfo = await resolver.resolve(boardTarget) + const boardDefines = boardInfo.define + ? Array.isArray(boardInfo.define) + ? boardInfo.define + : [boardInfo.define] + : [] // ===== 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. + // 1. Emit per-board defines (BOARD_*, vendor flags, etc.). Both VPP and + // legacy hals.json entries route through `boardInfo.define`. + if (boardDefines.length > 0) { 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` + for (const define of boardDefines) { + DEFINES_CONTENT += `#define ${define}\n` } } - // 2. If the board entry does not have the define property, we will just write a double line break to the file. + // 2. Separator between the board defines block and the rest. DEFINES_CONTENT += '\n\n' // 3. Now we write the information for the defines.h file based on the device configuration and other preferences. diff --git a/src/backend/editor/compiler/modbus-defines.ts b/src/backend/editor/compiler/modbus-defines.ts index 2c4584ac3..693eb0b96 100644 --- a/src/backend/editor/compiler/modbus-defines.ts +++ b/src/backend/editor/compiler/modbus-defines.ts @@ -135,17 +135,30 @@ export function generateModbusDefines(state: VppModbusScreenState): string { } if (tcpOn) { - if (tcp.tcp_mac_address) lines.push(`#define MBTCP_MAC ${formatMacForDefine(tcp.tcp_mac_address)}`) - if (tcp.enable_dhcp !== true) { - // Static-host block: only emit the macros when actually configured. - // ModbusSlave.cpp guards each with #ifdef, so omission is the canonical - // "fall back to DHCP" signal even though `enable_dhcp=true` is the - // explicit selector in the UI. - if (tcp.ip_address) lines.push(`#define MBTCP_IP ${formatIpForDefine(tcp.ip_address)}`) - if (tcp.dns) lines.push(`#define MBTCP_DNS ${formatIpForDefine(tcp.dns)}`) - if (tcp.gateway) lines.push(`#define MBTCP_GATEWAY ${formatIpForDefine(tcp.gateway)}`) - if (tcp.subnet) lines.push(`#define MBTCP_SUBNET ${formatIpForDefine(tcp.subnet)}`) - } + // MBTCP_MAC / MBTCP_IP / MBTCP_DNS / MBTCP_GATEWAY / MBTCP_SUBNET + // are referenced unconditionally inside the `#ifdef MBTCP` block in + // `resources/sources/Baremetal/Baremetal.ino` (it builds five byte + // arrays and uses `sizeof(arr) < 4` as a compile-time DHCP-vs-static + // selector that cascades through to `mbconfig_ethernet_iface(mac, + // …, NULL, NULL, …)`). Missing a single macro fails compilation; an + // unset macro is signalled by emitting a single-byte `0` so the + // array has `sizeof == 1`, the `< 4` check fires, and the runtime + // falls back to the DHCP/NULL path. Wi-Fi mode ignores these args + // inside `mbconfig_ethernet_iface` (see `ModbusSlave.cpp:199-225`), + // so the placeholder values are harmless there too. + const macLiteral = tcp.tcp_mac_address ? formatMacForDefine(tcp.tcp_mac_address) : '0' + lines.push(`#define MBTCP_MAC ${macLiteral}`) + + const dhcpOn = tcp.enable_dhcp === true + const ipLiteral = !dhcpOn && tcp.ip_address ? formatIpForDefine(tcp.ip_address) : '0' + const dnsLiteral = !dhcpOn && tcp.dns ? formatIpForDefine(tcp.dns) : '0' + const gatewayLiteral = !dhcpOn && tcp.gateway ? formatIpForDefine(tcp.gateway) : '0' + const subnetLiteral = !dhcpOn && tcp.subnet ? formatIpForDefine(tcp.subnet) : '0' + lines.push(`#define MBTCP_IP ${ipLiteral}`) + lines.push(`#define MBTCP_DNS ${dnsLiteral}`) + lines.push(`#define MBTCP_GATEWAY ${gatewayLiteral}`) + lines.push(`#define MBTCP_SUBNET ${subnetLiteral}`) + const iface = tcp.tcp_interface ?? TCP_DEFAULTS.tcp_interface if (iface === 'Wi-Fi') { if (tcp.tcp_wifi_ssid) lines.push(`#define MBTCP_SSID "${tcp.tcp_wifi_ssid}"`) From 2aa28d0dcf9abf364a18b07582fb82c9462cabdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 28 May 2026 02:21:46 +0200 Subject: [PATCH 13/61] feat(vendor-screen): honor password, ip-address, mac-address field types in form layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The VPP screen schema (`schema/screen.schema.json`) declares 13 field types, but the form layout renderer only had explicit branches for `boolean`, `number`, and `select` — everything else fell through to a plain ``. That made the Wi-Fi password on the Modbus screen show in cleartext while the user typed it, and the IP / DNS / gateway / subnet / MAC fields accept any string with zero format hint. Adds three new branches: - `password` → `` Native browser masking. Suppresses credential autofill since this is a device config form, not a sign-in. - `ip-address` → `` Default IPv4 pattern + 15-char cap; both overridable per-field via the schema's `validation` and `maxLength`. The `placeholder` declared on the modbus.json fields (`192.168.0.10`, `8.8.8.8`, etc.) now actually shows up. `inputMode='decimal'` hints mobile keyboards to numeric layout while keeping `.` typeable. - `mac-address` → same shape with the colon-separated hex pattern and the screen's `DE:AD:BE:EF:00:01` placeholder. The generic text fallback now also honors `placeholder` / `maxLength` / `validation` from the schema so plain `type: "text"` fields (Wi-Fi SSID, RS485 EN pin) pick up their hints too. `FieldDef` is extended with the three optional props and the input className gets factored into `TEXT_INPUT_CLASS` so the next field type that lands doesn't drift style-wise. No behaviour change for boolean / number / select / unknown types. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../vendor-screen/layouts/form-layout.tsx | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx index a2fd58268..032e1bc4f 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx @@ -18,8 +18,28 @@ type FieldDef = { unit?: string help?: string options?: string[] | Array<{ value: string; label: string }> + // Honored by text-like inputs (text, password, ip-address, mac-address). + // Mirrors the VPP screen schema's optional field props — empty strings + // are skipped so HTML5 placeholder/maxLength/pattern stay unset when + // the screen author didn't supply them. + placeholder?: string + maxLength?: number + validation?: string } +// Shared input styling for every branch (text, number, password, +// ip-address, mac-address). Keeping it in one place avoids style drift +// when new field types land. +const TEXT_INPUT_CLASS = + 'flex h-[30px] w-48 items-center rounded-md border border-neutral-100 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300' + +// Anchor-less HTML5 patterns for the formatted text types. The schema's +// per-field `validation` (when present) is more specific and wins via the +// runtime override below, but these defaults give a sensible UX hint when +// the screen author didn't ship a regex. +const IPV4_PATTERN = '^(\\d{1,3}\\.){3}\\d{1,3}$' +const MAC_PATTERN = '^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$' + type FormLayoutProps = { section: ScreenSection } @@ -143,12 +163,47 @@ function FormLayout({ section }: FormLayoutProps) { })} + ) : field.type === 'password' ? ( + updateField(field.id, e.target.value)} + placeholder={field.placeholder} + maxLength={field.maxLength} + pattern={field.validation} + autoComplete='new-password' + className={TEXT_INPUT_CLASS} + /> + ) : field.type === 'ip-address' ? ( + updateField(field.id, e.target.value)} + placeholder={field.placeholder ?? '0.0.0.0'} + maxLength={field.maxLength ?? 15} + pattern={field.validation ?? IPV4_PATTERN} + className={TEXT_INPUT_CLASS} + /> + ) : field.type === 'mac-address' ? ( + updateField(field.id, e.target.value)} + placeholder={field.placeholder ?? 'AA:BB:CC:DD:EE:FF'} + maxLength={field.maxLength ?? 17} + pattern={field.validation ?? MAC_PATTERN} + className={TEXT_INPUT_CLASS} + /> ) : ( updateField(field.id, e.target.value)} - className='flex h-[30px] w-48 items-center rounded-md border border-neutral-100 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300' + placeholder={field.placeholder} + maxLength={field.maxLength} + pattern={field.validation} + className={TEXT_INPUT_CLASS} /> )} {field.help && } From dac95858ab8dd2cbe0bcfb97ea0772acd0113777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 28 May 2026 03:10:10 +0200 Subject: [PATCH 14/61] feat(devices-dropdown): group boards by source VPP package, built-ins on top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The device dropdown in the board Configuration screen sorted every entry alphabetically into one flat list — so a user with both `com.openplc.arduino` and `com.openplc.espressif` installed saw Arduino boards interleaved with ESP boards by name. Hard to scan, and the built-in Runtime v3 / Runtime v4 / Simulator entries could end up scattered too depending on which packages were installed. New ordering, owned by the pure `orderBoardsByVppGroup` helper: 1. Built-in targets first — anything in the merged Map without a `vpp` field — sorted alphabetically (which incidentally yields Runtime v3 → Runtime v4 → Simulator naturally). 2. VPP-sourced devices, partitioned by `info.vpp.packageId`. Groups sorted alphabetically by package id; devices within each group sorted alphabetically by display name. Every board from `com.openplc.arduino` lands contiguously before any board from `com.openplc.espressif`, etc. `hardware-module.ts:getAvailableBoards` swaps its final `.sort(localeCompare)` pass for the helper. No other call sites change — Map insertion order propagates through `Array.from(.entries ())` in `board.tsx` so the dropdown picks up the new ordering for free. Helper is pure, no I/O, no module dependencies; defensive against empty input and malformed manifests (entries with falsy `packageId` fall back to the built-ins bucket). Nine unit tests cover the full contract including a reproduction of the user-reported scenario (arduino + espressif installed side-by-side). The `AvailableBoards` Map's value type is declared inline in `types.ts` and the legacy `BoardInfo` export refers to the hals.json schema instead — derive the runtime/UI value type via `infer V` on the Map so future shape drift propagates here automatically. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../order-boards-by-vpp-group.test.ts | 140 ++++++++++++++++++ .../editor/hardware/hardware-module.ts | 9 +- .../hardware/order-boards-by-vpp-group.ts | 67 +++++++++ 3 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 src/backend/editor/hardware/__tests__/order-boards-by-vpp-group.test.ts create mode 100644 src/backend/editor/hardware/order-boards-by-vpp-group.ts diff --git a/src/backend/editor/hardware/__tests__/order-boards-by-vpp-group.test.ts b/src/backend/editor/hardware/__tests__/order-boards-by-vpp-group.test.ts new file mode 100644 index 000000000..d3df92a42 --- /dev/null +++ b/src/backend/editor/hardware/__tests__/order-boards-by-vpp-group.test.ts @@ -0,0 +1,140 @@ +import { orderBoardsByVppGroup } from '../order-boards-by-vpp-group' +import type { AvailableBoards } from '../types' + +// The Map's value type is declared inline in `types.ts`; derive it the +// same way the helper does. Tests only care about the `vpp.packageId` +// field, so we cast minimal objects through `unknown`. +type AvailableBoardInfo = AvailableBoards extends Map ? V : never +const builtIn = (): AvailableBoardInfo => ({}) as unknown as AvailableBoardInfo +const vppBoard = (packageId: string): AvailableBoardInfo => + ({ vpp: { packageId } }) as unknown as AvailableBoardInfo + +describe('orderBoardsByVppGroup', () => { + it('returns an empty Map untouched', () => { + const result = orderBoardsByVppGroup(new Map()) + expect(result.size).toBe(0) + }) + + it('keeps the three built-in targets at the top, sorted by name', () => { + // Insertion order intentionally non-alphabetical so we can prove the + // function sorts rather than passing through whatever the caller built. + const input: AvailableBoards = new Map([ + ['OpenPLC Simulator', builtIn()], + ['OpenPLC Runtime v4', builtIn()], + ['OpenPLC Runtime v3', builtIn()], + ]) + expect([...orderBoardsByVppGroup(input).keys()]).toEqual([ + 'OpenPLC Runtime v3', + 'OpenPLC Runtime v4', + 'OpenPLC Simulator', + ]) + }) + + it('groups VPP devices contiguously by package id, never interleaving across packages', () => { + // Mixed input: a board from `arduino`, one from `espressif`, another + // from `arduino` — naive alphabetical sort would scatter them. The + // function must keep `com.openplc.arduino` together before + // `com.openplc.espressif`. + const input: AvailableBoards = new Map([ + ['ESP32 Generic', vppBoard('com.openplc.espressif')], + ['Arduino Uno', vppBoard('com.openplc.arduino')], + ['ESP8266 NodeMCU', vppBoard('com.openplc.espressif')], + ['Arduino Mega', vppBoard('com.openplc.arduino')], + ]) + expect([...orderBoardsByVppGroup(input).keys()]).toEqual([ + 'Arduino Mega', + 'Arduino Uno', + 'ESP32 Generic', + 'ESP8266 NodeMCU', + ]) + }) + + it('sorts VPP groups alphabetically by package id, then devices alphabetically inside each group', () => { + const input: AvailableBoards = new Map([ + ['Foo', vppBoard('com.vendor.b')], + ['Bar', vppBoard('com.vendor.a')], + ['Baz', vppBoard('com.vendor.b')], + ['Qux', vppBoard('com.vendor.a')], + ]) + expect([...orderBoardsByVppGroup(input).keys()]).toEqual(['Bar', 'Qux', 'Baz', 'Foo']) + }) + + it('places built-ins ahead of every VPP group regardless of name', () => { + // A VPP board name that would sort before "OpenPLC Runtime v3" alphabetically + // ("Arduino Uno" < "OpenPLC ...") must still appear AFTER built-ins. + const input: AvailableBoards = new Map([ + ['Arduino Uno', vppBoard('com.openplc.arduino')], + ['OpenPLC Runtime v3', builtIn()], + ['OpenPLC Simulator', builtIn()], + ]) + expect([...orderBoardsByVppGroup(input).keys()]).toEqual([ + 'OpenPLC Runtime v3', + 'OpenPLC Simulator', + 'Arduino Uno', + ]) + }) + + it('treats VPP entries with falsy packageId as built-ins (defensive against malformed manifests)', () => { + const malformed = { vpp: { packageId: '' } } as unknown as AvailableBoardInfo + const input: AvailableBoards = new Map([ + ['Broken Board', malformed], + ['Real Board', vppBoard('com.openplc.arduino')], + ]) + // "Broken Board" with empty packageId falls back into built-ins, sorts + // alphabetically with them, lands ahead of the VPP group. + expect([...orderBoardsByVppGroup(input).keys()]).toEqual(['Broken Board', 'Real Board']) + }) + + it('preserves the original BoardInfo references (no clone, no field rewrite)', () => { + const built = builtIn() + const vpp = vppBoard('com.openplc.arduino') + const input: AvailableBoards = new Map([ + ['Arduino Uno', vpp], + ['OpenPLC Simulator', built], + ]) + const result = orderBoardsByVppGroup(input) + expect(result.get('OpenPLC Simulator')).toBe(built) + expect(result.get('Arduino Uno')).toBe(vpp) + }) + + it('produces a fresh Map (does not mutate the input)', () => { + const input: AvailableBoards = new Map([ + ['B', vppBoard('com.openplc.a')], + ['A', vppBoard('com.openplc.a')], + ]) + const snapshot = [...input.keys()] + orderBoardsByVppGroup(input) + expect([...input.keys()]).toEqual(snapshot) + }) + + it('models the canonical openplc-arduino + openplc-espressif install scenario end-to-end', () => { + // Reproduces the user-reported pain point: alphabetical sort interleaved + // an Arduino board between two Espressif boards. The grouping fixes it + // while keeping built-ins on top. + const input: AvailableBoards = new Map([ + ['Arduino Uno R4 WiFi', vppBoard('com.openplc.arduino')], + ['ESP32 Generic', vppBoard('com.openplc.espressif')], + ['Arduino Mega', vppBoard('com.openplc.arduino')], + ['ESP8266 D1-mini', vppBoard('com.openplc.espressif')], + ['OpenPLC Runtime v4', builtIn()], + ['Arduino Uno', vppBoard('com.openplc.arduino')], + ['OpenPLC Simulator', builtIn()], + ['OpenPLC Runtime v3', builtIn()], + ['ESP32 WROOM', vppBoard('com.openplc.espressif')], + ]) + expect([...orderBoardsByVppGroup(input).keys()]).toEqual([ + // Built-ins, alphabetical + 'OpenPLC Runtime v3', + 'OpenPLC Runtime v4', + 'OpenPLC Simulator', + // com.openplc.arduino group, alphabetical + 'Arduino Mega', + 'Arduino Uno', + 'Arduino Uno R4 WiFi', + // com.openplc.espressif group, alphabetical + 'ESP32 Generic', + 'ESP32 WROOM', + 'ESP8266 D1-mini', + ]) + }) +}) diff --git a/src/backend/editor/hardware/hardware-module.ts b/src/backend/editor/hardware/hardware-module.ts index b56f27a23..a2643b53b 100644 --- a/src/backend/editor/hardware/hardware-module.ts +++ b/src/backend/editor/hardware/hardware-module.ts @@ -11,6 +11,7 @@ import { PackageManagerModule } from '../package-manager' import { logger } from '../services/logger-service' import { assertPathContained } from '../utils/path-containment' import { type BoardBuildInfo, BoardInfoResolver } from './board-info-resolver' +import { orderBoardsByVppGroup } from './order-boards-by-vpp-group' import type { AvailableBoards, HalsFile, SerialPort } from './types' // interface MethodsResult { @@ -205,9 +206,11 @@ class HardwareModule { const mutableBoards: AvailableBoards = new Map(availableBoards) await this.#mergeVppBoards(mutableBoards) - // Sort boards alphabetically by name - const sortedBoards: AvailableBoards = new Map([...mutableBoards.entries()].sort(([a], [b]) => a.localeCompare(b))) - return sortedBoards + // Group by source VPP package so devices from the same package land + // contiguously in the device dropdown, with the three built-in targets + // (OpenPLC Runtime v3, v4, Simulator) pinned to the top. See + // `order-boards-by-vpp-group.ts` for the full ordering contract. + return orderBoardsByVppGroup(mutableBoards) } async #mergeVppBoards(boards: AvailableBoards): Promise { diff --git a/src/backend/editor/hardware/order-boards-by-vpp-group.ts b/src/backend/editor/hardware/order-boards-by-vpp-group.ts new file mode 100644 index 000000000..88b7f0112 --- /dev/null +++ b/src/backend/editor/hardware/order-boards-by-vpp-group.ts @@ -0,0 +1,67 @@ +import type { AvailableBoards } from './types' + +// The `AvailableBoards` Map's value shape is declared inline in +// `types.ts` (the legacy `BoardInfo` export refers to the hals.json +// schema, not the runtime/UI board info). Derive the correct value type +// from the Map itself so future schema drift in `AvailableBoards` +// propagates here automatically. +type AvailableBoardInfo = AvailableBoards extends Map ? V : never + +/** + * Reorder the boards Map so the device dropdown groups entries by their + * source VPP package instead of intermixing them alphabetically across + * every installed package. + * + * Order: + * + * 1. Built-in targets (anything without a `vpp` field — `hals.json` + * retains `OpenPLC Runtime v3`, `OpenPLC Runtime v4`, and + * `OpenPLC Simulator` after the Arduino-to-VPP migration). Sorted + * by name alphabetically; the natural alphabetical order also + * happens to be the chronological order users expect (Runtime v3 + * → Runtime v4 → Simulator). + * + * 2. VPP-sourced devices, partitioned by `info.vpp.packageId`, + * VPP groups sorted alphabetically by package id, devices within + * each group sorted alphabetically by display name. Side effect: + * every device from `com.openplc.arduino` lands contiguously, then + * every device from `com.openplc.arduino-industrial`, etc. + * + * Pure function — relies on `Map` insertion-order preservation. Caller + * is expected to feed the merged result of `hals.json` + every + * `#mergeVppBoards` pass. Defensive against: + * + * - Empty input (returns an empty Map without iterating). + * - VPP entries with falsy `packageId` (treated as built-in so a + * malformed manifest can't bury an entry off-list). + */ +export function orderBoardsByVppGroup(boards: AvailableBoards): AvailableBoards { + const builtIns: Array<[string, AvailableBoardInfo]> = [] + const vppGroups = new Map>() + + for (const [name, info] of boards.entries()) { + const packageId = info.vpp?.packageId + if (packageId) { + const group = vppGroups.get(packageId) ?? [] + group.push([name, info]) + vppGroups.set(packageId, group) + } else { + builtIns.push([name, info]) + } + } + + builtIns.sort(([a], [b]) => a.localeCompare(b)) + for (const group of vppGroups.values()) { + group.sort(([a], [b]) => a.localeCompare(b)) + } + const sortedGroupKeys = [...vppGroups.keys()].sort((a, b) => a.localeCompare(b)) + + const ordered: AvailableBoards = new Map() + for (const [name, info] of builtIns) ordered.set(name, info) + for (const key of sortedGroupKeys) { + const group = vppGroups.get(key) + if (!group) continue + for (const [name, info] of group) ordered.set(name, info) + } + return ordered +} From 5792a4e6f7f01c9330057357891604a4684574e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 28 May 2026 22:52:37 +0200 Subject: [PATCH 15/61] feat(package-manager): add VPP catalog browser with version action menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Browse Catalog tab next to Installed in the Package Manager, modelled after the Arduino IDE Board Manager. Each card folds every package action into a single chevron-trigger menu (Radix DropdownMenu) that drives install / update / switch / uninstall / editor-incompat states off a small state machine derived from installed version vs the package's per-version minEditorVersion. PackagePort gains listRemoteCatalog() and installFromRemote() so the Browse Catalog UI flows through the existing port abstraction. Both adapter methods are stubs today — listRemoteCatalog rejects with a clear "backend not yet available" so the CatalogBrowser surfaces its error banner, and installFromRemote resolves with a backend-not-wired error that names the requested packageId@version. The wire contract the CDN backend must match is documented in EDGE-482 (and its two subtasks); the editor adapter swap is a strict string replace of the stubs with HTTP fetch + write-to-disk handoff to the local install pipeline once the CDN ships. Adds a tiny inline semver utility (compare + compatibility check) to avoid pulling in the semver npm package for two comparisons, with 100% test coverage as required by the frontend utils threshold. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../package-manager/catalog-browser.tsx | 467 ++++++++++++++++++ .../editor/package-manager/index.tsx | 296 ++++++----- src/frontend/utils/__tests__/semver.test.ts | 62 +++ src/frontend/utils/semver.ts | 45 ++ .../editor/__tests__/package-adapter.test.ts | 24 + .../adapters/editor/package-adapter.ts | 37 +- src/middleware/shared/ports/package-port.ts | 22 +- src/middleware/shared/ports/types.ts | 38 ++ 8 files changed, 862 insertions(+), 129 deletions(-) create mode 100644 src/frontend/components/_features/[workspace]/editor/package-manager/catalog-browser.tsx create mode 100644 src/frontend/utils/__tests__/semver.test.ts create mode 100644 src/frontend/utils/semver.ts diff --git a/src/frontend/components/_features/[workspace]/editor/package-manager/catalog-browser.tsx b/src/frontend/components/_features/[workspace]/editor/package-manager/catalog-browser.tsx new file mode 100644 index 000000000..fd2d2c09f --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/package-manager/catalog-browser.tsx @@ -0,0 +1,467 @@ +/** + * Catalog Browser — remote VPP discovery surface. + * + * Renders a single-pane scrollable list of remote packages fetched from the + * OpenPLC CDN, modelled after the Arduino IDE Board Manager: a search bar at + * the top, expandable cards per package, and a single action menu per entry + * that handles install, update, version switch, uninstall, and editor- + * compatibility blocking — all in one button-driven menu. + * + * Each card runs a small state machine on top of two signals: + * - the version (if any) of that package already installed on disk, + * - the newest version whose `minEditorVersion` is satisfied by the running + * editor (`APP_VERSION`). + * + * The action button's label is derived from those — Install / Installed / + * Update / Editor outdated. The menu it opens carries the actual choices. + * + * The catalog data + install action both flow through `PackagePort`. Until + * the CDN backend ships, the editor adapter returns a hardcoded mock catalog + * and install resolves with a "backend not connected" error — the UI surface + * is final, only the adapter changes when the backend lands. + */ + +import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import { ArrowIcon } from '@root/frontend/assets/icons/interface/Arrow' +import { DownloadIcon } from '@root/frontend/assets/icons/interface/Download' +import { MagnifierIcon } from '@root/frontend/assets/icons/interface/Magnifier' +import { RefreshIcon } from '@root/frontend/assets/icons/interface/Refresh' +import { TrashCanIcon } from '@root/frontend/assets/icons/interface/TrashCan' +import { useOpenPLCStore } from '@root/frontend/store' +import { compareSemver, isCompatibleEditorVersion } from '@root/frontend/utils/semver' +import type { RemoteCatalogEntry, RemoteVersionEntry } from '@root/middleware/shared/ports/types' +import { usePackages } from '@root/middleware/shared/providers/platform-context' +import { useCallback, useEffect, useMemo, useState } from 'react' + +interface CatalogBrowserProps { + installedVersions: Map + /** + * Fired whenever the on-disk inventory may have changed — covers both + * install and uninstall outcomes. Name kept stable so the parent doesn't + * need cascading edits; treat as "onInventoryChanged". + */ + onInstalled: () => void +} + +const CatalogBrowser = ({ installedVersions, onInstalled }: CatalogBrowserProps) => { + const packages = usePackages() + const openModal = useOpenPLCStore((s) => s.modalActions.openModal) + + const [entries, setEntries] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [searchTerm, setSearchTerm] = useState('') + const [expandedId, setExpandedId] = useState(null) + // Single busy flag for both install and uninstall — they're mutually + // exclusive per card and both warrant disabling the action menu. + const [busyId, setBusyId] = useState(null) + const [fetchedAt, setFetchedAt] = useState(null) + + const fetchCatalog = useCallback(async () => { + if (!packages) return + setIsLoading(true) + setError(null) + try { + const result = await packages.listRemoteCatalog() + setEntries(result.entries) + setFetchedAt(result.fetchedAt) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to fetch catalog') + } finally { + setIsLoading(false) + } + }, [packages]) + + useEffect(() => { + void fetchCatalog() + }, [fetchCatalog]) + + const filteredEntries = useMemo(() => { + const term = searchTerm.trim().toLowerCase() + if (!term) return entries + return entries.filter((entry) => { + const haystack = [entry.name, entry.packageId, entry.vendor.name, entry.description, ...(entry.tags ?? [])] + .join(' ') + .toLowerCase() + return haystack.includes(term) + }) + }, [entries, searchTerm]) + + const handleInstall = useCallback( + async (packageId: string, version: string) => { + if (!packages) return + setBusyId(packageId) + try { + const result = await packages.installFromRemote(packageId, version) + if (result.success) { + onInstalled() + } else { + openModal('debugger-message', { + type: 'info', + title: 'Backend not connected', + message: + result.error ?? 'Remote install is not yet available. Use "Add from file..." with a downloaded .vpp.', + buttons: ['OK'], + onResponse: () => {}, + }) + } + } finally { + setBusyId(null) + } + }, + [packages, openModal, onInstalled], + ) + + const handleUninstall = useCallback( + async (packageId: string) => { + if (!packages) return + setBusyId(packageId) + try { + const result = await packages.uninstall(packageId) + if (result.success) { + onInstalled() + } else { + openModal('debugger-message', { + type: 'error', + title: 'Uninstall failed', + message: result.error ?? 'Could not uninstall the package.', + buttons: ['OK'], + onResponse: () => {}, + }) + } + } finally { + setBusyId(null) + } + }, + [packages, openModal, onInstalled], + ) + + if (!packages) { + return ( +
+ Package management is not available on this platform. +
+ ) + } + + return ( +
+ {/* Search + refresh row */} +
+
+ + setSearchTerm(e.target.value)} + placeholder='Search packages by name, vendor, or tag...' + className='font-caption text-cp-sm w-full rounded-md border border-neutral-200 bg-white py-2 pl-10 pr-3 text-neutral-950 placeholder:text-neutral-400 focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand dark:border-neutral-700 dark:bg-neutral-950 dark:text-white dark:placeholder:text-neutral-500' + aria-label='Search remote catalog' + /> +
+ +
+ + {/* Catalog list */} +
+ {isLoading ? ( +
+ Loading catalog... +
+ ) : error ? ( +
+ {error} + +
+ ) : filteredEntries.length === 0 ? ( +
+ {searchTerm ? `No packages matching "${searchTerm}"` : 'Catalog is empty.'} +
+ ) : ( +
    + {filteredEntries.map((entry) => ( + setExpandedId((cur) => (cur === entry.packageId ? null : entry.packageId))} + onInstall={(version) => void handleInstall(entry.packageId, version)} + onUninstall={() => void handleUninstall(entry.packageId)} + /> + ))} +
+ )} +
+ + {/* Footer: catalog freshness hint */} + {fetchedAt && !isLoading && !error && ( +
+ {filteredEntries.length} of {entries.length} package{entries.length === 1 ? '' : 's'} · catalog fetched{' '} + {new Date(fetchedAt).toLocaleString()} +
+ )} +
+ ) +} + +interface CatalogCardProps { + entry: RemoteCatalogEntry + isExpanded: boolean + installedVersion: string | null + isBusy: boolean + onToggleExpand: () => void + onInstall: (version: string) => void + onUninstall: () => void +} + +type TriggerKind = 'install' | 'installed' | 'update' | 'incompatible' + +interface TriggerState { + kind: TriggerKind + label: string + required?: string +} + +function computeTriggerState( + installedVersion: string | null, + latestCompatible: RemoteVersionEntry | null, +): TriggerState { + if (!latestCompatible) { + return { kind: 'incompatible', label: 'Editor outdated' } + } + if (!installedVersion) { + return { kind: 'install', label: 'Install' } + } + const cmp = compareSemver(latestCompatible.version, installedVersion) + if (cmp > 0) return { kind: 'update', label: 'Update' } + return { kind: 'installed', label: 'Installed' } +} + +const CatalogCard = ({ + entry, + isExpanded, + installedVersion, + isBusy, + onToggleExpand, + onInstall, + onUninstall, +}: CatalogCardProps) => { + const latestVersion = entry.versions[0] + const latestCompatible = entry.versions.find((v) => isCompatibleEditorVersion(v.minEditorVersion, APP_VERSION)) ?? null + + // Reference version surfaced in the static pill next to the name — what + // the user "has" right now (or would get by default if they hit Install). + const referenceVersion = installedVersion ?? latestCompatible?.version ?? latestVersion.version + + const triggerState = computeTriggerState(installedVersion, latestCompatible) + + const handleRowClick = (e: React.MouseEvent) => { + if (e.defaultPrevented) return + onToggleExpand() + } + + return ( +
  • +
    { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggleExpand() + } + }} + className='flex w-full items-start gap-3 p-3 text-left' + > +
    +
    + + {entry.name} + + + v{referenceVersion} + +
    + + + by {entry.vendor.name} · {(latestCompatible ?? latestVersion).deviceCount} device + {(latestCompatible ?? latestVersion).deviceCount === 1 ? '' : 's'} + + +

    + {entry.description} +

    +
    + + +
    + + {isExpanded && ( +
    +
    + + + + + {entry.vendor.url && } + {installedVersion && } +
    +
    + )} +
  • + ) +} + +interface ActionMenuButtonProps { + entry: RemoteCatalogEntry + installedVersion: string | null + triggerState: TriggerState + isBusy: boolean + onInstall: (version: string) => void + onUninstall: () => void +} + +const ActionMenuButton = ({ + entry, + installedVersion, + triggerState, + isBusy, + onInstall, + onUninstall, +}: ActionMenuButtonProps) => { + const triggerStyleByKind: Record = { + install: 'border border-brand bg-brand text-white hover:bg-brand-medium', + update: 'border border-brand bg-brand text-white hover:bg-brand-medium', + installed: 'border border-emerald-500/30 bg-transparent text-emerald-700 dark:text-emerald-400', + incompatible: + 'border border-neutral-300 bg-transparent text-neutral-500 dark:border-neutral-700 dark:text-neutral-500', + } + + return ( +
    e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}> + + + + + + + + Available versions + + {entry.versions.map((v) => { + const compatible = isCompatibleEditorVersion(v.minEditorVersion, APP_VERSION) + const isInstalledHere = installedVersion === v.version + const itemLabel = labelForVersionItem(v.version, installedVersion, isInstalledHere) + return ( + { + if (!compatible || isInstalledHere) return + onInstall(v.version) + }} + className='flex cursor-pointer flex-col items-start gap-0.5 rounded-md px-2 py-1.5 outline-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[highlighted]:bg-neutral-100 dark:data-[highlighted]:bg-neutral-850' + > + + {isInstalledHere && '✓ '} + {itemLabel} + + {!compatible && v.minEditorVersion && ( + + requires editor {v.minEditorVersion}+ + + )} + + ) + })} + {installedVersion !== null && ( + <> + + onUninstall()} + className='flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 font-caption text-[11px] font-medium text-red-600 outline-none data-[highlighted]:bg-red-50 dark:text-red-400 dark:data-[highlighted]:bg-red-950/30' + > + + Uninstall + + + )} + + + +
    + ) +} + +function labelForVersionItem( + versionInThisRow: string, + installedVersion: string | null, + isInstalledHere: boolean, +): string { + if (isInstalledHere) return `v${versionInThisRow} (installed)` + if (!installedVersion) return `Install v${versionInThisRow}` + const cmp = compareSemver(versionInThisRow, installedVersion) + if (cmp > 0) return `Update to v${versionInThisRow}` + return `Switch to v${versionInThisRow}` +} + +const DetailRow = ({ label, value }: { label: string; value: string }) => { + return ( +
    + + {label} + + {value} +
    + ) +} + +export { CatalogBrowser } diff --git a/src/frontend/components/_features/[workspace]/editor/package-manager/index.tsx b/src/frontend/components/_features/[workspace]/editor/package-manager/index.tsx index 0d968dd27..ae368246d 100644 --- a/src/frontend/components/_features/[workspace]/editor/package-manager/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/package-manager/index.tsx @@ -1,18 +1,32 @@ +/** + * Package Manager — VPP (Vendor Plugin Package) lifecycle UI. + * + * Two view modes share the tab strip at the top: + * - "Installed" — two-pane layout (list + manifest details) for packages + * already on disk, with import-from-file and uninstall affordances. + * - "Browse Catalog" — single-pane scrollable list of remote packages + * fetched from the OpenPLC CDN (currently mocked until the backend + * ships). Modelled after the Arduino IDE Board Manager. + */ + import * as Popover from '@radix-ui/react-popover' import { MinusIcon } from '@root/frontend/assets/icons/interface/Minus' import { PlusIcon } from '@root/frontend/assets/icons/interface/Plus' -import { useOpenPLCStore } from '@root/frontend/store' import type { InstalledPackage, PackageManifest } from '@root/middleware/shared/ports/types' import { usePackages } from '@root/middleware/shared/providers/platform-context' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { CatalogBrowser } from './catalog-browser' + +type ViewMode = 'installed' | 'catalog' const PackageManagerEditor = () => { const packages = usePackages() + const [viewMode, setViewMode] = useState('installed') const [installedPackages, setInstalledPackages] = useState([]) const [selectedPackageId, setSelectedPackageId] = useState(null) const [selectedManifest, setSelectedManifest] = useState(null) const [isPopoverOpen, setIsPopoverOpen] = useState(false) - const openModal = useOpenPLCStore((state) => state.modalActions.openModal) const refreshPackages = useCallback(async () => { if (!packages) return @@ -36,6 +50,11 @@ const PackageManagerEditor = () => { void fetchManifest() }, [selectedPackageId, packages]) + const installedVersions = useMemo( + () => new Map(installedPackages.map((p) => [p.packageId, p.version])), + [installedPackages], + ) + const handleImportFromFile = async () => { if (!packages) return setIsPopoverOpen(false) @@ -48,15 +67,9 @@ const PackageManagerEditor = () => { } } - const handleImportFromInternet = () => { + const handleSwitchToCatalog = () => { setIsPopoverOpen(false) - openModal('debugger-message', { - type: 'info', - title: 'Coming Soon', - message: 'Online package browsing will be available in a future update.', - buttons: ['OK'], - onResponse: () => {}, - }) + setViewMode('catalog') } const handleUninstall = async () => { @@ -77,141 +90,170 @@ const PackageManagerEditor = () => { } return ( -
    - {/* Left panel: Installed packages list */} -
    -
    -

    Installed Packages

    -
    - - +
    + {/* Top tab strip — Installed | Browse Catalog */} +
    + setViewMode('installed')} /> + setViewMode('catalog')} /> +
    + + {viewMode === 'installed' ? ( +
    + {/* Left panel: Installed packages list */} +
    +
    +

    Installed Packages

    +
    + + + + + + + + + + + + - - - - +
    +
    + +
    + {installedPackages.length === 0 ? ( +
    + No packages installed. Click + to add a package. +
    + ) : ( + installedPackages.map((pkg) => ( - - - - - + )) + )} +
    -
    -
    - {installedPackages.length === 0 ? ( -
    - No packages installed. Click + to add a package. -
    - ) : ( - installedPackages.map((pkg) => ( - - )) - )} -
    -
    +
    -
    - - {/* Right panel: Package details */} -
    - {selectedManifest ? ( - <> -

    - {selectedManifest.package.name} -

    -
    - - - {selectedManifest.package.vendor.url && ( - - )} - - {selectedManifest.package.license && ( - - )} - -
    - Devices -
    - {selectedManifest.devices.map((device) => ( -
    - - {device.name} - - {device.category && ( - - ({device.category}) - - )} + {/* Right panel: Package details */} +
    + {selectedManifest ? ( + <> +

    + {selectedManifest.package.name} +

    +
    + + + {selectedManifest.package.vendor.url && ( + + )} + + {selectedManifest.package.license && ( + + )} + +
    + Devices +
    + {selectedManifest.devices.map((device) => ( +
    + + {device.name} + + {device.category && ( + + ({device.category}) + + )} +
    + ))}
    - ))} +
    + + ) : ( +
    + Select a package to view details
    -
    - - ) : ( -
    - Select a package to view details + )}
    - )} -
    +
    + ) : ( + void refreshPackages()} /> + )}
    ) } +function ViewTab({ label, isActive, onClick }: { label: string; isActive: boolean; onClick: () => void }) { + return ( + + ) +} + function DetailRow({ label, value }: { label: string; value: string }) { return (
    diff --git a/src/frontend/utils/__tests__/semver.test.ts b/src/frontend/utils/__tests__/semver.test.ts new file mode 100644 index 000000000..97ec6d22f --- /dev/null +++ b/src/frontend/utils/__tests__/semver.test.ts @@ -0,0 +1,62 @@ +import { compareSemver, isCompatibleEditorVersion } from '../semver' + +describe('compareSemver', () => { + it('returns 0 when versions are identical', () => { + expect(compareSemver('4.1.1', '4.1.1')).toBe(0) + expect(compareSemver('0.0.0', '0.0.0')).toBe(0) + }) + + it('returns 1 when the first version is greater (major bump)', () => { + expect(compareSemver('5.0.0', '4.1.1')).toBe(1) + }) + + it('returns -1 when the first version is smaller (major bump)', () => { + expect(compareSemver('4.1.1', '5.0.0')).toBe(-1) + }) + + it('compares minor versions when majors match', () => { + expect(compareSemver('4.2.0', '4.1.99')).toBe(1) + expect(compareSemver('4.1.0', '4.2.0')).toBe(-1) + }) + + it('compares patch versions when major+minor match', () => { + expect(compareSemver('4.1.2', '4.1.1')).toBe(1) + expect(compareSemver('4.1.0', '4.1.1')).toBe(-1) + }) + + it('strips pre-release suffix before comparing', () => { + // The function intentionally ignores pre-release ordering; both compare + // as the same `4.1.1` triple. If we ever ship pre-releases for real this + // contract needs revisiting, but ignoring is the safer default today. + expect(compareSemver('4.1.1-rc.1', '4.1.1')).toBe(0) + expect(compareSemver('4.1.1+build.5', '4.1.1-rc.1')).toBe(0) + }) + + it('treats malformed inputs as 0.0.0 (defensive against corrupt manifests)', () => { + expect(compareSemver('not-a-version', '0.0.0')).toBe(0) + expect(compareSemver('', '0.0.0')).toBe(0) + expect(compareSemver('1.2', '1.2.0')).toBe(0) // missing patch defaults to 0 + expect(compareSemver('abc.def.ghi', '0.0.1')).toBe(-1) // bogus < 0.0.1 + }) +}) + +describe('isCompatibleEditorVersion', () => { + it('returns true when no minimum is required', () => { + expect(isCompatibleEditorVersion(undefined, '4.1.1')).toBe(true) + expect(isCompatibleEditorVersion('', '4.1.1')).toBe(true) + }) + + it('returns true when the editor is at exactly the required version', () => { + expect(isCompatibleEditorVersion('4.1.1', '4.1.1')).toBe(true) + }) + + it('returns true when the editor is newer than required', () => { + expect(isCompatibleEditorVersion('4.0.0', '4.1.1')).toBe(true) + expect(isCompatibleEditorVersion('3.5.0', '4.1.1')).toBe(true) + }) + + it('returns false when the editor is older than required', () => { + expect(isCompatibleEditorVersion('5.0.0', '4.1.1')).toBe(false) + expect(isCompatibleEditorVersion('4.2.0', '4.1.1')).toBe(false) + }) +}) diff --git a/src/frontend/utils/semver.ts b/src/frontend/utils/semver.ts new file mode 100644 index 000000000..673370824 --- /dev/null +++ b/src/frontend/utils/semver.ts @@ -0,0 +1,45 @@ +/** + * Tiny semver helpers used by the VPP catalog browser to compare a package + * version's `minEditorVersion` against the running editor's `APP_VERSION`. + * + * Intentionally local — adding the full `semver` npm dependency for two + * comparisons would inflate the renderer bundle for no real gain. Pre-release + * suffixes (`-rc.1`, `+build.5`) are stripped before parsing; this matches + * what arduino-cli does when matching boards.txt menu constraints, and we + * don't currently publish pre-release VPPs. + * + * Malformed strings degrade to `0.0.0` so a corrupt manifest in the wild + * doesn't crash the UI — it just compares as the lowest possible version. + */ + +type Triple = readonly [number, number, number] + +function parseSemver(input: string): Triple { + const stripped = input.split(/[-+]/)[0] + const parts = stripped.split('.') + const major = Number.parseInt(parts[0] ?? '', 10) + const minor = Number.parseInt(parts[1] ?? '', 10) + const patch = Number.parseInt(parts[2] ?? '', 10) + return [ + Number.isFinite(major) ? major : 0, + Number.isFinite(minor) ? minor : 0, + Number.isFinite(patch) ? patch : 0, + ] +} + +export function compareSemver(a: string, b: string): -1 | 0 | 1 { + const [aMajor, aMinor, aPatch] = parseSemver(a) + const [bMajor, bMinor, bPatch] = parseSemver(b) + if (aMajor !== bMajor) return aMajor > bMajor ? 1 : -1 + if (aMinor !== bMinor) return aMinor > bMinor ? 1 : -1 + if (aPatch !== bPatch) return aPatch > bPatch ? 1 : -1 + return 0 +} + +export function isCompatibleEditorVersion( + minRequired: string | undefined, + current: string, +): boolean { + if (!minRequired) return true + return compareSemver(current, minRequired) >= 0 +} diff --git a/src/middleware/adapters/editor/__tests__/package-adapter.test.ts b/src/middleware/adapters/editor/__tests__/package-adapter.test.ts index ab24e8138..8ae820d23 100644 --- a/src/middleware/adapters/editor/__tests__/package-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/package-adapter.test.ts @@ -130,6 +130,30 @@ describe('createEditorPackageAdapter', () => { }) }) + describe('listRemoteCatalog', () => { + // Until the CDN backend (EDGE-482) lands, the catalog method rejects. + // The CatalogBrowser surfaces this through its error banner with a Try + // Again button — no UI crash, no empty-catalog confusion. + it('rejects with a "backend not yet available" error so the UI surfaces its error state', async () => { + await expect(adapter.listRemoteCatalog()).rejects.toThrow(/not yet available/i) + }) + }) + + describe('installFromRemote', () => { + it('resolves with success:false and an error that names the requested package + version', async () => { + const result = await adapter.installFromRemote('com.openplc.arduino', '0.2.0') + expect(result.success).toBe(false) + expect(result.error).toContain('com.openplc.arduino') + expect(result.error).toContain('0.2.0') + }) + + it('still surfaces the packageId when version is omitted', async () => { + const result = await adapter.installFromRemote('com.openplc.espressif') + expect(result.success).toBe(false) + expect(result.error).toContain('com.openplc.espressif') + }) + }) + describe('event subscriptions', () => { it('forwards onOpenManager registration to the bridge and returns its unsubscribe', () => { const unsubscribe = jest.fn() diff --git a/src/middleware/adapters/editor/package-adapter.ts b/src/middleware/adapters/editor/package-adapter.ts index 008c4341e..569509df9 100644 --- a/src/middleware/adapters/editor/package-adapter.ts +++ b/src/middleware/adapters/editor/package-adapter.ts @@ -11,11 +11,27 @@ * packages:get-manifest (invoke) * packages:open-manager (on) * packages:boards-updated (on) + * + * `listRemoteCatalog` and `installFromRemote` are still stubs until the + * CDN backend ships (see EDGE-482 + subtasks for the wire contract). The + * UI surface exists and consumes these methods through `PackagePort`; both + * surface a clean "backend not connected" error today so the Browse Catalog + * tab degrades gracefully instead of crashing. */ import { parsePackageManifest } from '../../shared/ports/package-manifest-schema' import type { PackagePort } from '../../shared/ports/package-port' -import type { ImportResult, InstalledPackage, PackageManifest, Result, Unsubscribe } from '../../shared/ports/types' +import type { + ImportResult, + InstalledPackage, + PackageManifest, + RemoteCatalog, + Result, + Unsubscribe, +} from '../../shared/ports/types' + +const REMOTE_BACKEND_NOT_WIRED = + 'OpenPLC CDN catalog backend is not yet available. Use "Add from file..." with a downloaded .vpp for now.' export function createEditorPackageAdapter(): PackagePort { return { @@ -44,6 +60,25 @@ export function createEditorPackageAdapter(): PackagePort { return parsePackageManifest(raw) }, + listRemoteCatalog(): Promise { + // TODO(EDGE-482): replace with HTTP fetch against the OpenPLC CDN + // catalog URL once the backend lands. Until then we throw so the + // CatalogBrowser surfaces its error state with a Try Again button + // instead of rendering an empty catalog. + return Promise.reject(new Error(REMOTE_BACKEND_NOT_WIRED)) + }, + + installFromRemote(packageId: string, version?: string): Promise { + // TODO(EDGE-482): replace with `fetch(downloadUrl) -> tmp file -> + // local install pipeline` once the CDN backend lands. The shape of + // the error keeps the UI's "Backend not connected" modal honest. + const versionSuffix = version ? `@${version}` : '' + return Promise.resolve({ + success: false, + error: `Remote install for "${packageId}${versionSuffix}" is not available — ${REMOTE_BACKEND_NOT_WIRED}`, + }) + }, + onOpenManager(callback: () => void): Unsubscribe { return window.bridge.onOpenPackageManager(callback) }, diff --git a/src/middleware/shared/ports/package-port.ts b/src/middleware/shared/ports/package-port.ts index f147ee42b..dfbbc0e09 100644 --- a/src/middleware/shared/ports/package-port.ts +++ b/src/middleware/shared/ports/package-port.ts @@ -12,7 +12,14 @@ * - window.bridge.getPackageManifest() */ -import type { ImportResult, InstalledPackage, PackageManifest, Result, Unsubscribe } from './types' +import type { + ImportResult, + InstalledPackage, + PackageManifest, + RemoteCatalog, + Result, + Unsubscribe, +} from './types' export interface PackagePort { /** @@ -37,6 +44,19 @@ export interface PackagePort { */ getManifest(packageId: string): Promise + /** + * Fetch the remote VPP catalog from the OpenPLC CDN. + * Currently mocked in both adapters until the backend ships. + */ + listRemoteCatalog(): Promise + + /** + * Download and install a VPP from the remote catalog by package id (and + * optional explicit version — defaults to the catalog's advertised one). + * Currently a no-op stub in both adapters until the backend ships. + */ + installFromRemote(packageId: string, version?: string): Promise + /** * Subscribe to the "open package manager" event (triggered from menu). */ diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index 23a428b33..676a9b3c4 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -777,6 +777,44 @@ export interface ImportResult { error?: string } +export interface RemoteVersionEntry { + version: string + downloadUrl: string + publishedAt?: string + /** + * Minimum editor semver required to run this package version. The UI + * compares this against `APP_VERSION` and flags incompatible entries in + * the dropdown so users don't try to install something the editor can't + * load. + */ + minEditorVersion?: string + deviceCount: number + releaseNotes?: string +} + +export interface RemoteCatalogEntry { + packageId: string + name: string + vendor: { + name: string + url?: string + logoUrl?: string + } + description: string + license?: string + tags?: string[] + /** + * Available versions, ordered newest-first. The adapter/CDN owns the + * ordering contract — UI code treats `versions[0]` as the latest. + */ + versions: RemoteVersionEntry[] +} + +export interface RemoteCatalog { + entries: RemoteCatalogEntry[] + fetchedAt: string +} + export interface IoMappingEntry { slot: number moduleId: string From 64863b5282cc4a1ef34a783edcc4b445ad3243e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 28 May 2026 23:05:18 +0200 Subject: [PATCH 16/61] feat(package-manager): isolate VPP catalog mock behind USE_LOCAL_MOCK flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the canned catalog + the two port-shaped mock functions (mockListRemoteCatalog, mockInstallFromRemote) into a dedicated remote-catalog-mock.ts so the adapter no longer carries ~200 lines of fixture data. The adapter gains a USE_LOCAL_MOCK constant (committed value MUST stay false) that delegates to the mock when flipped — a working-tree-only edit that lets devs exercise the Browse Catalog UI end-to-end against the fixture while EDGE-482 (real CDN) is still pending. Adds full coverage for the mock module to keep the 100% threshold on src/middleware/adapters/editor/ intact. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/remote-catalog-mock.test.ts | 69 +++++ .../adapters/editor/package-adapter.ts | 14 +- .../adapters/editor/remote-catalog-mock.ts | 265 ++++++++++++++++++ 3 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 src/middleware/adapters/editor/__tests__/remote-catalog-mock.test.ts create mode 100644 src/middleware/adapters/editor/remote-catalog-mock.ts diff --git a/src/middleware/adapters/editor/__tests__/remote-catalog-mock.test.ts b/src/middleware/adapters/editor/__tests__/remote-catalog-mock.test.ts new file mode 100644 index 000000000..13d4318fc --- /dev/null +++ b/src/middleware/adapters/editor/__tests__/remote-catalog-mock.test.ts @@ -0,0 +1,69 @@ +import { mockInstallFromRemote, mockListRemoteCatalog } from '../remote-catalog-mock' + +describe('mockListRemoteCatalog', () => { + it('returns a valid RemoteCatalog with at least one entry and a fresh ISO timestamp', async () => { + const result = await mockListRemoteCatalog() + expect(result.entries.length).toBeGreaterThan(0) + expect(Number.isNaN(Date.parse(result.fetchedAt))).toBe(false) + }) + + it('honours the RemoteCatalogEntry shape: every entry has versions[] (≥1) and required per-version fields', async () => { + const result = await mockListRemoteCatalog() + for (const entry of result.entries) { + expect(entry.packageId).toMatch(/^[a-z][a-z0-9.-]+$/) + expect(entry.vendor.name.length).toBeGreaterThan(0) + expect(entry.description.length).toBeGreaterThan(0) + expect(entry.versions.length).toBeGreaterThan(0) + for (const v of entry.versions) { + expect(v.version).toMatch(/^\d+\.\d+\.\d+/) + expect(v.downloadUrl).toMatch(/^https?:\/\//) + expect(v.deviceCount).toBeGreaterThanOrEqual(0) + } + } + }) + + it('orders each entry versions newest-first (catalog contract: versions[0] === latest)', async () => { + const result = await mockListRemoteCatalog() + const parse = (v: string): [number, number, number] => { + const [a, b, c] = v.split('.').map((n) => parseInt(n, 10)) + return [a || 0, b || 0, c || 0] + } + const cmp = (a: [number, number, number], b: [number, number, number]) => { + if (a[0] !== b[0]) return a[0] - b[0] + if (a[1] !== b[1]) return a[1] - b[1] + return a[2] - b[2] + } + for (const entry of result.entries) { + for (let i = 0; i < entry.versions.length - 1; i += 1) { + expect(cmp(parse(entry.versions[i].version), parse(entry.versions[i + 1].version))).toBeGreaterThan(0) + } + } + }) + + it('includes at least one entry whose latest requires a newer editor — exercises the incompatibility UI path', async () => { + // The mock deliberately ships com.openplc.stm32-community 0.3.0 with + // minEditorVersion 5.0.0 so the CatalogBrowser's "Editor outdated" + // disabled state is visible without faking APP_VERSION at runtime. + const result = await mockListRemoteCatalog() + const hasIncompatibleLatest = result.entries.some((e) => { + const min = e.versions[0]?.minEditorVersion + return min !== undefined && min.startsWith('5.') + }) + expect(hasIncompatibleLatest).toBe(true) + }) +}) + +describe('mockInstallFromRemote', () => { + it('resolves with success:false and an error that names the requested package + version', async () => { + const result = await mockInstallFromRemote('com.openplc.arduino', '0.2.0') + expect(result.success).toBe(false) + expect(result.error).toContain('com.openplc.arduino') + expect(result.error).toContain('0.2.0') + }) + + it('still surfaces the packageId when version is omitted', async () => { + const result = await mockInstallFromRemote('com.openplc.espressif') + expect(result.success).toBe(false) + expect(result.error).toContain('com.openplc.espressif') + }) +}) diff --git a/src/middleware/adapters/editor/package-adapter.ts b/src/middleware/adapters/editor/package-adapter.ts index 569509df9..db21111aa 100644 --- a/src/middleware/adapters/editor/package-adapter.ts +++ b/src/middleware/adapters/editor/package-adapter.ts @@ -29,10 +29,20 @@ import type { Result, Unsubscribe, } from '../../shared/ports/types' +import { mockInstallFromRemote, mockListRemoteCatalog } from './remote-catalog-mock' const REMOTE_BACKEND_NOT_WIRED = 'OpenPLC CDN catalog backend is not yet available. Use "Add from file..." with a downloaded .vpp for now.' +/** + * Local-dev toggle: when `true`, the catalog port methods are served from + * the static fixture in `remote-catalog-mock.ts` so the Browse Catalog UI + * can be exercised end-to-end without a real CDN. Committed value MUST stay + * `false` — flipping is a working-tree-only edit while EDGE-482 (real CDN) + * is still pending. + */ +const USE_LOCAL_MOCK = false + export function createEditorPackageAdapter(): PackagePort { return { importFromFile(): Promise { @@ -62,9 +72,10 @@ export function createEditorPackageAdapter(): PackagePort { listRemoteCatalog(): Promise { // TODO(EDGE-482): replace with HTTP fetch against the OpenPLC CDN - // catalog URL once the backend lands. Until then we throw so the + // catalog URL once the backend lands. Until then we reject so the // CatalogBrowser surfaces its error state with a Try Again button // instead of rendering an empty catalog. + if (USE_LOCAL_MOCK) return mockListRemoteCatalog() return Promise.reject(new Error(REMOTE_BACKEND_NOT_WIRED)) }, @@ -72,6 +83,7 @@ export function createEditorPackageAdapter(): PackagePort { // TODO(EDGE-482): replace with `fetch(downloadUrl) -> tmp file -> // local install pipeline` once the CDN backend lands. The shape of // the error keeps the UI's "Backend not connected" modal honest. + if (USE_LOCAL_MOCK) return mockInstallFromRemote(packageId, version) const versionSuffix = version ? `@${version}` : '' return Promise.resolve({ success: false, diff --git a/src/middleware/adapters/editor/remote-catalog-mock.ts b/src/middleware/adapters/editor/remote-catalog-mock.ts new file mode 100644 index 000000000..6246fb84d --- /dev/null +++ b/src/middleware/adapters/editor/remote-catalog-mock.ts @@ -0,0 +1,265 @@ +/** + * Local development mock for the VPP catalog port methods. + * + * Mirrors the real packages currently shipped in github.com/Autonomy-Logic/ + * openplc-packages so the Browse Catalog UI can be exercised against a + * plausible inventory while the OpenPLC CDN backend (EDGE-482) is still + * pending. Each entry exposes 1–3 versions (newest-first); one — the STM32 + * community package — deliberately ships a future 0.3.0 with + * `minEditorVersion: '5.0.0'` so the UI's "Editor outdated" path can be + * exercised without faking APP_VERSION at runtime. + * + * To activate the mock during dev, flip `USE_LOCAL_MOCK` in + * `package-adapter.ts` to `true`. Do NOT commit that flip — the committed + * baseline is the stub adapter that returns a "backend not yet available" + * error so the Browse Catalog tab degrades gracefully. + * + * When the CDN backend lands, this file can be deleted (or repurposed as a + * test fixture) and the adapter wired to real `fetch()` calls. Wire shape + * is documented in EDGE-483 / EDGE-484. + */ + +import type { ImportResult, RemoteCatalog, RemoteCatalogEntry } from '../../shared/ports/types' + +const MOCK_REMOTE_CATALOG: RemoteCatalogEntry[] = [ + { + packageId: 'com.openplc.arduino', + name: 'OpenPLC — Arduino boards', + vendor: { name: 'Arduino', url: 'https://www.arduino.cc' }, + description: + 'OpenPLC HAL support for Arduino-family (maker line) boards: Uno, Mega, Nano, Leonardo, Due, Giga, Micro, Nano ESP32, Uno R4 / R4 WiFi / Q, Zero, Mkr WiFi / Zero, Nano 33 BLE / IoT / Every / RP2040 Connect.', + license: 'GPL-3.0', + tags: ['arduino', 'avr', 'samd', 'esp32', 'mbed', 'official'], + versions: [ + { + version: '0.2.0', + downloadUrl: 'https://cdn.openplcproject.com/packages/com.openplc.arduino-0.2.0.vpp', + publishedAt: '2026-05-20T00:00:00Z', + minEditorVersion: '4.0.0', + deviceCount: 19, + }, + { + version: '0.1.5', + downloadUrl: 'https://cdn.openplcproject.com/packages/com.openplc.arduino-0.1.5.vpp', + publishedAt: '2026-03-10T00:00:00Z', + minEditorVersion: '4.0.0', + deviceCount: 17, + }, + { + version: '0.1.0', + downloadUrl: 'https://cdn.openplcproject.com/packages/com.openplc.arduino-0.1.0.vpp', + publishedAt: '2026-01-22T00:00:00Z', + minEditorVersion: '3.5.0', + deviceCount: 15, + }, + ], + }, + { + packageId: 'com.openplc.arduino-industrial', + name: 'OpenPLC — Arduino industrial boards', + vendor: { name: 'Arduino', url: 'https://www.arduino.cc/pro' }, + description: + 'OpenPLC HAL support for the Arduino Pro industrial line: Edge Control, Opta, and the Portenta H7 family.', + license: 'GPL-3.0', + tags: ['arduino', 'industrial', 'mbed', 'official'], + versions: [ + { + version: '0.1.0', + downloadUrl: 'https://cdn.openplcproject.com/packages/com.openplc.arduino-industrial-0.1.0.vpp', + publishedAt: '2026-04-12T00:00:00Z', + minEditorVersion: '4.0.0', + deviceCount: 3, + }, + { + version: '0.0.9', + downloadUrl: 'https://cdn.openplcproject.com/packages/com.openplc.arduino-industrial-0.0.9.vpp', + publishedAt: '2026-02-28T00:00:00Z', + minEditorVersion: '3.8.0', + deviceCount: 2, + }, + ], + }, + { + packageId: 'com.openplc.espressif', + name: 'OpenPLC — Espressif boards', + vendor: { name: 'Espressif', url: 'https://www.espressif.com' }, + description: + 'OpenPLC HAL support for the ESP32 family (Generic, WROOM, WROVER, C3, C6, S2, S3) and the ESP8266 (D1 mini, NodeMCU).', + license: 'GPL-3.0', + tags: ['espressif', 'esp32', 'esp8266', 'wifi', 'official'], + versions: [ + { + version: '0.1.0', + downloadUrl: 'https://cdn.openplcproject.com/packages/com.openplc.espressif-0.1.0.vpp', + publishedAt: '2026-04-08T00:00:00Z', + minEditorVersion: '4.0.0', + deviceCount: 9, + }, + { + version: '0.0.8', + downloadUrl: 'https://cdn.openplcproject.com/packages/com.openplc.espressif-0.0.8.vpp', + publishedAt: '2026-02-15T00:00:00Z', + minEditorVersion: '3.8.0', + deviceCount: 7, + }, + ], + }, + { + packageId: 'com.openplc.stm32-community', + name: 'OpenPLC — STM32 community boards', + vendor: { name: 'STM32 community', url: 'https://github.com/stm32duino' }, + description: + 'OpenPLC HAL support for community STM32 boards: Blackpill F411CE and Bluepill F103CB (each with DFU, HID, SWD, and Serial upload variants), plus the NUCLEO F446ZET.', + license: 'GPL-3.0', + tags: ['stm32', 'arm', 'cortex-m', 'community'], + versions: [ + // Future 0.3.0 requires an editor we don't ship yet — exercises the + // "Editor outdated" disabled state without faking APP_VERSION at + // runtime. 0.2.0 stays compatible so the card's "latestCompatible" + // path can be observed too. + { + version: '0.3.0', + downloadUrl: 'https://cdn.openplcproject.com/packages/com.openplc.stm32-community-0.3.0.vpp', + publishedAt: '2026-06-15T00:00:00Z', + minEditorVersion: '5.0.0', + deviceCount: 12, + }, + { + version: '0.2.0', + downloadUrl: 'https://cdn.openplcproject.com/packages/com.openplc.stm32-community-0.2.0.vpp', + publishedAt: '2026-05-28T00:00:00Z', + minEditorVersion: '4.0.0', + deviceCount: 9, + }, + { + version: '0.1.0', + downloadUrl: 'https://cdn.openplcproject.com/packages/com.openplc.stm32-community-0.1.0.vpp', + publishedAt: '2026-01-30T00:00:00Z', + minEditorVersion: '3.5.0', + deviceCount: 3, + }, + ], + }, + { + packageId: 'com.openplc.raspberry-pi', + name: 'OpenPLC — Raspberry Pi', + vendor: { name: 'Raspberry Pi', url: 'https://www.raspberrypi.com' }, + description: + 'OpenPLC HAL support for Raspberry Pi hardware: the Raspberry Pi SBC running OpenPLC Runtime v4 (Linux), and the four Pico microcontroller variants (Pico, Pico 2, Pico 2 RISCV, Pico W) compiled via arduino-cli.', + license: 'GPL-3.0', + tags: ['raspberry-pi', 'rp2040', 'linux', 'sbc', 'official'], + versions: [ + { + version: '0.1.0', + downloadUrl: 'https://cdn.openplcproject.com/packages/com.openplc.raspberry-pi-0.1.0.vpp', + publishedAt: '2026-04-15T00:00:00Z', + minEditorVersion: '4.0.0', + deviceCount: 5, + }, + { + version: '0.0.7', + downloadUrl: 'https://cdn.openplcproject.com/packages/com.openplc.raspberry-pi-0.0.7.vpp', + publishedAt: '2026-02-04T00:00:00Z', + minEditorVersion: '3.5.0', + deviceCount: 4, + }, + ], + }, + { + packageId: 'com.openplc.fx3u-compatible', + name: 'OpenPLC — FX3U-compatible boards', + vendor: { name: 'OpenPLC' }, + description: + 'OpenPLC HAL support for FX3U-compatible STM32 F103 boards (generic clones using the serial bootloader programming flow).', + license: 'GPL-3.0', + tags: ['fx3u', 'stm32', 'mitsubishi-clone', 'community'], + // Single-version entry — exercises the dropdown's "1-item" rendering. + versions: [ + { + version: '0.1.0', + downloadUrl: 'https://cdn.openplcproject.com/packages/com.openplc.fx3u-compatible-0.1.0.vpp', + publishedAt: '2026-04-22T00:00:00Z', + minEditorVersion: '4.0.0', + deviceCount: 3, + }, + ], + }, + { + packageId: 'com.openplc.sequent-microsystems', + name: 'OpenPLC — Sequent Microsystems', + vendor: { name: 'Sequent Microsystems', url: 'https://sequentmicrosystems.com' }, + description: 'OpenPLC HAL support for Sequent Microsystems HATs and the RP1-based industrial controller.', + license: 'GPL-3.0', + tags: ['sequent-microsystems', 'rp1', 'industrial'], + versions: [ + { + version: '0.1.0', + downloadUrl: 'https://cdn.openplcproject.com/packages/com.openplc.sequent-microsystems-0.1.0.vpp', + publishedAt: '2026-05-02T00:00:00Z', + minEditorVersion: '4.0.0', + deviceCount: 1, + }, + ], + }, + { + packageId: 'com.facts-engineering.p1am', + name: 'OpenPLC — Facts Engineering P1AM', + vendor: { name: 'Facts Engineering', url: 'https://facts-engineering.com' }, + description: + 'OpenPLC HAL support for the Facts Engineering P1AM-200 industrial controller and its Productivity1000-series I/O modules.', + license: 'GPL-3.0', + tags: ['facts-engineering', 'p1am', 'samd', 'industrial', 'partner'], + versions: [ + { + version: '0.1.0', + downloadUrl: 'https://cdn.openplcproject.com/packages/com.facts-engineering.p1am-0.1.0.vpp', + publishedAt: '2026-05-10T00:00:00Z', + minEditorVersion: '4.0.0', + deviceCount: 1, + }, + ], + }, + { + packageId: 'com.synergy-logic.slm-rp4', + name: 'OpenPLC — Synergy Logic SLM-RP4', + vendor: { name: 'Synergy Logic', url: 'https://synergylogic.com' }, + description: 'OpenPLC HAL support for the Synergy Logic SLM-RP4 industrial controller (ARM64 Linux runtime).', + license: 'GPL-3.0', + tags: ['synergy-logic', 'arm64', 'linux', 'industrial', 'partner'], + versions: [ + { + version: '0.1.0', + downloadUrl: 'https://cdn.openplcproject.com/packages/com.synergy-logic.slm-rp4-0.1.0.vpp', + publishedAt: '2026-05-15T00:00:00Z', + minEditorVersion: '4.0.0', + deviceCount: 1, + }, + ], + }, +] + +/** + * Mock implementation of `PackagePort.listRemoteCatalog` — adds a 200ms + * delay so the CatalogBrowser's loading state is briefly visible during + * dev, then returns the canned catalog above. + */ +export async function mockListRemoteCatalog(): Promise { + await new Promise((resolve) => setTimeout(resolve, 200)) + return { entries: MOCK_REMOTE_CATALOG, fetchedAt: new Date().toISOString() } +} + +/** + * Mock implementation of `PackagePort.installFromRemote` — never actually + * installs anything (the local install pipeline is intentionally out of + * scope for the mock), but resolves with a "backend not wired" error that + * names the requested `packageId@version` so the UI's modal renders the + * full request the way it would in production. + */ +export async function mockInstallFromRemote(packageId: string, version?: string): Promise { + await new Promise((resolve) => setTimeout(resolve, 150)) + const versionSuffix = version ? `@${version}` : '' + return { + success: false, + error: `[mock] Remote install for "${packageId}${versionSuffix}" is not wired — flip USE_LOCAL_MOCK off and pull a real CDN once EDGE-482 ships.`, + } +} From 7e5d839ee39472fb3abca5075402e5a56141331f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Fri, 29 May 2026 12:20:37 +0200 Subject: [PATCH 17/61] feat(package-manager): wire VPP catalog to real CDN backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swaps the listRemoteCatalog / installFromRemote stubs in the editor adapter for real calls against the autonomy-edge VPP catalog API. CATALOG_BASE_URL defaults to the staging deployment (https://api-staging.autonomylogic.com); local backend devs flip to http://localhost:3333. The USE_LOCAL_MOCK working-tree flag is kept for offline dev against the canned fixture in remote-catalog-mock.ts. listRemoteCatalog now fetches GET /vpp-catalog/v1/catalog.json directly from the renderer — JSON shape is the contract documented in EDGE-482. installFromRemote defers to a new IPC channel packages:install-from-url: main downloads the .vpp binary from the catalog's downloadUrl, writes it to {tmp}/openplc-vpp-{id}-{ver}-{uuid}.vpp, hands the path to the existing PackageManagerModule.importFromFile pipeline (same code the "Add from file..." flow already exercises), cleans up the temp file in finally, and emits packages:boards-updated on success. The download must run in main because the catalog backend serves a private S3 bucket through its own API — the renderer is not authorized to talk to S3 directly and lacks ergonomic write access to {userData}/packages anyway. Port signature gains downloadUrl as a required argument so the editor never constructs URLs on its own; the catalog entry is the source of truth per the backend contract. CatalogBrowser propagates downloadUrl from the selected RemoteVersionEntry through the action menu. Tests cover the success path (fetch + JSON parse, IPC delegation with the full payload), the error paths (non-ok HTTP status, fetch rejection, bridge-side install failure), and keep the mock module at 100% coverage with the updated 3-arg signature. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../package-manager/catalog-browser.tsx | 18 +++--- src/main/modules/ipc/main.ts | 39 +++++++++++ src/main/modules/ipc/renderer.ts | 12 ++++ .../editor/__tests__/package-adapter.test.ts | 64 +++++++++++++++---- .../__tests__/remote-catalog-mock.test.ts | 14 ++-- .../adapters/editor/package-adapter.ts | 57 +++++++++-------- .../adapters/editor/remote-catalog-mock.ts | 13 ++-- src/middleware/shared/ports/package-port.ts | 10 +-- 8 files changed, 163 insertions(+), 64 deletions(-) diff --git a/src/frontend/components/_features/[workspace]/editor/package-manager/catalog-browser.tsx b/src/frontend/components/_features/[workspace]/editor/package-manager/catalog-browser.tsx index fd2d2c09f..6c716e716 100644 --- a/src/frontend/components/_features/[workspace]/editor/package-manager/catalog-browser.tsx +++ b/src/frontend/components/_features/[workspace]/editor/package-manager/catalog-browser.tsx @@ -88,19 +88,19 @@ const CatalogBrowser = ({ installedVersions, onInstalled }: CatalogBrowserProps) }, [entries, searchTerm]) const handleInstall = useCallback( - async (packageId: string, version: string) => { + async (packageId: string, version: string, downloadUrl: string) => { if (!packages) return setBusyId(packageId) try { - const result = await packages.installFromRemote(packageId, version) + const result = await packages.installFromRemote(packageId, version, downloadUrl) if (result.success) { onInstalled() } else { openModal('debugger-message', { - type: 'info', - title: 'Backend not connected', + type: 'error', + title: 'Install failed', message: - result.error ?? 'Remote install is not yet available. Use "Add from file..." with a downloaded .vpp.', + result.error ?? 'Remote install failed. Try again, or use "Add from file..." with a downloaded .vpp.', buttons: ['OK'], onResponse: () => {}, }) @@ -205,7 +205,7 @@ const CatalogBrowser = ({ installedVersions, onInstalled }: CatalogBrowserProps) installedVersion={installedVersions.get(entry.packageId) ?? null} isBusy={busyId === entry.packageId} onToggleExpand={() => setExpandedId((cur) => (cur === entry.packageId ? null : entry.packageId))} - onInstall={(version) => void handleInstall(entry.packageId, version)} + onInstall={(version, downloadUrl) => void handleInstall(entry.packageId, version, downloadUrl)} onUninstall={() => void handleUninstall(entry.packageId)} /> ))} @@ -230,7 +230,7 @@ interface CatalogCardProps { installedVersion: string | null isBusy: boolean onToggleExpand: () => void - onInstall: (version: string) => void + onInstall: (version: string, downloadUrl: string) => void onUninstall: () => void } @@ -352,7 +352,7 @@ interface ActionMenuButtonProps { installedVersion: string | null triggerState: TriggerState isBusy: boolean - onInstall: (version: string) => void + onInstall: (version: string, downloadUrl: string) => void onUninstall: () => void } @@ -406,7 +406,7 @@ const ActionMenuButton = ({ disabled={!compatible || isInstalledHere} onSelect={() => { if (!compatible || isInstalledHere) return - onInstall(v.version) + onInstall(v.version, v.downloadUrl) }} className='flex cursor-pointer flex-col items-start gap-0.5 rounded-md px-2 py-1.5 outline-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[highlighted]:bg-neutral-100 dark:data-[highlighted]:bg-neutral-850' > diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index cbc7ed2ae..7bb57ecb3 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -17,10 +17,12 @@ import type { } from '@root/middleware/shared/ports/ethercat-types' import { CreatePouFileProps } from '@root/types/IPC/pou-service' import { CreateProjectFileProps } from '@root/types/IPC/project-service' +import { randomUUID } from 'crypto' import dgram from 'dgram' import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron' import { app, dialog, nativeTheme, shell } from 'electron' import { readFile, realpathSync, stat, statSync, unwatchFile, watchFile } from 'fs' +import { unlink, writeFile } from 'fs/promises' import type { IncomingHttpHeaders, IncomingMessage } from 'http' import https from 'https' import { networkInterfaces } from 'os' @@ -820,6 +822,7 @@ class MainProcessBridge implements MainIpcModule { // ===================== PACKAGE MANAGER ===================== this.registerHandle('packages:import-from-file', this.handlePackagesImportFromFile) + this.registerHandle('packages:install-from-url', this.handlePackagesInstallFromUrl) this.registerHandle('packages:list-installed', this.handlePackagesListInstalled) this.registerHandle('packages:uninstall', this.handlePackagesUninstall) this.registerHandle('packages:get-manifest', this.handlePackagesGetManifest) @@ -1244,6 +1247,42 @@ class MainProcessBridge implements MainIpcModule { } return importResult } + handlePackagesInstallFromUrl = async ( + _event: IpcMainInvokeEvent, + args: { packageId: string; version: string; downloadUrl: string }, + ) => { + const { packageId, version, downloadUrl } = args + // Download in the main process — the renderer can't reach the install + // pipeline directly, and main has clean fs / temp-dir ergonomics. The + // VPP catalog backend serves a private S3 bucket through its own API, + // so `downloadUrl` always points at the backend (never S3 directly). + let tempPath: string | null = null + try { + const response = await fetch(downloadUrl) + if (!response.ok) { + return { + success: false, + error: `Download failed: ${response.status} ${response.statusText}`, + } + } + const buffer = new Uint8Array(await response.arrayBuffer()) + tempPath = join(app.getPath('temp'), `openplc-vpp-${packageId}-${version}-${randomUUID()}.vpp`) + await writeFile(tempPath, buffer) + const importResult = await this.packageManagerModule.importFromFile(tempPath) + if (importResult.success) { + this.mainWindow?.webContents.send('packages:boards-updated') + } + return importResult + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } finally { + if (tempPath) { + // Best-effort cleanup — never fail the install because the temp + // file lingered; OS will reap it on reboot anyway. + await unlink(tempPath).catch(() => {}) + } + } + } handlePackagesListInstalled = async () => this.packageManagerModule.listInstalled() handlePackagesUninstall = async (_event: IpcMainInvokeEvent, packageId: string) => { const result = this.packageManagerModule.uninstall(packageId) diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index a85c7cb03..c5f3c1d52 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -317,6 +317,18 @@ const rendererProcessBridge = { devices?: string[] error?: string }> => ipcRenderer.invoke('packages:import-from-file'), + installPackageFromUrl: (args: { + packageId: string + version: string + downloadUrl: string + }): Promise<{ + success: boolean + canceled?: boolean + packageId?: string + packageName?: string + devices?: string[] + error?: string + }> => ipcRenderer.invoke('packages:install-from-url', args), listInstalledPackages: (): Promise< Array<{ packageId: string; version: string; installedAt: string; path: string; devices: string[] }> > => ipcRenderer.invoke('packages:list-installed'), diff --git a/src/middleware/adapters/editor/__tests__/package-adapter.test.ts b/src/middleware/adapters/editor/__tests__/package-adapter.test.ts index 8ae820d23..ec6348e1f 100644 --- a/src/middleware/adapters/editor/__tests__/package-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/package-adapter.test.ts @@ -44,6 +44,7 @@ let adapter: PackagePort beforeEach(() => { window.bridge = { importPackageFromFile: jest.fn().mockResolvedValue(importOk), + installPackageFromUrl: jest.fn().mockResolvedValue(importOk), listInstalledPackages: jest.fn().mockResolvedValue(installedPackages), uninstallPackage: jest.fn().mockResolvedValue({ success: true }), getPackageManifest: jest.fn().mockResolvedValue(validManifest), @@ -51,9 +52,14 @@ beforeEach(() => { onBoardsUpdated: jest.fn().mockImplementation(() => jest.fn()), } as unknown as typeof window.bridge + global.fetch = jest.fn() as unknown as typeof fetch adapter = createEditorPackageAdapter() }) +afterEach(() => { + ;(global.fetch as unknown as jest.Mock).mockReset() +}) + describe('createEditorPackageAdapter', () => { describe('importFromFile', () => { it('delegates to window.bridge.importPackageFromFile and returns the wire result', async () => { @@ -131,26 +137,56 @@ describe('createEditorPackageAdapter', () => { }) describe('listRemoteCatalog', () => { - // Until the CDN backend (EDGE-482) lands, the catalog method rejects. - // The CatalogBrowser surfaces this through its error banner with a Try - // Again button — no UI crash, no empty-catalog confusion. - it('rejects with a "backend not yet available" error so the UI surfaces its error state', async () => { - await expect(adapter.listRemoteCatalog()).rejects.toThrow(/not yet available/i) + it('fetches the catalog from the backend and returns the parsed JSON', async () => { + const catalog = { entries: [], fetchedAt: '2026-05-29T10:00:00Z' } + ;(global.fetch as unknown as jest.Mock).mockResolvedValue({ + ok: true, + status: 200, + json: async () => catalog, + }) + const result = await adapter.listRemoteCatalog() + expect(global.fetch).toHaveBeenCalledWith(expect.stringMatching(/\/vpp-catalog\/v1\/catalog\.json$/)) + expect(result).toEqual(catalog) + }) + + it('rejects with a contextual error when the backend returns a non-ok status', async () => { + ;(global.fetch as unknown as jest.Mock).mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + json: async () => ({}), + }) + await expect(adapter.listRemoteCatalog()).rejects.toThrow(/503/) + }) + + it('propagates fetch failures (offline, DNS) up to the caller', async () => { + ;(global.fetch as unknown as jest.Mock).mockRejectedValue(new Error('ECONNREFUSED')) + await expect(adapter.listRemoteCatalog()).rejects.toThrow(/ECONNREFUSED/) }) }) describe('installFromRemote', () => { - it('resolves with success:false and an error that names the requested package + version', async () => { - const result = await adapter.installFromRemote('com.openplc.arduino', '0.2.0') - expect(result.success).toBe(false) - expect(result.error).toContain('com.openplc.arduino') - expect(result.error).toContain('0.2.0') + it('delegates to window.bridge.installPackageFromUrl with the full request payload', async () => { + const result = await adapter.installFromRemote( + 'com.openplc.arduino', + '0.2.0', + 'http://localhost:3333/vpp-catalog/v1/packages/com.openplc.arduino-0.2.0.vpp', + ) + expect(window.bridge.installPackageFromUrl).toHaveBeenCalledWith({ + packageId: 'com.openplc.arduino', + version: '0.2.0', + downloadUrl: 'http://localhost:3333/vpp-catalog/v1/packages/com.openplc.arduino-0.2.0.vpp', + }) + expect(result).toEqual(importOk) }) - it('still surfaces the packageId when version is omitted', async () => { - const result = await adapter.installFromRemote('com.openplc.espressif') - expect(result.success).toBe(false) - expect(result.error).toContain('com.openplc.espressif') + it('forwards bridge-side failures verbatim so the UI modal stays honest', async () => { + ;(window.bridge.installPackageFromUrl as jest.Mock).mockResolvedValue({ + success: false, + error: 'Download failed: 404 Not Found', + }) + const result = await adapter.installFromRemote('com.openplc.espressif', '0.1.0', 'http://example/missing.vpp') + expect(result).toEqual({ success: false, error: 'Download failed: 404 Not Found' }) }) }) diff --git a/src/middleware/adapters/editor/__tests__/remote-catalog-mock.test.ts b/src/middleware/adapters/editor/__tests__/remote-catalog-mock.test.ts index 13d4318fc..7a91dc6f5 100644 --- a/src/middleware/adapters/editor/__tests__/remote-catalog-mock.test.ts +++ b/src/middleware/adapters/editor/__tests__/remote-catalog-mock.test.ts @@ -54,16 +54,22 @@ describe('mockListRemoteCatalog', () => { }) describe('mockInstallFromRemote', () => { - it('resolves with success:false and an error that names the requested package + version', async () => { - const result = await mockInstallFromRemote('com.openplc.arduino', '0.2.0') + it('resolves with success:false and an error that names the requested package + version + downloadUrl', async () => { + const result = await mockInstallFromRemote( + 'com.openplc.arduino', + '0.2.0', + 'http://localhost:3333/vpp-catalog/v1/packages/com.openplc.arduino-0.2.0.vpp', + ) expect(result.success).toBe(false) expect(result.error).toContain('com.openplc.arduino') expect(result.error).toContain('0.2.0') + expect(result.error).toContain('localhost:3333') }) - it('still surfaces the packageId when version is omitted', async () => { - const result = await mockInstallFromRemote('com.openplc.espressif') + it('echoes the packageId across different requests so the modal renders the right context', async () => { + const result = await mockInstallFromRemote('com.openplc.espressif', '0.1.0', 'http://example/foo.vpp') expect(result.success).toBe(false) expect(result.error).toContain('com.openplc.espressif') + expect(result.error).toContain('0.1.0') }) }) diff --git a/src/middleware/adapters/editor/package-adapter.ts b/src/middleware/adapters/editor/package-adapter.ts index db21111aa..0cf4df5c9 100644 --- a/src/middleware/adapters/editor/package-adapter.ts +++ b/src/middleware/adapters/editor/package-adapter.ts @@ -5,18 +5,19 @@ * package lifecycle operations (import, list, uninstall, manifest). * * IPC channels: - * packages:import-from-file (invoke) + * packages:import-from-file (invoke) + * packages:install-from-url (invoke) * packages:list-installed (invoke) * packages:uninstall (invoke) * packages:get-manifest (invoke) * packages:open-manager (on) * packages:boards-updated (on) * - * `listRemoteCatalog` and `installFromRemote` are still stubs until the - * CDN backend ships (see EDGE-482 + subtasks for the wire contract). The - * UI surface exists and consumes these methods through `PackagePort`; both - * surface a clean "backend not connected" error today so the Browse Catalog - * tab degrades gracefully instead of crashing. + * `listRemoteCatalog` fetches the JSON catalog directly from the OpenPLC + * VPP catalog backend (autonomy-edge). `installFromRemote` defers the + * binary download to main via the `packages:install-from-url` IPC channel + * so the existing local install pipeline (`PackageManagerModule. + * importFromFile`) can run unchanged. */ import { parsePackageManifest } from '../../shared/ports/package-manifest-schema' @@ -31,15 +32,21 @@ import type { } from '../../shared/ports/types' import { mockInstallFromRemote, mockListRemoteCatalog } from './remote-catalog-mock' -const REMOTE_BACKEND_NOT_WIRED = - 'OpenPLC CDN catalog backend is not yet available. Use "Add from file..." with a downloaded .vpp for now.' +/** + * Base URL of the OpenPLC VPP catalog backend (autonomy-edge). Points at + * the staging deployment by default. For local backend dev, flip to + * `http://localhost:3333` (the `pnpm dev:backend` listen address). When + * the production deployment lands, promote this to a webpack DefinePlugin + * -injected constant mirroring `APP_VERSION` so the value travels through + * CI and per-environment builds carry the right host. + */ +const CATALOG_BASE_URL = 'https://api-staging.autonomylogic.com' /** * Local-dev toggle: when `true`, the catalog port methods are served from * the static fixture in `remote-catalog-mock.ts` so the Browse Catalog UI - * can be exercised end-to-end without a real CDN. Committed value MUST stay - * `false` — flipping is a working-tree-only edit while EDGE-482 (real CDN) - * is still pending. + * can be exercised end-to-end without a running backend. Committed value + * MUST stay `false` — flipping is a working-tree-only edit for offline dev. */ const USE_LOCAL_MOCK = false @@ -70,25 +77,21 @@ export function createEditorPackageAdapter(): PackagePort { return parsePackageManifest(raw) }, - listRemoteCatalog(): Promise { - // TODO(EDGE-482): replace with HTTP fetch against the OpenPLC CDN - // catalog URL once the backend lands. Until then we reject so the - // CatalogBrowser surfaces its error state with a Try Again button - // instead of rendering an empty catalog. + async listRemoteCatalog(): Promise { if (USE_LOCAL_MOCK) return mockListRemoteCatalog() - return Promise.reject(new Error(REMOTE_BACKEND_NOT_WIRED)) + const response = await fetch(`${CATALOG_BASE_URL}/vpp-catalog/v1/catalog.json`) + if (!response.ok) { + throw new Error(`Catalog fetch failed: ${response.status} ${response.statusText}`) + } + return (await response.json()) as RemoteCatalog }, - installFromRemote(packageId: string, version?: string): Promise { - // TODO(EDGE-482): replace with `fetch(downloadUrl) -> tmp file -> - // local install pipeline` once the CDN backend lands. The shape of - // the error keeps the UI's "Backend not connected" modal honest. - if (USE_LOCAL_MOCK) return mockInstallFromRemote(packageId, version) - const versionSuffix = version ? `@${version}` : '' - return Promise.resolve({ - success: false, - error: `Remote install for "${packageId}${versionSuffix}" is not available — ${REMOTE_BACKEND_NOT_WIRED}`, - }) + installFromRemote(packageId: string, version: string, downloadUrl: string): Promise { + if (USE_LOCAL_MOCK) return mockInstallFromRemote(packageId, version, downloadUrl) + // Defer to main — it downloads the binary, writes it to a temp file, + // and hands it off to PackageManagerModule.importFromFile (the same + // pipeline that the "Add from file..." flow uses). + return window.bridge.installPackageFromUrl({ packageId, version, downloadUrl }) }, onOpenManager(callback: () => void): Unsubscribe { diff --git a/src/middleware/adapters/editor/remote-catalog-mock.ts b/src/middleware/adapters/editor/remote-catalog-mock.ts index 6246fb84d..0371253fa 100644 --- a/src/middleware/adapters/editor/remote-catalog-mock.ts +++ b/src/middleware/adapters/editor/remote-catalog-mock.ts @@ -252,14 +252,17 @@ export async function mockListRemoteCatalog(): Promise { * Mock implementation of `PackagePort.installFromRemote` — never actually * installs anything (the local install pipeline is intentionally out of * scope for the mock), but resolves with a "backend not wired" error that - * names the requested `packageId@version` so the UI's modal renders the - * full request the way it would in production. + * names the requested `packageId@version` plus the `downloadUrl` so the + * UI's modal renders the full request the way it would in production. */ -export async function mockInstallFromRemote(packageId: string, version?: string): Promise { +export async function mockInstallFromRemote( + packageId: string, + version: string, + downloadUrl: string, +): Promise { await new Promise((resolve) => setTimeout(resolve, 150)) - const versionSuffix = version ? `@${version}` : '' return { success: false, - error: `[mock] Remote install for "${packageId}${versionSuffix}" is not wired — flip USE_LOCAL_MOCK off and pull a real CDN once EDGE-482 ships.`, + error: `[mock] Remote install for "${packageId}@${version}" (${downloadUrl}) is not wired — flip USE_LOCAL_MOCK off to hit the real backend.`, } } diff --git a/src/middleware/shared/ports/package-port.ts b/src/middleware/shared/ports/package-port.ts index dfbbc0e09..b5c8eb2c8 100644 --- a/src/middleware/shared/ports/package-port.ts +++ b/src/middleware/shared/ports/package-port.ts @@ -46,16 +46,16 @@ export interface PackagePort { /** * Fetch the remote VPP catalog from the OpenPLC CDN. - * Currently mocked in both adapters until the backend ships. */ listRemoteCatalog(): Promise /** - * Download and install a VPP from the remote catalog by package id (and - * optional explicit version — defaults to the catalog's advertised one). - * Currently a no-op stub in both adapters until the backend ships. + * Download and install a VPP from the remote catalog. The caller passes + * the `downloadUrl` it read from the catalog entry's selected version — + * the editor never constructs download URLs itself (the catalog is the + * source of truth, per the backend contract documented in EDGE-482). */ - installFromRemote(packageId: string, version?: string): Promise + installFromRemote(packageId: string, version: string, downloadUrl: string): Promise /** * Subscribe to the "open package manager" event (triggered from menu). From 516d21b2c8fa75fed5613ec74d972bef34e7db93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Fri, 29 May 2026 14:41:41 +0200 Subject: [PATCH 18/61] fix(compiler): inject -I{core,variant} into precompile so c_blocks_code finds Arduino.h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handlePrecompileUserLib was substituting the recipe's {includes} placeholder with only the project-local paths -I${srcDir} / -I${baremetalDir}, dropping the core and variant include paths that arduino-cli normally injects there at compile time. Most TUs in the precompile (arduino_runtime_glue.cpp, pou_MAIN.cpp, configuration.cpp etc.) never directly #include , so they compiled fine — but c_blocks_code.cpp (emitted whenever the project has a C/C++ POU) does, and on cores whose platform.txt does not embed -I{build.core.path} literally into recipe.cpp.o.pattern (Renesas Uno R4 WiFi is the case that surfaced it), the precompile blew up with: c_blocks_code.cpp:5:10: fatal error: Arduino.h: No such file or directory Pull build.core.path and build.variant.path from extractToolchainProperties (already populated from arduino-cli --show-properties=expanded) and prepend them to the includeArgs list. build.core.path is mandatory — without it Arduino.h would never resolve and no compile could succeed; raise an actionable error if --show- properties omits it. build.variant.path is optional (some runtime-only or minimalist cores omit variants); skip the -I flag when empty. Ordering mirrors arduino-cli's own injection (core, variant, then project-local paths) so headers in the project src/ tree never shadow core/variant ones by accident. Adds three regression tests in __tests__/handle-precompile-user-lib. test.ts: - argv carries -I${build.core.path} and -I${build.variant.path} when both are populated. - the variant -I is omitted (but the core -I stays) when build.variant.path is empty. - hard-fail with an actionable error when build.core.path is absent. Note: src/backend/editor/compiler/compiler-module.spec.ts is also updated (cannedProps gains build.core.path / build.variant.path, and the same three tests are added there) but jest's current testMatch config does not pick up .spec.ts files outside __tests__/ — that file has not been running. The defensive update keeps it correct for the day someone fixes the matcher, but the tests above in __tests__/handle-precompile-user-lib.test.ts are the ones actually guarding the regression today. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../handle-precompile-user-lib.test.ts | 175 ++++++++++++++++++ .../editor/compiler/compiler-module.spec.ts | 83 +++++++++ .../editor/compiler/compiler-module.ts | 27 ++- 3 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 src/backend/editor/compiler/__tests__/handle-precompile-user-lib.test.ts diff --git a/src/backend/editor/compiler/__tests__/handle-precompile-user-lib.test.ts b/src/backend/editor/compiler/__tests__/handle-precompile-user-lib.test.ts new file mode 100644 index 000000000..584658576 --- /dev/null +++ b/src/backend/editor/compiler/__tests__/handle-precompile-user-lib.test.ts @@ -0,0 +1,175 @@ +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { CompilerModule } from '../compiler-module' +import type { ToolchainProperties } from '../types' + +// Electron is imported transitively by compiler-module; stub the bits the +// instantiation path actually touches so jest doesn't have to load the real +// runtime in the renderer-test environment. +jest.mock('electron', () => ({ + app: { + getPath: jest.fn().mockReturnValue('/tmp/mock-user-data'), + getAppPath: jest.fn().mockReturnValue('/tmp/mock-app-root'), + isPackaged: false, + getVersion: jest.fn().mockReturnValue('0.0.0-test'), + }, + dialog: { showSaveDialog: jest.fn().mockResolvedValue({ filePath: '/tmp/mock-save-path' }) }, +})) +jest.mock('electron/main', () => ({}), { virtual: true }) + +// Route every child-process invocation through a shared `execImpl.current` +// dispatcher so tests can capture the argv each precompile TU spawn produces. +// execFile is the path recipe-exec.ts hits today; exec stays mocked for the +// legacy compile-related call sites in the module's wider call graph. +const execImpl: { current: (cmd: string) => Promise<{ stdout: string; stderr: string }> } = { + current: async () => ({ stdout: '', stderr: '' }), +} +const renderArgvAsCmd = (command: string, args: ReadonlyArray): string => + [command, ...args].map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(' ') +jest.mock('node:child_process', () => { + const { promisify } = jest.requireActual('node:util') as typeof import('node:util') + const exec = ( + cmd: string, + _opts: unknown, + cb: (err: Error | null, val?: { stdout: string; stderr: string }) => void, + ) => { + execImpl.current(cmd).then((v) => cb(null, v)).catch((e: Error) => cb(e)) + return { kill: () => undefined } + } + ;(exec as unknown as { [k: symbol]: unknown })[promisify.custom] = (cmd: string) => execImpl.current(cmd) + const execFile = ( + command: string, + args: ReadonlyArray, + _opts: unknown, + cb: (err: Error | null, val?: { stdout: string; stderr: string }) => void, + ) => { + execImpl.current(renderArgvAsCmd(command, args)).then((v) => cb(null, v)).catch((e: Error) => cb(e)) + return { kill: () => undefined } + } + ;(execFile as unknown as { [k: symbol]: unknown })[promisify.custom] = ( + command: string, + args: ReadonlyArray, + ) => execImpl.current(renderArgvAsCmd(command, args)) + return { exec, execFile, spawn: jest.fn() } +}) + +;(process as unknown as { resourcesPath: string }).resourcesPath ??= process.cwd() + +describe('handlePrecompileUserLib include-path injection', () => { + const fs = jest.requireActual('node:fs') as typeof import('node:fs') + const noopLog = jest.fn() + let compilerModule: CompilerModule + let buildDir: string + let srcDir: string + let extractSpy: jest.SpyInstance + + // The recipe deliberately includes `{includes}` twice and a trailing + // `-o {object_file}` so every assertion that scans the rendered command + // can rely on a stable, deterministic shape. + const baseProps: ToolchainProperties = { + fqbn: 'arduino:renesas_uno:unor4wifi', + properties: { + 'compiler.path': '/fake/renesas/bin/', + 'compiler.ar.cmd': 'arm-none-eabi-ar', + 'compiler.ar.flags': 'rcs', + 'build.arch': 'RENESAS_UNO', + 'build.core.path': '/fake/renesas/cores/arduino', + 'build.variant.path': '/fake/renesas/variants/UNOWIFIR4', + }, + recipeCpp: 'arm-none-eabi-g++ -c {source_file} {includes} -o {object_file}', + recipeC: 'arm-none-eabi-gcc -c {source_file} {includes} -o {object_file}', + recipeAr: 'arm-none-eabi-ar rcs {archive_file_path} {object_file}', + } + + beforeEach(() => { + compilerModule = new CompilerModule() + noopLog.mockClear() + buildDir = fs.mkdtempSync(join(tmpdir(), 'openplc-precompile-includes-')) + srcDir = join(buildDir, 'src') + fs.mkdirSync(srcDir, { recursive: true }) + extractSpy = jest + .spyOn(compilerModule, 'extractToolchainProperties') + .mockResolvedValue(baseProps as unknown as ToolchainProperties) + execImpl.current = async () => ({ stdout: '', stderr: '' }) + }) + + afterEach(() => { + extractSpy.mockRestore() + fs.rmSync(buildDir, { recursive: true, force: true }) + }) + + it('injects -I{build.core.path} and -I{build.variant.path} into every TU compile', async () => { + // The Renesas-style failure mode: c_blocks_code.cpp includes + // which lives at build.core.path/Arduino.h. The platform recipe leaves + // the bare core/variant -I out of recipe.cpp.o.pattern and relies on + // arduino-cli to inject them at compile time via {includes}. The + // precompile mirrors that injection here. + fs.writeFileSync(join(srcDir, 'c_blocks_code.cpp'), '#include \n', 'utf-8') + + const execCalls: string[] = [] + execImpl.current = async (cmd) => { + execCalls.push(cmd) + return { stdout: '', stderr: '' } + } + + await compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:renesas_uno:unor4wifi', + handleOutputData: noopLog, + }) + + const compileCmd = execCalls.find((c) => c.includes('c_blocks_code.cpp')) ?? '' + expect(compileCmd).toContain('-I/fake/renesas/cores/arduino') + expect(compileCmd).toContain('-I/fake/renesas/variants/UNOWIFIR4') + }) + + it('omits the variant -I when build.variant.path is unset (runtime-only / minimalist cores)', async () => { + extractSpy.mockResolvedValue({ + ...baseProps, + properties: { ...baseProps.properties, 'build.variant.path': '' }, + } as unknown as ToolchainProperties) + + fs.writeFileSync(join(srcDir, 'c_blocks_code.cpp'), '#include \n', 'utf-8') + + const execCalls: string[] = [] + execImpl.current = async (cmd) => { + execCalls.push(cmd) + return { stdout: '', stderr: '' } + } + + await compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:renesas_uno:unor4wifi', + handleOutputData: noopLog, + }) + + const compileCmd = execCalls.find((c) => c.includes('c_blocks_code.cpp')) ?? '' + expect(compileCmd).toContain('-I/fake/renesas/cores/arduino') + // No bare `-I` followed by space-then-empty — the variant flag is dropped entirely. + expect(compileCmd).not.toMatch(/-I(\s|$)/) + }) + + it('hard-fails with an actionable error when build.core.path is missing from --show-properties', async () => { + extractSpy.mockResolvedValue({ + ...baseProps, + properties: { + 'compiler.path': '/fake/renesas/bin/', + 'compiler.ar.cmd': 'arm-none-eabi-ar', + 'compiler.ar.flags': 'rcs', + // build.core.path intentionally absent — TUs that include + // would silently fail to find the header otherwise. + }, + } as unknown as ToolchainProperties) + + fs.writeFileSync(join(srcDir, 'c_blocks_code.cpp'), '#include \n', 'utf-8') + + await expect( + compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:renesas_uno:unor4wifi', + handleOutputData: noopLog, + }), + ).rejects.toThrow(/build\.core\.path.*core is likely not installed/s) + }) +}) diff --git a/src/backend/editor/compiler/compiler-module.spec.ts b/src/backend/editor/compiler/compiler-module.spec.ts index 18adb54b4..5e2e0cad4 100644 --- a/src/backend/editor/compiler/compiler-module.spec.ts +++ b/src/backend/editor/compiler/compiler-module.spec.ts @@ -438,6 +438,8 @@ describe('CompilerModule', () => { 'compiler.ar.cmd': 'avr-ar', 'compiler.ar.flags': 'rcs', 'build.arch': 'AVR', + 'build.core.path': '/fake/avr/cores/arduino', + 'build.variant.path': '/fake/avr/variants/standard', }, recipeCpp: 'avr-g++ -c {source_file} {includes} {includes} -o {object_file}', recipeC: 'avr-gcc -c {source_file} {includes} -o {object_file}', @@ -547,6 +549,8 @@ describe('CompilerModule', () => { extractSpy.mockResolvedValue({ ...cannedProps, properties: { + // build.core.path present so we reach the compiler/ar check + 'build.core.path': '/fake/avr/cores/arduino', /* compiler.path & compiler.ar.cmd intentionally absent */ }, } as unknown as ToolchainProperties) @@ -657,6 +661,84 @@ describe('CompilerModule', () => { ]) }) + it('injects -I{build.core.path} and -I{build.variant.path} into every TU compile (so Arduino.h resolves)', async () => { + // Reproduces the failure mode where Renesas-style cores leave the + // bare core/variant -I out of recipe.cpp.o.pattern and rely on + // arduino-cli to inject them at compile time via the `{includes}` + // substitution. The precompile mirrors that injection here. + fs.writeFileSync(join(srcDir, 'pou_MAIN.cpp'), '// pou\n', 'utf-8') + + const execCalls: string[] = [] + execImpl.current = async (cmd) => { + execCalls.push(cmd) + return { stdout: '', stderr: '' } + } + + await compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:avr:uno', + handleOutputData: noopLog, + }) + + const compileCmd = execCalls.find((c) => c.includes('pou_MAIN.cpp')) ?? '' + expect(compileCmd).toContain('-I/fake/avr/cores/arduino') + expect(compileCmd).toContain('-I/fake/avr/variants/standard') + }) + + it('omits the variant -I when build.variant.path is unset (runtime-only / minimalist cores)', async () => { + extractSpy.mockResolvedValue({ + ...cannedProps, + properties: { + ...cannedProps.properties, + 'build.variant.path': '', + }, + } as unknown as ToolchainProperties) + + fs.writeFileSync(join(srcDir, 'pou_MAIN.cpp'), '// pou\n', 'utf-8') + + const execCalls: string[] = [] + execImpl.current = async (cmd) => { + execCalls.push(cmd) + return { stdout: '', stderr: '' } + } + + await compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:avr:uno', + handleOutputData: noopLog, + }) + + const compileCmd = execCalls.find((c) => c.includes('pou_MAIN.cpp')) ?? '' + expect(compileCmd).toContain('-I/fake/avr/cores/arduino') + // No `-I` followed by empty path — the variant flag is dropped entirely. + expect(compileCmd).not.toMatch(/-I(\s|$)/) + }) + + it('hard-fails with an actionable error when build.core.path is missing from --show-properties', async () => { + extractSpy.mockResolvedValue({ + ...cannedProps, + properties: { + 'compiler.path': '/fake/avr/bin/', + 'compiler.ar.cmd': 'avr-ar', + 'compiler.ar.flags': 'rcs', + // build.core.path intentionally absent — TUs that include + // would silently fail to find the header. + }, + } as unknown as ToolchainProperties) + + fs.writeFileSync(join(srcDir, 'pou_MAIN.cpp'), '// pou\n', 'utf-8') + + execImpl.current = async () => ({ stdout: '', stderr: '' }) + + await expect( + compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:avr:uno', + handleOutputData: noopLog, + }), + ).rejects.toThrow(/build\.core\.path.*core is likely not installed/s) + }) + it('hard-fails with an actionable error when no arch property is exposed by --show-properties', async () => { // Reproduce a custom/legacy core whose platform.txt exposes none // of build.mcu / build.architecture / build.arch. The legacy @@ -670,6 +752,7 @@ describe('CompilerModule', () => { 'compiler.path': '/fake/avr/bin/', 'compiler.ar.cmd': 'avr-ar', 'compiler.ar.flags': 'rcs', + 'build.core.path': '/fake/avr/cores/arduino', // build.mcu / build.architecture / build.arch intentionally absent }, } as unknown as ToolchainProperties) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 8fd6c9721..df1303317 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -1529,7 +1529,32 @@ class CompilerModule { // -I arguments are passed as bare argv entries (no extra quoting) — // execFile delivers them literally to the toolchain on every host. - const includeArgs = [`-I${srcDir}`, `-I${baremetalDir}`] + // + // arduino-cli normally injects `-I{build.core.path}` and + // `-I{build.variant.path}` into the `{includes}` substitution at + // compile time — those are where `Arduino.h` and `pins_arduino.h` + // live. The platform.txt recipe expands `-I{build.core.path}/tinyusb` + // etc. literally, but the *base* core path comes from `{includes}`. + // Renesas's recipe in particular leaves the base out, so a TU like + // `c_blocks_code.cpp` that does `#include ` fails the + // precompile with "Arduino.h: No such file or directory". Mirroring + // arduino-cli's injection here keeps every TU finding the core/ + // variant headers regardless of how the core author chose to wire + // its recipe template. + const corePath = tcProps.properties['build.core.path'] + const variantPath = tcProps.properties['build.variant.path'] + if (!corePath) { + throw new Error( + `Toolchain pre-compile requires build.core.path from arduino-cli --show-properties for "${fqbn}". ` + + `The board's core is likely not installed.`, + ) + } + const includeArgs = [ + `-I${corePath}`, + ...(variantPath ? [`-I${variantPath}`] : []), + `-I${srcDir}`, + `-I${baremetalDir}`, + ] // Appended after the recipe so the last `-std=` wins over the core's // implicit gnu++14. extraCxxFlags carries VPP per-board cxx_flags. From 4d14ecc5b71e778f3dc5c067624cc063d48211ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Fri, 29 May 2026 14:42:35 +0200 Subject: [PATCH 19/61] fix(c-blocks): undef Arduino min/max macros before strucpp headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arduino.h defines `min` and `max` as preprocessor macros, which collide with the `std::min` / `std::max` function templates and `numeric_limits::min()` / `max()` static members declared by / (both pulled in transitively via iec_string.hpp). Without scrubbing, projects with a C/C++ POU fail to build with "macro min requires 2 arguments, but only 1 given" cascades across the entire AVR libstdc++ tree. Undef both macros immediately after `#include ` and before the strucpp runtime headers. Back-port of 6a5fbf6e3 (already on origin/development via #794) — this branch (feat/vpp-compile-pipeline-port) diverged at b1812341c before that fix landed, so AVR projects with a C/C++ POU were still broken here. The previous commit on this branch (which makes the precompile actually find Arduino.h via the -I{core,variant} fix) is what surfaces this collision in the first place. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../cpp/__tests__/generateCBlocksCode.test.ts | 29 +++++++++++++++++-- .../shared/utils/cpp/generateCBlocksCode.ts | 9 ++++++ 2 files changed, 36 insertions(+), 2 deletions(-) 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. From 485ac144d76f7515a18f60877b883d5ca81f3dcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Fri, 29 May 2026 18:04:26 +0200 Subject: [PATCH 20/61] feat(vpp): map GPIO pin-mapping to runtime-v4 plugin config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a runtime-v4 board expose physical GPIO through the existing pin-mapping table instead of a bespoke vendor screen: - Forward a manifest-declared `capabilities` block from VPP boards into BoardInfo so a GPIO board can opt into `pinMapping` (resolveTargetCapabilities already merges it over the preset). - Render the Pin Mapping table for any non-simulator target with the pinMapping capability (not only Arduino), alongside runtime stats when connected. - Emit a `pins[]` array in the generated plugin config from devices/pin-mapping.json: digital in/out as {pin,direction,byte,bit}, analog out as PWM {pin,direction:'pwm',word}; analog in is skipped. - Fix ZIP entry separators to forward slashes so nested files (e.g. vpp_plugin/) extract correctly on POSIX runtimes — path.join emitted backslashes on Windows, which the runtime treated as literal filenames. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/compiler/compiler-module.ts | 22 ++++++- .../editor/hardware/hardware-module.ts | 4 ++ src/backend/editor/hardware/types.ts | 6 +- .../generate-vendor-plugin-config.test.ts | 47 +++++++++++++ .../vpp/generate-vendor-plugin-config.ts | 66 ++++++++++++++++++- .../editor/device/configuration/board.tsx | 37 ++++++----- src/middleware/shared/ports/types.ts | 6 ++ 7 files changed, 169 insertions(+), 19 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index df1303317..3ec9ceb67 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -2063,7 +2063,11 @@ class CompilerModule { for (const entry of entries) { const fullPath = path.join(currentPath, entry.name) - const zipPath = relativePath ? path.join(relativePath, entry.name) : entry.name + // ZIP entry names must use forward slashes (the ZIP spec separator). + // path.join would emit backslashes on Windows, which a POSIX runtime + // then treats as literal filename characters rather than directory + // separators — breaking extraction of every nested file. + const zipPath = relativePath ? `${relativePath}/${entry.name}` : entry.name if (entry.isDirectory()) { await addFilesToZip(fullPath, zipFolder, zipPath) @@ -2382,6 +2386,20 @@ class CompilerModule { // Device configuration may not exist yet — use empty vendor data } + // Read the GPIO pin-mapping for pin-based boards (capabilities. + // pinMapping). The generator turns these into the plugin config's + // pins[] array. Module-based boards have no pins, so this stays + // empty and no pins[] key is emitted. + let devicePins: DevicePin[] = [] + try { + const pinMappingPath = join(normalizedProjectPath, 'devices', 'pin-mapping.json') + const pinMappingRaw = await readFile(pinMappingPath, 'utf-8') + const parsedPins: unknown = JSON.parse(pinMappingRaw) + if (Array.isArray(parsedPins)) devicePins = parsedPins as DevicePin[] + } catch { + // No pin-mapping file — leave empty. + } + // Pre-load each module's configScreen JSON so the (pure) // generator can encode per-slot configuration bytes without // touching the filesystem. @@ -2405,7 +2423,7 @@ class CompilerModule { return { ...m, configScreenDefinition } }), ) - const finalConfig = generateVendorPluginConfig(configTemplate, vendorScreenData, modules) + const finalConfig = generateVendorPluginConfig(configTemplate, vendorScreenData, modules, devicePins) // configTemplate is supplied by the package author through // their .vpp manifest. Without validation, plugin_name like diff --git a/src/backend/editor/hardware/hardware-module.ts b/src/backend/editor/hardware/hardware-module.ts index a2643b53b..3ceb2f9fb 100644 --- a/src/backend/editor/hardware/hardware-module.ts +++ b/src/backend/editor/hardware/hardware-module.ts @@ -291,6 +291,10 @@ class HardwareModule { device.target.platformOptions && device.target.platformOptions.length > 0 ? device.target.platformOptions : undefined, + // Forward any capability overrides the manifest declares (e.g. a + // runtime-v4 GPIO board setting `pinMapping: true`). + // `resolveTargetCapabilities` merges these over the preset. + capabilities: device.capabilities, vpp: { packageId: manifest.package.id, deviceId: device.id, diff --git a/src/backend/editor/hardware/types.ts b/src/backend/editor/hardware/types.ts index 4006d050a..233718765 100644 --- a/src/backend/editor/hardware/types.ts +++ b/src/backend/editor/hardware/types.ts @@ -1,6 +1,6 @@ import { z } from 'zod/v4' -import type { PlatformOption } from '../../../middleware/shared/ports/types' +import type { PlatformOption, TargetCapabilities } from '../../../middleware/shared/ports/types' const SerialPortSchema = z.object({ name: z.string(), @@ -130,6 +130,10 @@ type AvailableBoards = Map< /** VPP-declared FQBN sub-options (e.g. Nano cpu=atmega328old). Absent * when the manifest doesn't expose variants — see ports/types.ts. */ platformOptions?: PlatformOption[] + /** Manifest-declared capability overrides (e.g. a runtime-v4 GPIO board + * setting `pinMapping: true`). Merged over the preset by + * `resolveTargetCapabilities`. */ + capabilities?: Partial } > diff --git a/src/backend/shared/utils/vpp/__tests__/generate-vendor-plugin-config.test.ts b/src/backend/shared/utils/vpp/__tests__/generate-vendor-plugin-config.test.ts index 25edc289d..e923efe98 100644 --- a/src/backend/shared/utils/vpp/__tests__/generate-vendor-plugin-config.test.ts +++ b/src/backend/shared/utils/vpp/__tests__/generate-vendor-plugin-config.test.ts @@ -644,3 +644,50 @@ describe('generateVendorPluginConfig', () => { expect(slot.module_config?.startsWith('40 03')).toBe(true) }) }) + +describe('generateVendorPluginConfig — pins[] (GPIO pin-mapping)', () => { + it('omits pins[] when no device pins are supplied', () => { + const result = generateVendorPluginConfig({ plugin_name: 'rpi_gpio' }, {}, []) + expect(result.pins).toBeUndefined() + }) + + it('maps digital input/output pins to pin + direction + byte/bit', () => { + const result = generateVendorPluginConfig({ plugin_name: 'rpi_gpio' }, {}, [], [ + { pin: '11', pinType: 'digitalOutput', address: '%QX0.0' }, + { pin: '13', pinType: 'digitalInput', address: '%IX1.3' }, + ]) + expect(result.pins).toEqual([ + { pin: 11, direction: 'output', byte: 0, bit: 0 }, + { pin: 13, direction: 'input', byte: 1, bit: 3 }, + ]) + }) + + it('maps analog outputs to PWM (word index) and skips analog inputs', () => { + const result = generateVendorPluginConfig({}, {}, [], [ + { pin: '11', pinType: 'digitalOutput', address: '%QX0.0' }, + { pin: '26', pinType: 'analogInput', address: '%IW0' }, + { pin: '12', pinType: 'analogOutput', address: '%QW3' }, + ]) + expect(result.pins).toEqual([ + { pin: 11, direction: 'output', byte: 0, bit: 0 }, + { pin: 12, direction: 'pwm', word: 3 }, + ]) + }) + + it('skips rows with a non-numeric pin or an unparseable address', () => { + const result = generateVendorPluginConfig({}, {}, [], [ + { pin: 'P11', pinType: 'digitalOutput', address: '%QX0.0' }, + { pin: '18', pinType: 'digitalOutput', address: '' }, + { pin: '22', pinType: 'digitalInput', address: '%IX2.1' }, + ]) + expect(result.pins).toEqual([{ pin: 22, direction: 'input', byte: 2, bit: 1 }]) + }) + + it('emits pins[] alongside an empty slots[] for pin-only boards', () => { + const result = generateVendorPluginConfig({ plugin_name: 'rpi_gpio' }, {}, [], [ + { pin: '11', pinType: 'digitalOutput', address: '%QX0.0' }, + ]) + expect(result.slots).toEqual([]) + expect(result.pins).toEqual([{ pin: 11, direction: 'output', byte: 0, bit: 0 }]) + }) +}) diff --git a/src/backend/shared/utils/vpp/generate-vendor-plugin-config.ts b/src/backend/shared/utils/vpp/generate-vendor-plugin-config.ts index 6aa71c894..60ee9e677 100644 --- a/src/backend/shared/utils/vpp/generate-vendor-plugin-config.ts +++ b/src/backend/shared/utils/vpp/generate-vendor-plugin-config.ts @@ -70,6 +70,25 @@ type IoMapping = { type VendorScreenData = Record +/** A single row of the editor's GPIO pin-mapping table. Only the fields + * this serializer needs are modelled. */ +type DevicePinInput = { + pin: string + pinType: string + address: string +} + +/** One entry of the plugin config's `pins` array, as consumed by a + * pin-based runtime-v4 plugin (e.g. the Raspberry Pi GPIO HAL). `pin` is + * the board's native pin identifier as typed in the pin-mapping table — for + * the Raspberry Pi that's the physical 40-pin header position. + * + * Digital lines carry byte/bit (the %IX/%QX image-table location); PWM + * (analog) outputs carry word (the %QW index). */ +type PluginPin = + | { pin: number; direction: 'input' | 'output'; byte: number; bit: number } + | { pin: number; direction: 'pwm'; word: number } + type BitRangeMapping = { base_byte: number base_bit: number @@ -275,6 +294,41 @@ function buildSlots(vendorScreenData: VendorScreenData, modules: VppModuleDefini return slots } +/** + * Build the `pins` array for a pin-based plugin from the editor's GPIO + * pin-mapping table. + * + * Each digital pin becomes `{ pin, direction, byte, bit }`, where the + * byte/bit come straight from the IEC address the editor's allocator + * assigned (%IX. for inputs, %QX. for outputs) — the + * same image-table location the compiled PLC program reads/writes, which is + * what binds a physical pin to a program variable. `pin` is the board's pin + * identifier as entered by the user (the physical header position on a Pi). + * + * Analog OUTPUTS (%QW) map to hardware PWM (direction 'pwm', word index). + * Analog INPUTS are skipped: the Raspberry Pi SBC has no on-board ADC. + */ +function buildPins(devicePins: DevicePinInput[]): PluginPin[] { + const pins: PluginPin[] = [] + for (const dp of devicePins) { + const pinNumber = Number.parseInt(dp.pin, 10) + if (!Number.isInteger(pinNumber) || pinNumber < 0) continue + + if (dp.pinType === 'digitalInput' || dp.pinType === 'digitalOutput') { + const parsed = parseBitAddress(dp.address) + if (!parsed) continue + const direction = dp.pinType === 'digitalInput' ? 'input' : 'output' + pins.push({ pin: pinNumber, direction, byte: parsed.byte, bit: parsed.bit }) + } else if (dp.pinType === 'analogOutput') { + const word = parseWordAddress(dp.address) + if (word === null) continue + pins.push({ pin: pinNumber, direction: 'pwm', word }) + } + // analogInput: no on-board ADC on the Pi — nothing to map. + } + return pins +} + /* ------------------------------------------------------------------ */ /* Module configuration encoding */ /* ------------------------------------------------------------------ */ @@ -354,12 +408,15 @@ function encodeModuleConfig( * All fields from the config template are preserved. Form-based vendor screen * data (keyed by persistence keys other than 'module-configuration' and * 'io-mapping') is merged at the root level. The `slots` array is always set - * from the backplane configuration + I/O mapping. + * from the backplane configuration + I/O mapping. When `devicePins` is + * non-empty (pin-based GPIO boards), a `pins` array is emitted from the + * editor's pin-mapping table. */ export function generateVendorPluginConfig( configTemplate: Record, vendorScreenData: VendorScreenData, modules: VppModuleDefinition[], + devicePins: DevicePinInput[] = [], ): Record { const result: Record = { ...configTemplate } @@ -376,6 +433,13 @@ export function generateVendorPluginConfig( // Always write the slots array from module configuration + IO mapping result.slots = buildSlots(vendorScreenData, modules) + // Pin-based GPIO boards (capabilities.pinMapping) serialize their + // pin-mapping table into a pins[] array. Module-based boards pass no + // pins, so the key stays absent for them. + if (devicePins.length > 0) { + result.pins = buildPins(devicePins) + } + return result } diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx index 9c71689a8..83890551e 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -55,6 +55,11 @@ const Board = memo(function () { const currentBoardInfo = availableBoards.get(deviceBoard) + // Whether this target exposes the GPIO pin-mapping table. Arduino boards + // enable it via their preset; runtime-v4 GPIO boards (e.g. the Raspberry + // Pi HAL) opt in with `capabilities.pinMapping` in their VPP manifest. + const pinMappingEnabled = resolveTargetCapabilities(currentBoardInfo).pinMapping + const runtimeIpAddress = useOpenPLCStore((state) => state.deviceDefinitions.configuration.runtimeIpAddress || '') const connectionStatus = useOpenPLCStore((state) => state.runtimeConnection.connectionStatus) const setRuntimeIpAddress = useOpenPLCStore((state) => state.deviceActions.setRuntimeIpAddress) @@ -584,25 +589,20 @@ const Board = memo(function () {
    {(() => { - // Only draw the divider when there's actually content below it: - // Runtime targets render stats only when connected (the stats - // section always shows the EtherCAT panel when connected, even - // before the first scan completes); pin mapping (future - // Arduino-family VPP path) always renders. + // Only draw the divider when there's actually content below it. + // Pin mapping renders for any non-simulator target that declares + // the pinMapping capability (Arduino boards, and runtime-v4 GPIO + // VPP boards like the Raspberry Pi). Runtime targets also render + // stats once connected — the two can coexist (a Pi shows the pin + // table always and the stats panels when connected). const isSim = isSimulatorTarget(currentBoardInfo) const isRuntime = isOpenPLCRuntimeTarget(currentBoardInfo) - const showDivider = !isSim && (isRuntime ? connectionStatus === 'connected' : true) + const showStats = isRuntime && connectionStatus === 'connected' + const showPinMapping = !isSim && pinMappingEnabled + const showDivider = showStats || showPinMapping return showDivider ?
    : null })()} - {isSimulatorTarget(currentBoardInfo) ? null : isOpenPLCRuntimeTarget(currentBoardInfo) ? ( - connectionStatus === 'connected' && ( -
    - {timingStats && } - - -
    - ) - ) : ( + {!isSimulatorTarget(currentBoardInfo) && pinMappingEnabled && (

    @@ -630,6 +630,13 @@ const Board = memo(function () {

    )} + {isOpenPLCRuntimeTarget(currentBoardInfo) && connectionStatus === 'connected' && ( +
    + {timingStats && } + + +
    + )} diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index 676a9b3c4..d5597de1b 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -747,6 +747,12 @@ export interface PackageManifest { } } screens?: Record + /** Optional target capability overrides for this device, merged over + * the preset the editor derives from the target type. A runtime-v4 + * board exposing physical GPIO (e.g. the Raspberry Pi) sets + * `{ pinMapping: true }` to surface the pin-mapping table and feed a + * pins[] array into its plugin config. */ + capabilities?: Partial moduleSystem?: { enabled: boolean maxSlots: number From 9b36823cf89740a4f7ef2833bb29cca46b9cee95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Mon, 1 Jun 2026 18:57:16 +0200 Subject: [PATCH 21/61] feat(package-manager): verify VPP Ed25519 signature before import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject unsigned or tampered packages at the single import trust boundary (both local "Add from file…" and remote install converge on importFromFile). - verify-package-signature.ts (backend/shared): pure verifier — checks alg/keyId, the Ed25519 signature over the canonical payload, and that every on-disk file matches the signed sha256 map exactly (no missing/extra/altered files). Fails closed. 100% test coverage. - trusted-keys.ts: keyId -> embedded public key registry (ready for rotation). - package-manager-module.ts: call the verifier after schema validation and before any path use; REQUIRE_SIGNATURE flag (committed true) gates strict enforcement. Mirrors the signing side in openplc-packages byte-for-byte (canonicalization, hashing, file enumeration). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../package-manager/package-manager-module.ts | 23 ++ .../editor/package-manager/trusted-keys.ts | 20 ++ .../verify-package-signature.test.ts | 234 ++++++++++++++++++ .../utils/vpp/verify-package-signature.ts | 188 ++++++++++++++ 4 files changed, 465 insertions(+) create mode 100644 src/backend/editor/package-manager/trusted-keys.ts create mode 100644 src/backend/shared/utils/vpp/__tests__/verify-package-signature.test.ts create mode 100644 src/backend/shared/utils/vpp/verify-package-signature.ts diff --git a/src/backend/editor/package-manager/package-manager-module.ts b/src/backend/editor/package-manager/package-manager-module.ts index 5d829bff3..32fbce036 100644 --- a/src/backend/editor/package-manager/package-manager-module.ts +++ b/src/backend/editor/package-manager/package-manager-module.ts @@ -5,9 +5,19 @@ import { join } from 'path' import { PackageManifestSchema } from '../../../middleware/shared/ports/package-manifest-schema' import { validatePathId } from '../../shared/utils/path-safety' +import { verifyPackageSignature } from '../../shared/utils/vpp/verify-package-signature' import { assertPathContained } from '../utils/path-containment' +import { TRUSTED_PACKAGE_KEYS } from './trusted-keys' import type { ImportResult, InstalledPackage, PackageManifest, PackageRegistry } from './types' +/** + * Enforce cryptographic signature verification on every import. Strict by + * design. Flip to `false` ONLY for local/offline development with unsigned + * packages — the committed value MUST stay `true`, mirroring the + * `USE_LOCAL_MOCK` convention in the package adapter. + */ +const REQUIRE_SIGNATURE = true + class PackageManagerModule { private packagesDir: string private registryPath: string @@ -55,6 +65,19 @@ class PackageManagerModule { } const manifest: PackageManifest = parsed.data as unknown as PackageManifest + // Cryptographically verify the package BEFORE trusting any of its + // contents. This is the single trust boundary both flows converge on + // (local "Add from file…" and remote install both extract here), so + // one check covers both. It runs after the manifest is structurally + // valid but before any field is used as a path or any HAL/plugin code + // is ever compiled. Fails closed. + if (REQUIRE_SIGNATURE) { + const verification = verifyPackageSignature(tempDir, TRUSTED_PACKAGE_KEYS) + if (!verification.valid) { + return { success: false, error: `Package signature verification failed: ${verification.error}` } + } + } + // Validate package.id BEFORE using it as a path component. Without // this, a malicious .vpp with `"id": "../../something"` would have // `targetDir` resolve outside packagesDir and the rmSync below diff --git a/src/backend/editor/package-manager/trusted-keys.ts b/src/backend/editor/package-manager/trusted-keys.ts new file mode 100644 index 000000000..0d6b00dd6 --- /dev/null +++ b/src/backend/editor/package-manager/trusted-keys.ts @@ -0,0 +1,20 @@ +/** + * Trusted VPP package-signing public keys. + * + * Maps `keyId` -> PEM-encoded Ed25519 public key. A package's + * `signature.json` names the `keyId` it was signed with; the verifier looks + * the key up here. The map shape (rather than a single constant) is what + * makes key rotation possible: publish packages signed with a new keyId, + * ship the editor with BOTH keys trusted, then retire the old one once no + * supported package version still depends on it. + * + * The private counterparts live ONLY in the openplc-packages signing + * pipeline (CI secret) and are never present in this repo. + */ + +export const TRUSTED_PACKAGE_KEYS: Record = { + 'openplc-2026': `-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEABdweEuJAfYG923RkmZLYsmonLvCcgVtgpJ7mngbRJQk= +-----END PUBLIC KEY----- +`, +} diff --git a/src/backend/shared/utils/vpp/__tests__/verify-package-signature.test.ts b/src/backend/shared/utils/vpp/__tests__/verify-package-signature.test.ts new file mode 100644 index 000000000..20412283b --- /dev/null +++ b/src/backend/shared/utils/vpp/__tests__/verify-package-signature.test.ts @@ -0,0 +1,234 @@ +import { generateKeyPairSync, sign as cryptoSign, createHash } from 'node:crypto' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { canonicalize, SIGNATURE_FILENAME, TrustedKeys, verifyPackageSignature } from '../verify-package-signature' + +// Replace node:fs with a spread of the real module so its exports become +// plain, configurable own-properties — Node's native module bindings are +// non-configurable and can't be spied on directly. All methods still call +// through to the genuine implementation; individual tests spy where needed. +jest.mock('node:fs', () => ({ ...jest.requireActual('node:fs') })) + +const KEY_ID = 'test-key' + +const { publicKey, privateKey } = generateKeyPairSync('ed25519') +const PUBLIC_PEM = publicKey.export({ type: 'spki', format: 'pem' }).toString() +const PRIVATE_PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString() +const TRUSTED: TrustedKeys = { [KEY_ID]: PUBLIC_PEM } + +const sha256 = (s: string): string => + createHash('sha256').update(Uint8Array.from(Buffer.from(s, 'utf-8'))).digest('hex') + +/** Files written into every fixture package (relative path -> contents). */ +const DEFAULT_FILES: Record = { + 'manifest.json': '{"formatVersion":"1.0"}', + 'hal/arduino/hal.cpp': 'void hardwareInit() {}', + 'assets/logo.png': 'PNGDATA', +} + +interface BuildOpts { + files?: Record + /** Override fields on the signed payload (applied before signing). */ + payloadOverride?: Record + /** Mutate the on-disk signature.json after signing (e.g. flip a byte). */ + signatureMutate?: (sig: Record) => void + /** Skip writing signature.json entirely. */ + omitSignature?: boolean + /** Write raw (non-signed) content as signature.json. */ + rawSignatureContent?: string +} + +function buildPackage(dir: string, opts: BuildOpts = {}): void { + const files = opts.files ?? DEFAULT_FILES + const fileHashes: Record = {} + for (const [rel, content] of Object.entries(files)) { + const full = join(dir, rel) + mkdirSync(dirname(full), { recursive: true }) + writeFileSync(full, content) + fileHashes[rel] = sha256(content) + } + + if (opts.omitSignature) return + + if (opts.rawSignatureContent !== undefined) { + writeFileSync(join(dir, SIGNATURE_FILENAME), opts.rawSignatureContent) + return + } + + const payload = { + formatVersion: '1.0', + alg: 'ed25519', + keyId: KEY_ID, + packageId: 'com.test.pkg', + version: '1.0.0', + signedAt: '2026-06-01T00:00:00.000Z', + files: fileHashes, + ...opts.payloadOverride, + } + const signature = cryptoSign( + null, + Uint8Array.from(Buffer.from(canonicalize(payload), 'utf-8')), + PRIVATE_PEM, + ).toString('base64') + const sig: Record = { ...payload, signature } + opts.signatureMutate?.(sig) + writeFileSync(join(dir, SIGNATURE_FILENAME), JSON.stringify(sig, null, 2)) +} + +describe('verifyPackageSignature', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'vpp-sig-test-')) + }) + + afterEach(() => { + jest.restoreAllMocks() + rmSync(dir, { recursive: true, force: true }) + }) + + it('accepts a correctly signed package', () => { + buildPackage(dir) + expect(verifyPackageSignature(dir, TRUSTED)).toEqual({ valid: true }) + }) + + it('rejects a package with no signature.json', () => { + buildPackage(dir, { omitSignature: true }) + const result = verifyPackageSignature(dir, TRUSTED) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/not signed/i) + }) + + it('rejects a signature.json that is not valid JSON', () => { + buildPackage(dir, { rawSignatureContent: 'not json at all' }) + expect(verifyPackageSignature(dir, TRUSTED).error).toMatch(/not signed/i) + }) + + it('rejects when the top-level JSON is not an object', () => { + buildPackage(dir, { rawSignatureContent: 'null' }) + expect(verifyPackageSignature(dir, TRUSTED).error).toMatch(/malformed/i) + }) + + it('rejects when the signature field is missing', () => { + buildPackage(dir, { + signatureMutate: (sig) => { + delete sig.signature + }, + }) + expect(verifyPackageSignature(dir, TRUSTED).error).toMatch(/malformed/i) + }) + + it('rejects when a payload field has the wrong type', () => { + buildPackage(dir, { payloadOverride: { version: 123 } }) + expect(verifyPackageSignature(dir, TRUSTED).error).toMatch(/malformed/i) + }) + + it('rejects when files is not an object', () => { + buildPackage(dir, { payloadOverride: { files: 'nope' } }) + expect(verifyPackageSignature(dir, TRUSTED).error).toMatch(/malformed/i) + }) + + it('rejects when a file hash entry is not a string', () => { + buildPackage(dir, { + signatureMutate: (sig) => { + ;(sig.files as Record)['manifest.json'] = 42 + }, + }) + expect(verifyPackageSignature(dir, TRUSTED).error).toMatch(/malformed/i) + }) + + it('rejects an unsupported algorithm', () => { + buildPackage(dir, { payloadOverride: { alg: 'rsa-pss' } }) + expect(verifyPackageSignature(dir, TRUSTED).error).toMatch(/Unsupported signature algorithm/i) + }) + + it('rejects an untrusted keyId', () => { + buildPackage(dir) + const result = verifyPackageSignature(dir, { 'other-key': PUBLIC_PEM }) + expect(result.error).toMatch(/Untrusted signing key/i) + }) + + it('rejects when the public key is malformed (crypto throws)', () => { + buildPackage(dir) + const result = verifyPackageSignature(dir, { [KEY_ID]: 'garbage-not-a-pem' }) + expect(result.error).toMatch(/Signature verification error/i) + }) + + it('rejects a tampered signature that decodes but does not verify', () => { + buildPackage(dir, { + signatureMutate: (sig) => { + const bytes = Buffer.from(sig.signature as string, 'base64') + bytes[0] ^= 0xff + sig.signature = bytes.toString('base64') + }, + }) + expect(verifyPackageSignature(dir, TRUSTED).error).toMatch(/Invalid package signature/i) + }) + + it('rejects when an extra unsigned file is added after signing', () => { + buildPackage(dir) + writeFileSync(join(dir, 'sneaky.txt'), 'injected') + expect(verifyPackageSignature(dir, TRUSTED).error).toMatch(/file count mismatch/i) + }) + + it('rejects when a signed file is removed', () => { + buildPackage(dir) + rmSync(join(dir, 'assets/logo.png')) + expect(verifyPackageSignature(dir, TRUSTED).error).toMatch(/file count mismatch/i) + }) + + it('rejects when a signed file is swapped for an unsigned one (same count)', () => { + buildPackage(dir) + rmSync(join(dir, 'assets/logo.png')) + writeFileSync(join(dir, 'assets/other.png'), 'PNGDATA') + expect(verifyPackageSignature(dir, TRUSTED).error).toMatch(/Unsigned file present/i) + }) + + it('rejects when a signed file is tampered with (same path)', () => { + buildPackage(dir) + writeFileSync(join(dir, 'hal/arduino/hal.cpp'), 'void hardwareInit() { evil(); }') + expect(verifyPackageSignature(dir, TRUSTED).error).toMatch(/Tampered file detected/i) + }) + + it('reports failure when listing package contents throws', () => { + buildPackage(dir) + const fs = jest.requireMock('node:fs') + jest.spyOn(fs, 'readdirSync').mockImplementation(() => { + throw new Error('readdir boom') + }) + expect(verifyPackageSignature(dir, TRUSTED).error).toMatch(/Failed to read package contents/i) + }) + + it('reports failure when hashing a package file throws', () => { + buildPackage(dir) + const fs = jest.requireMock('node:fs') + const realReadFileSync = jest.requireActual('node:fs').readFileSync + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jest.spyOn(fs, 'readFileSync').mockImplementation(((path: any, ...rest: any[]) => { + if (String(path).includes('hal.cpp')) throw new Error('read boom') + return (realReadFileSync as (...a: any[]) => unknown)(path, ...rest) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any) + expect(verifyPackageSignature(dir, TRUSTED).error).toMatch(/Failed to hash package file/i) + }) + +}) + +describe('canonicalize', () => { + it('serializes primitives and null', () => { + expect(canonicalize(null)).toBe('null') + expect(canonicalize(42)).toBe('42') + expect(canonicalize('hi')).toBe('"hi"') + expect(canonicalize(true)).toBe('true') + }) + + it('serializes arrays preserving order', () => { + expect(canonicalize([3, 'a', null])).toBe('[3,"a",null]') + }) + + it('sorts object keys recursively', () => { + expect(canonicalize({ b: 1, a: { d: 2, c: [1, 2] } })).toBe('{"a":{"c":[1,2],"d":2},"b":1}') + }) +}) diff --git a/src/backend/shared/utils/vpp/verify-package-signature.ts b/src/backend/shared/utils/vpp/verify-package-signature.ts new file mode 100644 index 000000000..ccbdbf953 --- /dev/null +++ b/src/backend/shared/utils/vpp/verify-package-signature.ts @@ -0,0 +1,188 @@ +/** + * VPP package signature verification. + * + * Counterpart to the signing side in the openplc-packages repo + * (`scripts/lib/package-signing.ts`). The two MUST agree byte-for-byte on: + * + * 1. File enumeration — every regular file under the extracted package, + * path relative to the root with POSIX separators ('/'), EXCLUDING the + * top-level `signature.json`. + * 2. File hashing — sha256 of the raw bytes, lower-case hex. + * 3. Canonicalization — recursive, key-sorted JSON with no extra + * whitespace. This is the exact byte string Ed25519 signs/verifies. + * + * Verification fails closed: a missing/garbled signature, an unknown key, a + * bad signature, or ANY file mismatch (extra, missing, or altered) rejects + * the package. This runs at the import trust boundary before the package's + * fields are used as paths or its HAL/plugin code is ever compiled. + */ + +import { createHash, verify as cryptoVerify } from 'node:crypto' +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative, sep } from 'node:path' + +export const SIGNATURE_FILENAME = 'signature.json' + +/** keyId -> PEM-encoded Ed25519 public key. */ +export type TrustedKeys = Record + +export interface SignatureVerification { + valid: boolean + error?: string +} + +interface SignaturePayload { + formatVersion: string + alg: string + keyId: string + packageId: string + version: string + signedAt: string + files: Record +} + +/** + * Recursive, key-sorted JSON serialization — must match the signing side + * exactly. Object keys are emitted in lexicographic order at every depth; + * arrays keep their order. + */ +export function canonicalize(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value) + } + if (Array.isArray(value)) { + return `[${value.map((v) => canonicalize(v)).join(',')}]` + } + const record = value as Record + const entries = Object.keys(record) + .sort() + .map((k) => `${JSON.stringify(k)}:${canonicalize(record[k])}`) + return `{${entries.join(',')}}` +} + +/** Collect every regular file under `dir`, as POSIX paths relative to `dir`. */ +function listPackageFiles(dir: string): string[] { + const out: string[] = [] + const walk = (current: string): void => { + for (const entry of readdirSync(current)) { + const full = join(current, entry) + if (statSync(full).isDirectory()) { + walk(full) + } else { + out.push(relative(dir, full).split(sep).join('/')) + } + } + } + walk(dir) + return out +} + +function sha256File(path: string): string { + return createHash('sha256').update(Uint8Array.from(readFileSync(path))).digest('hex') +} + +/** Narrow unknown JSON into a SignaturePayload + detached signature string. */ +function parseSignatureFile(raw: unknown): { payload: SignaturePayload; signature: string } | null { + if (raw === null || typeof raw !== 'object') return null + const obj = raw as Record + const { signature, ...rest } = obj + if (typeof signature !== 'string' || signature.length === 0) return null + if ( + typeof rest.formatVersion !== 'string' || + typeof rest.alg !== 'string' || + typeof rest.keyId !== 'string' || + typeof rest.packageId !== 'string' || + typeof rest.version !== 'string' || + typeof rest.signedAt !== 'string' || + rest.files === null || + typeof rest.files !== 'object' || + Array.isArray(rest.files) + ) { + return null + } + const files = rest.files as Record + for (const hash of Object.values(files)) { + if (typeof hash !== 'string') return null + } + return { payload: rest as unknown as SignaturePayload, signature } +} + +/** + * Verify the Ed25519 signature embedded in `/signature.json` + * against the bytes of every file in the package. + */ +export function verifyPackageSignature(extractedDir: string, trustedKeys: TrustedKeys): SignatureVerification { + const sigPath = join(extractedDir, SIGNATURE_FILENAME) + + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(sigPath, 'utf-8')) + } catch { + return { valid: false, error: 'Package is not signed (missing or unreadable signature.json)' } + } + + const sig = parseSignatureFile(parsed) + if (!sig) { + return { valid: false, error: 'signature.json is malformed' } + } + const { payload, signature } = sig + + if (payload.alg !== 'ed25519') { + return { valid: false, error: `Unsupported signature algorithm: ${payload.alg}` } + } + + const publicKeyPem = trustedKeys[payload.keyId] + if (!publicKeyPem) { + return { valid: false, error: `Untrusted signing key: ${payload.keyId}` } + } + + // 1) Verify the detached signature over the canonical payload. crypto.verify + // can throw on a malformed key/signature — treat any throw as invalid. + let signatureOk: boolean + try { + signatureOk = cryptoVerify( + null, + Uint8Array.from(Buffer.from(canonicalize(payload), 'utf-8')), + publicKeyPem, + Uint8Array.from(Buffer.from(signature, 'base64')), + ) + } catch { + return { valid: false, error: 'Signature verification error' } + } + if (!signatureOk) { + return { valid: false, error: 'Invalid package signature' } + } + + // 2) The signature only proves the `files` map is authentic. Now prove the + // package on disk IS that map: every listed file must exist with the signed + // hash, and no unlisted file may be present (catches injected files). + let actualFiles: string[] + try { + actualFiles = listPackageFiles(extractedDir).filter((f) => f !== SIGNATURE_FILENAME) + } catch { + return { valid: false, error: 'Failed to read package contents' } + } + + const signedPaths = Object.keys(payload.files) + if (actualFiles.length !== signedPaths.length) { + return { valid: false, error: 'Package contents do not match signature (file count mismatch)' } + } + + for (const rel of actualFiles) { + const expected = payload.files[rel] + if (expected === undefined) { + return { valid: false, error: `Unsigned file present in package: ${rel}` } + } + let actual: string + try { + actual = sha256File(join(extractedDir, rel)) + } catch { + return { valid: false, error: `Failed to hash package file: ${rel}` } + } + if (actual !== expected) { + return { valid: false, error: `Tampered file detected: ${rel}` } + } + } + + return { valid: true } +} From 00340405b1a2058a1019468d468ff46b40440e8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Mon, 1 Jun 2026 19:52:44 +0200 Subject: [PATCH 22/61] feat(package-manager): surface import/uninstall failures in a modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Package import and uninstall failures were silently swallowed — a rejected package (now including signature verification failures) closed the popover with no feedback. Show the backend error via the existing debugger-message error modal, mirroring the library manager. Import suppresses the modal when result.canceled is set (user dismissed the file picker). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/package-manager/index.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/frontend/components/_features/[workspace]/editor/package-manager/index.tsx b/src/frontend/components/_features/[workspace]/editor/package-manager/index.tsx index 0d968dd27..8edb88f24 100644 --- a/src/frontend/components/_features/[workspace]/editor/package-manager/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/package-manager/index.tsx @@ -45,6 +45,18 @@ const PackageManagerEditor = () => { if (result.packageId) { setSelectedPackageId(result.packageId) } + } else if (!result.canceled) { + // `canceled` means the user dismissed the file picker — not an error. + // Everything else (bad/missing manifest, failed signature verification, + // extraction failure) carries a message from the main process; surface + // it so a rejected package isn't a silent no-op. + openModal('debugger-message', { + type: 'error', + title: 'Package import failed', + message: result.error ?? 'Unknown error', + buttons: ['OK'], + onResponse: () => {}, + }) } } @@ -65,6 +77,14 @@ const PackageManagerEditor = () => { if (result.success) { setSelectedPackageId(null) await refreshPackages() + } else { + openModal('debugger-message', { + type: 'error', + title: 'Package uninstall failed', + message: result.error ?? 'Unknown error', + buttons: ['OK'], + onResponse: () => {}, + }) } } From 9abc3cd8ada732c71693bd1d5b3fa7993a2fe943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Mon, 1 Jun 2026 21:34:08 +0200 Subject: [PATCH 23/61] style(package-manager): apply prettier to signature verifier Format verify-package-signature.ts and its test to satisfy the shared ci-format check (and keep the backend/shared surface byte-identical with the upcoming openplc-web mirror). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../utils/vpp/__tests__/verify-package-signature.test.ts | 5 +++-- src/backend/shared/utils/vpp/verify-package-signature.ts | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/backend/shared/utils/vpp/__tests__/verify-package-signature.test.ts b/src/backend/shared/utils/vpp/__tests__/verify-package-signature.test.ts index 20412283b..170df4103 100644 --- a/src/backend/shared/utils/vpp/__tests__/verify-package-signature.test.ts +++ b/src/backend/shared/utils/vpp/__tests__/verify-package-signature.test.ts @@ -19,7 +19,9 @@ const PRIVATE_PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString const TRUSTED: TrustedKeys = { [KEY_ID]: PUBLIC_PEM } const sha256 = (s: string): string => - createHash('sha256').update(Uint8Array.from(Buffer.from(s, 'utf-8'))).digest('hex') + createHash('sha256') + .update(Uint8Array.from(Buffer.from(s, 'utf-8'))) + .digest('hex') /** Files written into every fixture package (relative path -> contents). */ const DEFAULT_FILES: Record = { @@ -213,7 +215,6 @@ describe('verifyPackageSignature', () => { }) as any) expect(verifyPackageSignature(dir, TRUSTED).error).toMatch(/Failed to hash package file/i) }) - }) describe('canonicalize', () => { diff --git a/src/backend/shared/utils/vpp/verify-package-signature.ts b/src/backend/shared/utils/vpp/verify-package-signature.ts index ccbdbf953..f5b3a3ab8 100644 --- a/src/backend/shared/utils/vpp/verify-package-signature.ts +++ b/src/backend/shared/utils/vpp/verify-package-signature.ts @@ -78,7 +78,9 @@ function listPackageFiles(dir: string): string[] { } function sha256File(path: string): string { - return createHash('sha256').update(Uint8Array.from(readFileSync(path))).digest('hex') + return createHash('sha256') + .update(Uint8Array.from(readFileSync(path))) + .digest('hex') } /** Narrow unknown JSON into a SignaturePayload + detached signature string. */ From 6adea2ae8a6606293ed521e9dcea382b18e5fee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Mon, 1 Jun 2026 22:45:57 +0200 Subject: [PATCH 24/61] refactor(package-manager): move trusted-keys to the shared surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trusted-keys.ts is a platform-agnostic trust anchor (keyId -> public key). Moving it to backend/shared/utils/vpp puts it on the byte-identical shared surface, so the editor and openplc-web are guaranteed (via ci-sync) to trust the same signing keys. package-manager-module.ts stays editor-only — it is an Electron adapter (app.getPath, local fs, registry) and the web will provide its own under backend/web, reusing the shared verifier + trusted keys. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/backend/editor/package-manager/package-manager-module.ts | 2 +- .../package-manager => shared/utils/vpp}/trusted-keys.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) rename src/backend/{editor/package-manager => shared/utils/vpp}/trusted-keys.ts (78%) diff --git a/src/backend/editor/package-manager/package-manager-module.ts b/src/backend/editor/package-manager/package-manager-module.ts index 32fbce036..0a1cc0b6e 100644 --- a/src/backend/editor/package-manager/package-manager-module.ts +++ b/src/backend/editor/package-manager/package-manager-module.ts @@ -5,9 +5,9 @@ import { join } from 'path' import { PackageManifestSchema } from '../../../middleware/shared/ports/package-manifest-schema' import { validatePathId } from '../../shared/utils/path-safety' +import { TRUSTED_PACKAGE_KEYS } from '../../shared/utils/vpp/trusted-keys' import { verifyPackageSignature } from '../../shared/utils/vpp/verify-package-signature' import { assertPathContained } from '../utils/path-containment' -import { TRUSTED_PACKAGE_KEYS } from './trusted-keys' import type { ImportResult, InstalledPackage, PackageManifest, PackageRegistry } from './types' /** diff --git a/src/backend/editor/package-manager/trusted-keys.ts b/src/backend/shared/utils/vpp/trusted-keys.ts similarity index 78% rename from src/backend/editor/package-manager/trusted-keys.ts rename to src/backend/shared/utils/vpp/trusted-keys.ts index 0d6b00dd6..b3f9dce6a 100644 --- a/src/backend/editor/package-manager/trusted-keys.ts +++ b/src/backend/shared/utils/vpp/trusted-keys.ts @@ -10,6 +10,10 @@ * * The private counterparts live ONLY in the openplc-packages signing * pipeline (CI secret) and are never present in this repo. + * + * This lives in the shared surface so the editor and openplc-web trust the + * exact same keys — the cross-repo sync check keeps them byte-identical, so + * the trust anchor can't silently diverge between platforms. */ export const TRUSTED_PACKAGE_KEYS: Record = { From fbfa5b8a7512d8c82bb879863a914651f4e36809 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 2 Jun 2026 15:52:07 +0200 Subject: [PATCH 25/61] fix(package-manager): reject symlinks/non-regular entries when verifying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit review on PR #829. Major: listPackageFiles used statSync (follows symlinks) while walking UNTRUSTED extracted content — a symlink could make the walk recurse outside the dir or loop forever, or make readFileSync later hash the link target. Switch to lstatSync and reject anything that is not a real directory or regular file (caught by the existing guard -> rejected). Minor (test): type the readFileSync spy against the PathOrFileDescriptor overload instead of any + eslint-disable; add a test covering the rejected non-regular entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../verify-package-signature.test.ts | 21 +++++++++++++------ .../utils/vpp/verify-package-signature.ts | 13 +++++++++--- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/backend/shared/utils/vpp/__tests__/verify-package-signature.test.ts b/src/backend/shared/utils/vpp/__tests__/verify-package-signature.test.ts index 170df4103..9c812a322 100644 --- a/src/backend/shared/utils/vpp/__tests__/verify-package-signature.test.ts +++ b/src/backend/shared/utils/vpp/__tests__/verify-package-signature.test.ts @@ -1,5 +1,5 @@ import { generateKeyPairSync, sign as cryptoSign, createHash } from 'node:crypto' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, type PathOrFileDescriptor, rmSync, type Stats, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -207,14 +207,23 @@ describe('verifyPackageSignature', () => { buildPackage(dir) const fs = jest.requireMock('node:fs') const realReadFileSync = jest.requireActual('node:fs').readFileSync - // eslint-disable-next-line @typescript-eslint/no-explicit-any - jest.spyOn(fs, 'readFileSync').mockImplementation(((path: any, ...rest: any[]) => { + // verifyPackageSignature only ever calls readFileSync(path) (single arg, + // string path → Buffer), so the mock matches that overload exactly. + jest.spyOn(fs, 'readFileSync').mockImplementation((path: PathOrFileDescriptor) => { if (String(path).includes('hal.cpp')) throw new Error('read boom') - return (realReadFileSync as (...a: any[]) => unknown)(path, ...rest) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any) + return realReadFileSync(path) + }) expect(verifyPackageSignature(dir, TRUSTED).error).toMatch(/Failed to hash package file/i) }) + + it('rejects a non-regular entry (symlink / special file)', () => { + buildPackage(dir) + const fs = jest.requireMock('node:fs') + // Simulate a symlink/special file: neither a directory nor a regular file. + const fakeStat = { isDirectory: () => false, isFile: () => false } as unknown as Stats + jest.spyOn(fs, 'lstatSync').mockReturnValue(fakeStat) + expect(verifyPackageSignature(dir, TRUSTED).error).toMatch(/Failed to read package contents/i) + }) }) describe('canonicalize', () => { diff --git a/src/backend/shared/utils/vpp/verify-package-signature.ts b/src/backend/shared/utils/vpp/verify-package-signature.ts index f5b3a3ab8..6f59edd44 100644 --- a/src/backend/shared/utils/vpp/verify-package-signature.ts +++ b/src/backend/shared/utils/vpp/verify-package-signature.ts @@ -18,7 +18,7 @@ */ import { createHash, verify as cryptoVerify } from 'node:crypto' -import { readdirSync, readFileSync, statSync } from 'node:fs' +import { lstatSync, readdirSync, readFileSync } from 'node:fs' import { join, relative, sep } from 'node:path' export const SIGNATURE_FILENAME = 'signature.json' @@ -66,10 +66,17 @@ function listPackageFiles(dir: string): string[] { const walk = (current: string): void => { for (const entry of readdirSync(current)) { const full = join(current, entry) - if (statSync(full).isDirectory()) { + // We're walking UNTRUSTED extracted content. lstatSync does NOT follow + // symlinks, so a symlink can't make the walk recurse outside `dir` (or + // loop forever) nor make `readFileSync` later hash its target. Reject + // anything that isn't a real directory or a regular file. + const stat = lstatSync(full) + if (stat.isDirectory()) { walk(full) - } else { + } else if (stat.isFile()) { out.push(relative(dir, full).split(sep).join('/')) + } else { + throw new Error(`Unsupported package entry (not a regular file): ${relative(dir, full).split(sep).join('/')}`) } } } From a7d47155ae55c708a7ca2e8a9932ab06a0807e22 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 2 Jun 2026 12:50:36 -0400 Subject: [PATCH 26/61] fix(compile): read board HAL via boardInfo.halSourceFile (covers VPP boards) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `compileProgram` builds the firmware skeleton for non-runtime-v4 targets, it needs to drop the board-specific HAL `.cpp` (defines `hardwareInit`, `updateInputBuffers`, `updateOutputBuffers`) at `src/arduino.cpp` so the linker can resolve those symbols. The pre-merge code read the path from `boardEntry.source` — a raw hals.json field — and joined it to `resources/sources/hal/`. Two problems after the BoardInfoResolver merge: 1. The flat `boardEntry` adapted from `BoardBuildInfo` doesn't carry the `source` field — that's a hals-only convention. For VPP-installed Arduino boards, `boardSource` was always `undefined` and the HAL content silently dropped. 2. Even if `source` were present, the path was hardcoded to `resources/sources/hal/`, which is wrong for VPP boards whose HAL files live inside the package directory. `boardInfo.halSourceFile` (already resolved by `BoardInfoResolver` to an absolute path — via `resolveHalSourcePath` for hals entries or `resolvePackageRelativePath` for VPP) covers both catalogs. Read from there. Reproduces: - Install the Arduino VPP, target Arduino Uno, compile. - Pre-fix: linker fails with `undefined reference to hardwareInit` / `updateInputBuffers` / `updateOutputBuffers`. - Post-fix: HAL `.cpp` flows through the firmware skeleton and the Baremetal sketch links cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/compiler/compiler-module.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 2edf3be6d..9538fe4e2 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -2541,22 +2541,22 @@ class CompilerModule { // 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. + // call into. `boardInfo.halSourceFile` is an absolute path + // resolved by `BoardInfoResolver` — works for both legacy + // hals.json entries (HAL lives under `resources/sources/hal/`) + // and VPP-installed boards (HAL lives inside the package + // directory). Read it here so the shared merge step drops + // 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) + if (boardInfo.halSourceFile) { try { - boardHalContent = await readFile(halPath, 'utf-8') + boardHalContent = await readFile(boardInfo.halSourceFile, 'utf-8') } catch (halErr) { _mainProcessPort.postMessage({ logLevel: 'warning', - message: `Could not read board HAL file at ${halPath}: ${getErrorMessage(halErr)}`, + message: `Could not read board HAL file at ${boardInfo.halSourceFile}: ${getErrorMessage(halErr)}`, }) } } From a8c6ad958165768b318338cc297a99fa2029866c Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 2 Jun 2026 13:05:45 -0400 Subject: [PATCH 27/61] fix(compile): unblock Arduino upload + drop the redundant compileOnly checkbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled fixes to the Arduino compile/upload flow. (1) Pipeline was skipping the arduino-cli upload step because of a bogus `deviceContext` gate at the bottom of `runCompilePipeline`. `PlatformDeviceContext` is the `editor-https` / `web-orchestrator` discriminator used by runtime-v3/v4 HTTPS uploads — both runtime branches return earlier in the pipeline, so by the time we reach the Arduino upload it's always `undefined` and the upload silently no-ops with `Arduino board not configured (no device context). Skipping upload.` Arduino uploads consume `communicationPort`, not deviceContext. Drop the gate. (2) The per-board "Compile Only" checkbox on the Board Settings screen is now redundant: the sidebar's three-mode build menu (Build / Build & Upload / Clean Build & Upload) is the single source of truth for `compileOnly`. Remove the dead store-backed pathway end-to-end: - Drop the checkbox UI from board.tsx. - Remove `compileOnly` from `DeviceConfiguration` (port type + both zod schemas + the slice's default). - Remove `setCompileOnly` action + its slice handler. - Remove `compileOnlySelectors` from `use-store-selectors`. - `handleBuild`'s fallback (`?? deviceDefinitions.configuration .compileOnly`) becomes `?? false` so callers that invoke handleBuild without overrides also upload. - Update device-slice tests. The pipeline's `compileOnly` parameter stays as-is — it's still the wire-level flag driven by the sidebar menu, just no longer duplicated in the persisted device-configuration store. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/backend/shared/compile/pipeline.ts | 15 ++++------ .../shared/types/PLC/devices/configuration.ts | 1 - .../editor/device/configuration/board.tsx | 28 ++----------------- .../workspace-activity-bar/default.tsx | 6 +++- src/frontend/hooks/use-store-selectors.ts | 7 +---- .../store/__tests__/device-slice.test.ts | 25 ++--------------- .../store/slices/device/data/types.ts | 1 - src/frontend/store/slices/device/slice.ts | 9 ------ src/frontend/store/slices/device/types.ts | 1 - src/middleware/shared/ports/types.ts | 1 - src/types/PLC/devices/configuration.ts | 1 - 11 files changed, 16 insertions(+), 79 deletions(-) diff --git a/src/backend/shared/compile/pipeline.ts b/src/backend/shared/compile/pipeline.ts index 825e64c14..8367c24a7 100644 --- a/src/backend/shared/compile/pipeline.ts +++ b/src/backend/shared/compile/pipeline.ts @@ -660,16 +660,11 @@ async function runCompilePipelineInner( 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 } - } + // Physical Arduino direct upload. Uses `communicationPort` (the + // user's serial-port pick) — no `deviceContext` involved; that + // shape is for the HTTPS/orchestrator runtime-v4 transports, which + // already returned above. Web's `uploadArduinoBoard` adapter + // no-ops because web doesn't target physical Arduinos directly. emit({ stage: 'upload', message: 'Uploading firmware to Arduino board...', level: 'info' }) const uploadResult = await port.uploadArduinoBoard( { diff --git a/src/backend/shared/types/PLC/devices/configuration.ts b/src/backend/shared/types/PLC/devices/configuration.ts index c9ffc9f9a..f6af2fa04 100644 --- a/src/backend/shared/types/PLC/devices/configuration.ts +++ b/src/backend/shared/types/PLC/devices/configuration.ts @@ -4,7 +4,6 @@ const deviceConfigurationSchema = z.object({ deviceBoard: z.string().default('OpenPLC Simulator'), communicationPort: z.string().default(''), runtimeIpAddress: z.string().optional(), - compileOnly: z.boolean().default(false), vendorScreenData: z.record(z.string(), z.unknown()).optional(), // User picks from VPP `target.platformOptions` (e.g. Nano cpu=atmega328old). // Keyed by option `key`, value is the chosen `values[].id`. The compile and diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx index 83890551e..a517a4dc9 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -2,18 +2,17 @@ import type { TimingStats } from '@root/middleware/shared/ports/types' import { useCapabilities, useDevice, useRuntime } from '@root/middleware/shared/providers/platform-context' import { resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useRef, useState } from 'react' import { MagnifierIcon } from '../../../../../../assets/icons/interface/Magnifier' import { MinusIcon } from '../../../../../../assets/icons/interface/Minus' import { PlusIcon } from '../../../../../../assets/icons/interface/Plus' import { RefreshIcon } from '../../../../../../assets/icons/interface/Refresh' -import { boardSelectors, compileOnlySelectors, pinSelectors } from '../../../../../../hooks/use-store-selectors' +import { boardSelectors, pinSelectors } from '../../../../../../hooks/use-store-selectors' import { useOpenPLCStore } from '../../../../../../store' import type { RuntimeConnection } from '../../../../../../store/slices/device/types' import { cn } from '../../../../../../utils/cn' import { isOpenPLCRuntimeTarget, isSimulatorTarget, validateRuntimeVersion } from '../../../../../../utils/device' -import { Checkbox } from '../../../../../_atoms/checkbox' import { Label } from '../../../../../_atoms/label' import { Select, SelectContent, SelectItem, SelectTrigger } from '../../../../../_atoms/select' import TableActions from '../../../../../_atoms/table-actions' @@ -30,9 +29,6 @@ const Board = memo(function () { const runtime = useRuntime() const { - deviceDefinitions: { - configuration: { compileOnly }, - }, deviceAvailableOptions: { availableBoards }, project: { data: { pous, servers, remoteDevices }, @@ -47,8 +43,6 @@ const Board = memo(function () { const currentSelectedPinTableRow = pinSelectors.useCurrentSelectedPinTableRow() const setCurrentSelectedPinTableRow = pinSelectors.useSelectPinTableRow() - const setCompileOnly = compileOnlySelectors.useSetCompileOnly() - const pins = pinSelectors.usePins() const createNewPin = pinSelectors.useCreateNewPin() const removePin = pinSelectors.useRemovePin() @@ -253,11 +247,6 @@ const Board = memo(function () { ) const handleRowClick = (row: HTMLTableRowElement) => setCurrentSelectedPinTableRow(parseInt(row.id)) - const handleCompileOnly = () => { - setCompileOnly(!memoizedCompileOnly) - } - const memoizedCompileOnly = useMemo(() => compileOnly, [compileOnly]) - const handleConnectToRuntime = useCallback(async () => { if (connectionStatus === 'connected') { // Disconnect - global polling hook will handle resetting failure counter @@ -375,19 +364,6 @@ const Board = memo(function () {

    Board Settings

    - {!isSimulatorTarget(currentBoardInfo) && ( -
    - - -
    - )}
    +
    + {groupedBoards.length === 0 ? ( +
    + No devices match “{deviceSearchTerm}”. +
    + ) : ( + groupedBoards.map(({ vendor, boards }) => ( +
    +
    + {vendor} +
    + {boards.map(({ board, data }) => { + const showVersion = !isSimulatorTarget(data) && data.coreVersion + const formattedBoard = `${board}${showVersion ? ` [${data.coreVersion}]` : ''}` + return ( + + + {formattedBoard} + + + ) + })} +
    + )) + )} + {capabilities.hasPackageManager && ( + <> +
    + + + + Install additional boards... + + + + )} +
    diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index 5c99a42d3..89cc68d43 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -676,6 +676,11 @@ export interface VppModuleDefinition { export interface VppMetadata { packageId: string + /** Human-readable vendor name from the package manifest's + * `package.vendor.name` field. Used by the device-dropdown to + * group boards under their vendor heading (e.g. all boards from + * `com.openplc.arduino` cluster under "Arduino"). */ + vendor: string deviceId: string packagePath: string screens: Record From 0ebae1f80d85fc696040650a0c4a708424ab6803 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 2 Jun 2026 13:36:13 -0400 Subject: [PATCH 29/61] refactor(dropdown): extract DropdownSearchInput atom, use in device dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes the inline "rounded search field inside a dropdown" snippet into a reusable atom (`_atoms/dropdown-search-input`) and uses it in two callers so the device dropdown matches the visual the variable- type picker already had. The previous device dropdown rolled its own search row with a magnifier icon + bottom-border separator. The bottom border clashed with the dropdown's outer `rounded-lg` border, surfacing as a visual notch at the top-left corner. The new component matches the variable-type dropdown exactly: padded sticky header, rounded text field with its own border, `text-xs` placeholder, no icon. Component encapsulates the bits every caller needs: - `sticky top-0` wrapper so the field stays pinned while items scroll, padded so the border doesn't touch the dropdown corner. - `e.stopPropagation()` on keydown so Radix Select/DropdownMenu don't intercept the user's keystroke for their own typeahead. - Space `preventDefault` so the parent Select doesn't toggle closed on a literal space character. Callers: - `_atoms/type-dropdown-selector` (the variable-type picker): the inline search snippet now uses the shared atom. Drops the direct `InputWithRef` import. - `_features/.../device/configuration/board.tsx`: replaces the bespoke magnifier-icon row. SelectContent reverts to a single `overflow-y-auto` outer (the atom's `sticky top-0` handles the pinning) — same pattern the type picker uses. Three more inline copies of the same snippet exist in `_molecules/.../selectable-cell.tsx` files; left untouched here to keep the diff focused. Same atom should slot into each one cleanly when those screens get touched next. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../_atoms/dropdown-search-input/index.tsx | 54 ++++++++ .../_atoms/type-dropdown-selector/index.tsx | 26 ++-- .../editor/device/configuration/board.tsx | 122 ++++++++---------- 3 files changed, 120 insertions(+), 82 deletions(-) create mode 100644 src/frontend/components/_atoms/dropdown-search-input/index.tsx diff --git a/src/frontend/components/_atoms/dropdown-search-input/index.tsx b/src/frontend/components/_atoms/dropdown-search-input/index.tsx new file mode 100644 index 000000000..bbb45b72c --- /dev/null +++ b/src/frontend/components/_atoms/dropdown-search-input/index.tsx @@ -0,0 +1,54 @@ +import { ComponentPropsWithoutRef, forwardRef } from 'react' + +import { cn } from '../../../utils/cn' +import { InputWithRef } from '../input' + +/** + * Rounded text field rendered inside a sticky header strip — the + * search-affordance used at the top of every filtered dropdown in + * the editor (variable-type pickers, device-board dropdown, etc.). + * + * The wrapper is `sticky top-0` so the field stays pinned while the + * dropdown's content scrolls. Padded so the field doesn't touch + * the dropdown's rounded border (which used to surface as a visual + * notch in the corner). + * + * `onKeyDown` defaults to `e.stopPropagation()` so the parent + * dropdown (Radix DropdownMenu / Select) doesn't intercept the + * keystroke for its own typeahead. Callers can pass their own + * handler; we call it after stopping propagation. Space gets + * `preventDefault` to keep parents like Radix Select from toggling + * closed on a literal space. + */ +type DropdownSearchInputProps = Omit, 'type'> & { + /** Optional extra classes for the outer sticky wrapper. The input + * itself takes shape from the component; layout context lives on + * this wrapper. */ + containerClassName?: string +} + +export const DropdownSearchInput = forwardRef( + ({ containerClassName, className, onKeyDown, placeholder = 'Search...', ...rest }, ref) => { + return ( +
    + { + event.stopPropagation() + if (event.key === ' ') event.preventDefault() + onKeyDown?.(event) + }} + {...rest} + /> +
    + ) + }, +) + +DropdownSearchInput.displayName = 'DropdownSearchInput' diff --git a/src/frontend/components/_atoms/type-dropdown-selector/index.tsx b/src/frontend/components/_atoms/type-dropdown-selector/index.tsx index b3e8c93b8..7f75d4e41 100644 --- a/src/frontend/components/_atoms/type-dropdown-selector/index.tsx +++ b/src/frontend/components/_atoms/type-dropdown-selector/index.tsx @@ -3,7 +3,7 @@ import _ from 'lodash' import { useState } from 'react' import { ArrowIcon } from '../../../assets/icons/interface/Arrow' -import { InputWithRef } from '../input' +import { DropdownSearchInput } from '../dropdown-search-input' type TypeDropdownSelectorProps = { value: string @@ -62,21 +62,15 @@ export const TypeDropdownSelector = ({ sideOffset={5} className='box z-50 max-h-[300px] w-[200px] overflow-y-auto rounded-lg bg-white dark:bg-neutral-950' > -
    - - setVariableFilters((prev) => ({ - ...prev, - [scope.definition]: e.target.value, - })) - } - onKeyDown={(e) => e.stopPropagation()} - /> -
    + + setVariableFilters((prev) => ({ + ...prev, + [scope.definition]: e.target.value, + })) + } + /> {filteredValues.length > 0 ? ( filteredValues.map((value) => ( {/* - Search field — pinned at the top so it never scrolls - out with long device lists. Stop key event propagation - so Radix Select's typeahead doesn't intercept what the - user types here. `e.preventDefault` on Space keeps the - Select from toggling closed on a space character. + Search field — `sticky top-0` keeps it pinned while + the list scrolls. Shares the rounded text-field + styling with the variable-type dropdown via the + shared `DropdownSearchInput` atom (which also stops + Radix Select's typeahead from intercepting + keystrokes). */} -
    - - setDeviceSearchTerm(e.target.value)} - onKeyDown={(e) => { - e.stopPropagation() - if (e.key === ' ') e.preventDefault() - }} - placeholder='Search devices…' - aria-label='Search devices' - className='w-full bg-transparent font-caption text-xs text-neutral-950 placeholder:text-neutral-400 focus:outline-none dark:text-white dark:placeholder:text-neutral-500' - /> -
    -
    - {groupedBoards.length === 0 ? ( -
    - No devices match “{deviceSearchTerm}”. -
    - ) : ( - groupedBoards.map(({ vendor, boards }) => ( -
    -
    - {vendor} -
    - {boards.map(({ board, data }) => { - const showVersion = !isSimulatorTarget(data) && data.coreVersion - const formattedBoard = `${board}${showVersion ? ` [${data.coreVersion}]` : ''}` - return ( - - - {formattedBoard} - - - ) - })} + setDeviceSearchTerm(e.target.value)} + aria-label='Search devices' + /> + {groupedBoards.length === 0 ? ( +
    + No devices match “{deviceSearchTerm}”. +
    + ) : ( + groupedBoards.map(({ vendor, boards }) => ( +
    +
    + {vendor}
    - )) - )} - {capabilities.hasPackageManager && ( - <> -
    - - - + Install additional boards... - - - - )} -
    + {boards.map(({ board, data }) => { + const showVersion = !isSimulatorTarget(data) && data.coreVersion + const formattedBoard = `${board}${showVersion ? ` [${data.coreVersion}]` : ''}` + return ( + + + {formattedBoard} + + + ) + })} +
    + )) + )} + {capabilities.hasPackageManager && ( + <> +
    + + + + Install additional boards... + + + + )}
    From 7e3e7e0cc0ec82636a1abea49773f6a6dac922f5 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 2 Jun 2026 13:45:18 -0400 Subject: [PATCH 30/61] fix(device-dropdown): vendor heading + first-keystroke focus loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled bugs in the grouped device dropdown. (1) Selected device hidden under its vendor heading on open. `scrollToSelectedOption` was aligning the checked SelectItem's top edge with the viewport's top edge (`block: 'start'`), which pushed the vendor heading right above it off-screen — the user had to scroll up a few pixels to see the heading they were visually under. Fix: when the checked item sits inside a `data-board-group` container (i.e. a vendor group), scroll that container into view instead. The heading scrolls in alongside the item. Non-grouped selects (e.g. the communication-port picker) keep the per-item scroll behaviour. (2) First-keystroke focus loss in the search input. Radix Select's typeahead intercepted the keystroke and moved focus to the first matching SelectItem. Symptom: typing the first character that doesn't match the currently-selected device kicked focus out of the input AND blanked the trigger's displayed value. Typing the FIRST character that DID match (selected = "Arduino Uno", first keystroke "A") did not — the typeahead's match landed on the already-focused checked item, so no focus jump. Root cause: Radix attaches its typeahead via native `addEventListener`, not React's synthetic-event system. `event.stopPropagation()` on a React event only stops bubbling inside React's tree; the native event keeps bubbling through the DOM and Radix's listener still sees it. Fix: in `DropdownSearchInput`, also call `event.nativeEvent.stopImmediatePropagation()` on keydown. That halts the native DOM bubble before it reaches Radix's handler. Applied in the shared atom so every consumer inherits it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../_atoms/dropdown-search-input/index.tsx | 22 ++++++++++++++----- .../editor/device/configuration/board.tsx | 15 +++++++++++-- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/frontend/components/_atoms/dropdown-search-input/index.tsx b/src/frontend/components/_atoms/dropdown-search-input/index.tsx index bbb45b72c..a766ca983 100644 --- a/src/frontend/components/_atoms/dropdown-search-input/index.tsx +++ b/src/frontend/components/_atoms/dropdown-search-input/index.tsx @@ -13,12 +13,21 @@ import { InputWithRef } from '../input' * the dropdown's rounded border (which used to surface as a visual * notch in the corner). * - * `onKeyDown` defaults to `e.stopPropagation()` so the parent - * dropdown (Radix DropdownMenu / Select) doesn't intercept the - * keystroke for its own typeahead. Callers can pass their own - * handler; we call it after stopping propagation. Space gets - * `preventDefault` to keep parents like Radix Select from toggling - * closed on a literal space. + * `onKeyDown` stops the keystroke from reaching the parent + * dropdown's typeahead. We call BOTH `stopPropagation` (React + * tree) AND `nativeEvent.stopImmediatePropagation()` (native DOM) + * because Radix Select attaches its typeahead listener via native + * `addEventListener`, so React's stopPropagation alone doesn't + * reach it — the keystroke bubbles up the native DOM tree even + * after React's synthetic-event bubbling halts. Symptom of the + * native-bubble bug: typing the first character that doesn't + * match the currently-selected item causes Radix to move focus + * to the first matching SelectItem, kicking focus out of the + * search input and blanking the trigger's displayed value. + * + * Space gets `preventDefault` so the parent Select doesn't toggle + * closed on a literal space character. Callers can pass their + * own handler; we call it after the propagation-stop pair. */ type DropdownSearchInputProps = Omit, 'type'> & { /** Optional extra classes for the outer sticky wrapper. The input @@ -41,6 +50,7 @@ export const DropdownSearchInput = forwardRef { event.stopPropagation() + event.nativeEvent.stopImmediatePropagation() if (event.key === ' ') event.preventDefault() onKeyDown?.(event) }} diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx index 2f76772c6..eba4e1042 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -137,7 +137,18 @@ const Board = memo(function () { if (!selectIsOpen) return const checkedElement = selectRef.current?.querySelector('[data-state="checked"]') - if (checkedElement) { + if (!checkedElement) return + + // When the checked item lives inside a vendor group, scroll the + // whole group container into view so the heading above the item + // stays on screen. Without this, `block: 'start'` aligns the + // item's top edge with the viewport's top, hiding the vendor + // heading the item sits under. Non-grouped selects (e.g. the + // communication-port picker) keep the per-item scroll. + const groupContainer = checkedElement.closest('[data-board-group]') + if (groupContainer) { + groupContainer.scrollIntoView({ block: 'start' }) + } else { checkedElement.scrollIntoView({ block: 'start' }) } } @@ -458,7 +469,7 @@ const Board = memo(function () {
    ) : ( groupedBoards.map(({ vendor, boards }) => ( -
    +
    {vendor}
    From f2247b1782804bdd58ffe3e9a1691960dfa54344 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 2 Jun 2026 13:56:43 -0400 Subject: [PATCH 31/61] fix(select): add disableTypeahead prop, use on device dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Radix Select's typeahead listener lives at the SelectContent level (not at the input), so stopping the keystroke at the input never reached it — the keystroke bubbled past the input's listener and Radix's typeahead grabbed it, moving focus to the first matching SelectItem and kicking it out of the search field. Surfacing the toggle as a prop on the atom rather than fighting it inline: - New `disableTypeahead?: boolean` on `_atoms/select`'s `SelectContent`. When set, the Content's `onKeyDownCapture` swallows printable-character keystrokes via `nativeEvent.stopImmediatePropagation()`. React's capture pass runs during the native event's capture phase BEFORE Radix's typeahead listener on the same element fires, so Radix never sees the event. - Navigation keys (arrows / Enter / Escape / Tab / Home / End / Page Up / Page Down) and modifier combos (Ctrl / Meta / Alt) flow through untouched so keyboard navigation + shortcuts still work. - Character insertion into the focused search input is a keydown default action, not a side-effect of listener propagation, so `stopPropagation` doesn't prevent the typed character from landing in the input. The user's typing flows in unimpeded. `board.tsx`'s device dropdown sets `disableTypeahead` — the search box owns text input now. Other Select consumers default to typeahead-on so existing behaviour stays. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/_atoms/select/index.tsx | 51 +++++++++++++++++++ .../editor/device/configuration/board.tsx | 5 ++ 2 files changed, 56 insertions(+) diff --git a/src/frontend/components/_atoms/select/index.tsx b/src/frontend/components/_atoms/select/index.tsx index 91ddc8ff9..a84f4117f 100644 --- a/src/frontend/components/_atoms/select/index.tsx +++ b/src/frontend/components/_atoms/select/index.tsx @@ -27,7 +27,43 @@ type ISelectContentProps = ComponentPropsWithoutRef + /** + * Disable Radix Select's built-in typeahead. Set this when the + * dropdown renders its own search input — Radix's typeahead + * otherwise steals focus to the first SelectItem whose label + * starts with the typed character, fighting the search field for + * keystrokes. + * + * Implementation: a React `onKeyDownCapture` listener on the + * Content element calls `nativeEvent.stopImmediatePropagation()` + * for single-character keystrokes. React's capture pass runs + * during the native event's capture phase BEFORE Radix's typeahead + * listener attached on the same element fires, and + * `stopImmediatePropagation` halts the native DOM bubble so + * Radix's handler is never reached. Navigation keys (arrows, + * Enter, Escape, Tab, Home/End, Page Up/Down) and modifier + * combinations flow through untouched so keyboard navigation + * still works. Character insertion into the focused input is a + * keydown default action and runs regardless of propagation + * control, so the user's typing still lands in the search field. + */ + disableTypeahead?: boolean } + +const TYPEAHEAD_PASSTHROUGH_KEYS = new Set([ + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'Enter', + 'Escape', + 'Tab', + 'Home', + 'End', + 'PageUp', + 'PageDown', +]) + const SelectContent = forwardRef, ISelectContentProps>( ( { @@ -39,10 +75,24 @@ const SelectContent = forwardRef, ISe side = 'bottom', className, viewportRef, + disableTypeahead = false, + onKeyDownCapture, ...res }, forwardedRef, ) => { + const handleKeyDownCapture: React.KeyboardEventHandler = (event) => { + if (disableTypeahead) { + const isSingleChar = event.key.length === 1 + const isModifierCombo = event.ctrlKey || event.metaKey || event.altKey + const isPassthrough = TYPEAHEAD_PASSTHROUGH_KEYS.has(event.key) + if (isSingleChar && !isModifierCombo && !isPassthrough) { + event.stopPropagation() + event.nativeEvent.stopImmediatePropagation() + } + } + onKeyDownCapture?.(event) + } return ( , ISe position={position} align={align} side={side} + onKeyDownCapture={handleKeyDownCapture} {...res} > diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx index eba4e1042..2d4526ada 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -449,6 +449,11 @@ const Board = memo(function () { align='center' side='bottom' viewportRef={deviceSelectRef} + // Disable Radix's typeahead so it doesn't fight the + // search box below for keystrokes — that fight was + // surfacing as focus loss on the first character + // that didn't match the currently-selected device. + disableTypeahead > {/* Search field — `sticky top-0` keeps it pinned while From eff15d29225b424229a853475dc874d66f5dcf1c Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 2 Jun 2026 14:08:42 -0400 Subject: [PATCH 32/61] debug(dropdown-search): instrument focus + keydown to track the steal Adds three logging surfaces to actually see what's stealing focus from the device-dropdown search field on the user's "second keystroke" symptom (my prior fixes targeted the wrong thing). - `DropdownSearchInput`: logs every keydown with the active element BEFORE and AFTER (via `setTimeout(0)`, past Radix's microtasks + React's commit phase). Also logs focus / blur transitions and where focus came from / went to via `event.relatedTarget`. - `SelectContent.handleKeyDownCapture`: logs whether the typeahead intercept fired and whether it decided to swallow the key. Will confirm or refute whether Radix is even seeing the events. - `board.tsx`: while the device dropdown is open, attaches a document-level `focusin` listener so EVERY focus change in the page is logged, with the element's tag / id / class / role / data-state / innerText snippet. Plain `console.log` rather than the logger service so the entries land in the renderer DevTools without filter setup. All three sites are clearly tagged so easy to grep / strip. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../_atoms/dropdown-search-input/index.tsx | 40 +++++++++++++++++++ .../components/_atoms/select/index.tsx | 9 +++++ .../editor/device/configuration/board.tsx | 23 +++++++++++ 3 files changed, 72 insertions(+) diff --git a/src/frontend/components/_atoms/dropdown-search-input/index.tsx b/src/frontend/components/_atoms/dropdown-search-input/index.tsx index a766ca983..e2f65b2a5 100644 --- a/src/frontend/components/_atoms/dropdown-search-input/index.tsx +++ b/src/frontend/components/_atoms/dropdown-search-input/index.tsx @@ -36,6 +36,17 @@ type DropdownSearchInputProps = Omit { + if (!el) return 'null' + const tag = el.tagName.toLowerCase() + const id = el.id ? `#${el.id}` : '' + const cls = typeof el.className === 'string' && el.className ? `.${el.className.split(' ')[0]}` : '' + const role = el.getAttribute('role') ? `[role=${el.getAttribute('role')}]` : '' + const dataState = el.getAttribute('data-state') ? `[data-state=${el.getAttribute('data-state')}]` : '' + return `${tag}${id}${cls}${role}${dataState}` +} + export const DropdownSearchInput = forwardRef( ({ containerClassName, className, onKeyDown, placeholder = 'Search...', ...rest }, ref) => { return ( @@ -49,10 +60,39 @@ export const DropdownSearchInput = forwardRef { + const inputEl = event.currentTarget + // eslint-disable-next-line no-console + console.log('[dropdown-search][keydown]', { + key: event.key, + inputValueBeforeNativeEdit: inputEl.value, + activeElement: describeEl(document.activeElement), + inputHasFocus: document.activeElement === inputEl, + }) event.stopPropagation() event.nativeEvent.stopImmediatePropagation() if (event.key === ' ') event.preventDefault() onKeyDown?.(event) + // Where does focus land after the browser is done with + // this keystroke? setTimeout(0) defers until after the + // current task — past Radix's microtasks, past React's + // commit phase, past any setState-triggered focus shifts. + setTimeout(() => { + // eslint-disable-next-line no-console + console.log('[dropdown-search][post-keydown @ +0ms]', { + key: event.key, + inputValueNow: inputEl.value, + activeElement: describeEl(document.activeElement), + inputHasFocus: document.activeElement === inputEl, + }) + }, 0) + }} + onFocus={(event) => { + // eslint-disable-next-line no-console + console.log('[dropdown-search][focus]', { from: describeEl(event.relatedTarget as Element | null) }) + }} + onBlur={(event) => { + // eslint-disable-next-line no-console + console.log('[dropdown-search][blur]', { to: describeEl(event.relatedTarget as Element | null) }) }} {...rest} /> diff --git a/src/frontend/components/_atoms/select/index.tsx b/src/frontend/components/_atoms/select/index.tsx index a84f4117f..bbe5e2db5 100644 --- a/src/frontend/components/_atoms/select/index.tsx +++ b/src/frontend/components/_atoms/select/index.tsx @@ -86,6 +86,15 @@ const SelectContent = forwardRef, ISe const isSingleChar = event.key.length === 1 const isModifierCombo = event.ctrlKey || event.metaKey || event.altKey const isPassthrough = TYPEAHEAD_PASSTHROUGH_KEYS.has(event.key) + // eslint-disable-next-line no-console + console.log('[select-content][keydown-capture]', { + key: event.key, + target: (event.target as Element).tagName, + isSingleChar, + isModifierCombo, + isPassthrough, + willSwallow: isSingleChar && !isModifierCombo && !isPassthrough, + }) if (isSingleChar && !isModifierCombo && !isPassthrough) { event.stopPropagation() event.nativeEvent.stopImmediatePropagation() diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx index 2d4526ada..4491021e7 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -181,6 +181,29 @@ const Board = memo(function () { scrollToSelectedOption(deviceSelectRef, deviceSelectIsOpen) }, [deviceSelectIsOpen]) + // DEBUG: log every focusin while the device dropdown is open so we + // can pinpoint what's stealing focus from the search field on the + // user's "second keystroke" symptom. Remove once the root cause + // is found. + useEffect(() => { + if (!deviceSelectIsOpen) return + const handler = (event: FocusEvent) => { + const target = event.target as Element | null + // eslint-disable-next-line no-console + console.log('[document][focusin]', { + tag: target?.tagName, + id: target?.id, + className: + target && typeof target.className === 'string' ? target.className.split(' ').slice(0, 2).join(' ') : '', + role: target?.getAttribute('role'), + dataState: target?.getAttribute('data-state'), + text: target instanceof HTMLElement ? target.innerText?.slice(0, 40) : '', + }) + } + document.addEventListener('focusin', handler) + return () => document.removeEventListener('focusin', handler) + }, [deviceSelectIsOpen]) + useEffect(() => { scrollToSelectedOption(communicationSelectRef, communicationSelectIsOpen) }, [communicationSelectIsOpen]) From c1b65cc1c33eca2798f9b69a0eb224bff12a1ea3 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 2 Jun 2026 14:13:22 -0400 Subject: [PATCH 33/61] fix(device-dropdown): restore input focus after filter unmounts checked item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The actual root cause of the focus drift, surfaced by the diagnostic logs: [dropdown-search][blur] {to: 'div#radix-:r36:...[role=listbox]...'} Typing 'p' in the search input filtered the selected board ("Arduino Mega") out of the visible list. Radix Select tracks the currently-focused SelectItem internally; when that item unmounts, Radix falls back to focusing the SelectContent listbox itself. The search input lost focus AS A SIDE EFFECT of the unmount — not via Radix's typeahead (which `disableTypeahead` correctly stopped, per the capture-log evidence). Fix: in `board.tsx`, a `useLayoutEffect` on `[groupedBoards, deviceSelectIsOpen, deviceSearchTerm]` restores focus to the search input via a ref. Parent layout effects run AFTER children's, so this fires after Radix's focus shift but before the next paint — the user never sees the focus blink. Gated on `deviceSearchTerm.length > 0` so the initial open of the dropdown still lets Radix focus the currently-selected item (the scroll-to-selected effect keys off that focus). `disableTypeahead` stays — it's the correct fix for the OTHER class of focus drift (Radix's typeahead intercepting character keys), even though it wasn't the issue here. The two mechanisms are independent; both can drift focus, both now blocked. Removed all diagnostic console.logs added in eff15d292. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../_atoms/dropdown-search-input/index.tsx | 40 ------------------ .../components/_atoms/select/index.tsx | 9 ---- .../editor/device/configuration/board.tsx | 41 +++++++++---------- 3 files changed, 19 insertions(+), 71 deletions(-) diff --git a/src/frontend/components/_atoms/dropdown-search-input/index.tsx b/src/frontend/components/_atoms/dropdown-search-input/index.tsx index e2f65b2a5..a766ca983 100644 --- a/src/frontend/components/_atoms/dropdown-search-input/index.tsx +++ b/src/frontend/components/_atoms/dropdown-search-input/index.tsx @@ -36,17 +36,6 @@ type DropdownSearchInputProps = Omit { - if (!el) return 'null' - const tag = el.tagName.toLowerCase() - const id = el.id ? `#${el.id}` : '' - const cls = typeof el.className === 'string' && el.className ? `.${el.className.split(' ')[0]}` : '' - const role = el.getAttribute('role') ? `[role=${el.getAttribute('role')}]` : '' - const dataState = el.getAttribute('data-state') ? `[data-state=${el.getAttribute('data-state')}]` : '' - return `${tag}${id}${cls}${role}${dataState}` -} - export const DropdownSearchInput = forwardRef( ({ containerClassName, className, onKeyDown, placeholder = 'Search...', ...rest }, ref) => { return ( @@ -60,39 +49,10 @@ export const DropdownSearchInput = forwardRef { - const inputEl = event.currentTarget - // eslint-disable-next-line no-console - console.log('[dropdown-search][keydown]', { - key: event.key, - inputValueBeforeNativeEdit: inputEl.value, - activeElement: describeEl(document.activeElement), - inputHasFocus: document.activeElement === inputEl, - }) event.stopPropagation() event.nativeEvent.stopImmediatePropagation() if (event.key === ' ') event.preventDefault() onKeyDown?.(event) - // Where does focus land after the browser is done with - // this keystroke? setTimeout(0) defers until after the - // current task — past Radix's microtasks, past React's - // commit phase, past any setState-triggered focus shifts. - setTimeout(() => { - // eslint-disable-next-line no-console - console.log('[dropdown-search][post-keydown @ +0ms]', { - key: event.key, - inputValueNow: inputEl.value, - activeElement: describeEl(document.activeElement), - inputHasFocus: document.activeElement === inputEl, - }) - }, 0) - }} - onFocus={(event) => { - // eslint-disable-next-line no-console - console.log('[dropdown-search][focus]', { from: describeEl(event.relatedTarget as Element | null) }) - }} - onBlur={(event) => { - // eslint-disable-next-line no-console - console.log('[dropdown-search][blur]', { to: describeEl(event.relatedTarget as Element | null) }) }} {...rest} /> diff --git a/src/frontend/components/_atoms/select/index.tsx b/src/frontend/components/_atoms/select/index.tsx index bbe5e2db5..a84f4117f 100644 --- a/src/frontend/components/_atoms/select/index.tsx +++ b/src/frontend/components/_atoms/select/index.tsx @@ -86,15 +86,6 @@ const SelectContent = forwardRef, ISe const isSingleChar = event.key.length === 1 const isModifierCombo = event.ctrlKey || event.metaKey || event.altKey const isPassthrough = TYPEAHEAD_PASSTHROUGH_KEYS.has(event.key) - // eslint-disable-next-line no-console - console.log('[select-content][keydown-capture]', { - key: event.key, - target: (event.target as Element).tagName, - isSingleChar, - isModifierCombo, - isPassthrough, - willSwallow: isSingleChar && !isModifierCombo && !isPassthrough, - }) if (isSingleChar && !isModifierCombo && !isPassthrough) { event.stopPropagation() event.nativeEvent.stopImmediatePropagation() diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx index 4491021e7..4269a3cb6 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -2,7 +2,7 @@ import type { TimingStats } from '@root/middleware/shared/ports/types' import { useCapabilities, useDevice, useRuntime } from '@root/middleware/shared/providers/platform-context' import { resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { MagnifierIcon } from '../../../../../../assets/icons/interface/Magnifier' import { MinusIcon } from '../../../../../../assets/icons/interface/Minus' @@ -84,6 +84,7 @@ const Board = memo(function () { const [deviceSelectIsOpen, setDeviceSelectIsOpen] = useState(false) const deviceSelectRef = useRef(null) const [deviceSearchTerm, setDeviceSearchTerm] = useState('') + const deviceSearchInputRef = useRef(null) /** * Boards grouped by vendor. VPP-installed boards come from @@ -181,28 +182,23 @@ const Board = memo(function () { scrollToSelectedOption(deviceSelectRef, deviceSelectIsOpen) }, [deviceSelectIsOpen]) - // DEBUG: log every focusin while the device dropdown is open so we - // can pinpoint what's stealing focus from the search field on the - // user's "second keystroke" symptom. Remove once the root cause - // is found. - useEffect(() => { - if (!deviceSelectIsOpen) return - const handler = (event: FocusEvent) => { - const target = event.target as Element | null - // eslint-disable-next-line no-console - console.log('[document][focusin]', { - tag: target?.tagName, - id: target?.id, - className: - target && typeof target.className === 'string' ? target.className.split(' ').slice(0, 2).join(' ') : '', - role: target?.getAttribute('role'), - dataState: target?.getAttribute('data-state'), - text: target instanceof HTMLElement ? target.innerText?.slice(0, 40) : '', - }) + // Keep focus on the search input as the user types. Radix Select + // moves focus to the SelectContent listbox whenever the currently- + // focused SelectItem unmounts — which happens every time the + // user's typing filters the selected board out of the visible + // list. Without this, the search input loses focus mid-typing + // and subsequent keystrokes hit the listbox instead. Runs in + // `useLayoutEffect` so the restore happens after Radix's focus + // shift (children's effects fire first) but before the next paint + // (so the user never sees the focus blink to the listbox). Gated + // on `deviceSearchTerm.length > 0` so the very first open of the + // dropdown still lets Radix focus the currently-selected item + // (which the scroll-to-selected effect keys off). + useLayoutEffect(() => { + if (deviceSelectIsOpen && deviceSearchTerm.length > 0) { + deviceSearchInputRef.current?.focus() } - document.addEventListener('focusin', handler) - return () => document.removeEventListener('focusin', handler) - }, [deviceSelectIsOpen]) + }, [groupedBoards, deviceSelectIsOpen, deviceSearchTerm]) useEffect(() => { scrollToSelectedOption(communicationSelectRef, communicationSelectIsOpen) @@ -487,6 +483,7 @@ const Board = memo(function () { keystrokes). */} setDeviceSearchTerm(e.target.value)} aria-label='Search devices' From 243bea3cdbc32c56d103fdc3ab51ae9c03b67d4a Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 2 Jun 2026 14:18:10 -0400 Subject: [PATCH 34/61] debug+fix(device-dropdown): catch Radix's focus-out via native focusout listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous useLayoutEffect-based focus restore didn't work — symptom unchanged. Two possibilities: (a) Radix's focus shift uses useEffect (not useLayoutEffect), so it runs AFTER my useLayoutEffect — my restoration is immediately undone. (b) Radix's focus shift uses a different timing mechanism altogether (raf, MutationObserver, microtask) that doesn't interleave with React's effect schedule. Rather than guess at the timing, this commit attaches a native `focusout` listener directly to the search input. Whenever the input loses focus while the user has actually typed something, the handler queues a microtask that immediately puts focus back. Microtasks run after the current task but before the next paint or any rAF callback, so this beats whatever timing Radix uses. Diagnostic logs restored (`[dropdown-search][keydown]` / `[focus]` / `[blur]`, `[document][focusin]`, plus new `[input][focusout]` and `[input][refocus]` tagging the new listener). If the bug persists, the logs will show the focusout firing → refocus running → some OTHER thing stealing focus again, which will tell us which thing to chase next. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../_atoms/dropdown-search-input/index.tsx | 26 +++++++ .../editor/device/configuration/board.tsx | 69 +++++++++++++++---- 2 files changed, 82 insertions(+), 13 deletions(-) diff --git a/src/frontend/components/_atoms/dropdown-search-input/index.tsx b/src/frontend/components/_atoms/dropdown-search-input/index.tsx index a766ca983..819dfbe03 100644 --- a/src/frontend/components/_atoms/dropdown-search-input/index.tsx +++ b/src/frontend/components/_atoms/dropdown-search-input/index.tsx @@ -36,6 +36,17 @@ type DropdownSearchInputProps = Omit { + if (!el) return 'null' + const tag = el.tagName.toLowerCase() + const id = el.id ? `#${el.id}` : '' + const cls = typeof el.className === 'string' && el.className ? `.${el.className.split(' ')[0]}` : '' + const role = el.getAttribute('role') ? `[role=${el.getAttribute('role')}]` : '' + const dataState = el.getAttribute('data-state') ? `[data-state=${el.getAttribute('data-state')}]` : '' + return `${tag}${id}${cls}${role}${dataState}` +} + export const DropdownSearchInput = forwardRef( ({ containerClassName, className, onKeyDown, placeholder = 'Search...', ...rest }, ref) => { return ( @@ -49,11 +60,26 @@ export const DropdownSearchInput = forwardRef { + const inputEl = event.currentTarget + // eslint-disable-next-line no-console + console.log('[dropdown-search][keydown]', { + key: event.key, + inputValue: inputEl.value, + activeBefore: describeEl(document.activeElement), + }) event.stopPropagation() event.nativeEvent.stopImmediatePropagation() if (event.key === ' ') event.preventDefault() onKeyDown?.(event) }} + onFocus={(event) => { + // eslint-disable-next-line no-console + console.log('[dropdown-search][focus]', { from: describeEl(event.relatedTarget as Element | null) }) + }} + onBlur={(event) => { + // eslint-disable-next-line no-console + console.log('[dropdown-search][blur]', { to: describeEl(event.relatedTarget as Element | null) }) + }} {...rest} />
    diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx index 4269a3cb6..7ec4be62b 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -2,7 +2,7 @@ import type { TimingStats } from '@root/middleware/shared/ports/types' import { useCapabilities, useDevice, useRuntime } from '@root/middleware/shared/providers/platform-context' import { resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { MagnifierIcon } from '../../../../../../assets/icons/interface/Magnifier' import { MinusIcon } from '../../../../../../assets/icons/interface/Minus' @@ -186,19 +186,62 @@ const Board = memo(function () { // moves focus to the SelectContent listbox whenever the currently- // focused SelectItem unmounts — which happens every time the // user's typing filters the selected board out of the visible - // list. Without this, the search input loses focus mid-typing - // and subsequent keystrokes hit the listbox instead. Runs in - // `useLayoutEffect` so the restore happens after Radix's focus - // shift (children's effects fire first) but before the next paint - // (so the user never sees the focus blink to the listbox). Gated - // on `deviceSearchTerm.length > 0` so the very first open of the - // dropdown still lets Radix focus the currently-selected item - // (which the scroll-to-selected effect keys off). - useLayoutEffect(() => { - if (deviceSelectIsOpen && deviceSearchTerm.length > 0) { - deviceSearchInputRef.current?.focus() + // list. A focusout listener on the input lets us synchronously + // catch the blur (no matter what timing Radix uses to trigger + // it) and immediately put focus back. Gated on + // `deviceSearchTerm.length > 0` so the initial open of the + // dropdown still lets Radix focus the currently-selected item. + useEffect(() => { + if (!deviceSelectIsOpen) return + const input = deviceSearchInputRef.current + if (!input) return + const handler = (event: FocusEvent) => { + // eslint-disable-next-line no-console + console.log('[input][focusout]', { + searchTerm: deviceSearchTerm, + relatedTarget: + event.relatedTarget instanceof Element + ? `${event.relatedTarget.tagName}${event.relatedTarget.id ? `#${event.relatedTarget.id}` : ''}[role=${event.relatedTarget.getAttribute('role')}]` + : 'null', + }) + if (deviceSearchTerm.length === 0) return + // Re-focus synchronously in the next microtask. Doing it + // inside the focusout handler directly throws in some + // browsers; queueMicrotask defers to immediately after the + // current task without yielding to paint. + queueMicrotask(() => { + if (deviceSearchInputRef.current) { + // eslint-disable-next-line no-console + console.log('[input][refocus]', { + activeBefore: + document.activeElement instanceof Element + ? `${document.activeElement.tagName}[role=${document.activeElement.getAttribute('role')}]` + : 'null', + }) + deviceSearchInputRef.current.focus() + } + }) } - }, [groupedBoards, deviceSelectIsOpen, deviceSearchTerm]) + input.addEventListener('focusout', handler) + return () => input.removeEventListener('focusout', handler) + }, [deviceSelectIsOpen, deviceSearchTerm]) + + // DEBUG: document-level focusin tracker. + useEffect(() => { + if (!deviceSelectIsOpen) return + const handler = (event: FocusEvent) => { + const target = event.target as Element | null + // eslint-disable-next-line no-console + console.log('[document][focusin]', { + tag: target?.tagName, + id: target?.id, + role: target?.getAttribute('role'), + dataState: target?.getAttribute('data-state'), + }) + } + document.addEventListener('focusin', handler) + return () => document.removeEventListener('focusin', handler) + }, [deviceSelectIsOpen]) useEffect(() => { scrollToSelectedOption(communicationSelectRef, communicationSelectIsOpen) From b1052d844788928150fc317af8a72a127aee6340 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 2 Jun 2026 14:24:37 -0400 Subject: [PATCH 35/61] chore(dropdown-search): strip diagnostic logs + speculative interceptors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup pass after the focusout-based fix landed. Three categories of dead code removed: - Diagnostic logs added to track down the focus drift: `[dropdown-search][keydown]`, `[dropdown-search][focus]`, `[dropdown-search][blur]`, `[select-content][keydown-capture]`, `[input][focusout]`, `[input][refocus]`, `[document][focusin]`. Plus the `describeEl` helper that only existed to format log entries. - `nativeEvent.stopImmediatePropagation()` in `DropdownSearchInput.onKeyDown` — speculation that Radix attached its typeahead via native `addEventListener`. The `[select-content][keydown-capture]` logs from the debug pass disproved that (Radix listens via React). With `disableTypeahead` swallowing the keystroke at SelectContent's capture phase, the input's onKeyDown isn't even reached for character keys. Plain `event.stopPropagation()` is enough defence for non-Select consumers (e.g. DropdownMenu inside `_atoms/type-dropdown-selector`). - `if (event.key === ' ') event.preventDefault()` in the atom — actively broken. `preventDefault` on space prevents the browser from inserting the space character into the focused input. Users searching for "Arduino Mega" couldn't type the space. Removed entirely; with `disableTypeahead` Radix never sees the space either. Docblocks rewritten to drop speculative claims about Radix's internals (the `useLayoutEffect` archaeology and the "Radix uses native listeners" theory) and describe what the code actually does. What stays: - `focusout` listener + `queueMicrotask` refocus in board.tsx — the actual root-cause fix. - `disableTypeahead` prop on SelectContent + its capture-phase swallower — handles the separate-but-related focus-drift mechanism (Radix's typeahead matching character keys). Without it, every keystroke would still trigger Radix's typeahead and cause visible focus flicker even with the focusout-restore in place. - Scroll-to-`data-board-group` in `scrollToSelectedOption` — unrelated fix for the vendor heading visibility on dropdown open. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../_atoms/dropdown-search-input/index.tsx | 56 +++-------------- .../components/_atoms/select/index.tsx | 27 ++++---- .../editor/device/configuration/board.tsx | 62 ++++--------------- 3 files changed, 32 insertions(+), 113 deletions(-) diff --git a/src/frontend/components/_atoms/dropdown-search-input/index.tsx b/src/frontend/components/_atoms/dropdown-search-input/index.tsx index 819dfbe03..ec4224b54 100644 --- a/src/frontend/components/_atoms/dropdown-search-input/index.tsx +++ b/src/frontend/components/_atoms/dropdown-search-input/index.tsx @@ -6,28 +6,16 @@ import { InputWithRef } from '../input' /** * Rounded text field rendered inside a sticky header strip — the * search-affordance used at the top of every filtered dropdown in - * the editor (variable-type pickers, device-board dropdown, etc.). + * the editor (variable-type picker, device-board dropdown, etc.). * - * The wrapper is `sticky top-0` so the field stays pinned while the - * dropdown's content scrolls. Padded so the field doesn't touch - * the dropdown's rounded border (which used to surface as a visual - * notch in the corner). + * The wrapper is `sticky top-0` so the field stays pinned while + * the dropdown's content scrolls. Padded so the field doesn't + * touch the dropdown's rounded border. * - * `onKeyDown` stops the keystroke from reaching the parent - * dropdown's typeahead. We call BOTH `stopPropagation` (React - * tree) AND `nativeEvent.stopImmediatePropagation()` (native DOM) - * because Radix Select attaches its typeahead listener via native - * `addEventListener`, so React's stopPropagation alone doesn't - * reach it — the keystroke bubbles up the native DOM tree even - * after React's synthetic-event bubbling halts. Symptom of the - * native-bubble bug: typing the first character that doesn't - * match the currently-selected item causes Radix to move focus - * to the first matching SelectItem, kicking focus out of the - * search input and blanking the trigger's displayed value. - * - * Space gets `preventDefault` so the parent Select doesn't toggle - * closed on a literal space character. Callers can pass their - * own handler; we call it after the propagation-stop pair. + * `onKeyDown` stops React-tree propagation so parent dropdowns + * (Radix Select / DropdownMenu) don't see the keystroke and + * interpret it as typeahead. Callers can pass their own + * `onKeyDown`; we call it after stopping propagation. */ type DropdownSearchInputProps = Omit, 'type'> & { /** Optional extra classes for the outer sticky wrapper. The input @@ -36,17 +24,6 @@ type DropdownSearchInputProps = Omit { - if (!el) return 'null' - const tag = el.tagName.toLowerCase() - const id = el.id ? `#${el.id}` : '' - const cls = typeof el.className === 'string' && el.className ? `.${el.className.split(' ')[0]}` : '' - const role = el.getAttribute('role') ? `[role=${el.getAttribute('role')}]` : '' - const dataState = el.getAttribute('data-state') ? `[data-state=${el.getAttribute('data-state')}]` : '' - return `${tag}${id}${cls}${role}${dataState}` -} - export const DropdownSearchInput = forwardRef( ({ containerClassName, className, onKeyDown, placeholder = 'Search...', ...rest }, ref) => { return ( @@ -60,26 +37,9 @@ export const DropdownSearchInput = forwardRef { - const inputEl = event.currentTarget - // eslint-disable-next-line no-console - console.log('[dropdown-search][keydown]', { - key: event.key, - inputValue: inputEl.value, - activeBefore: describeEl(document.activeElement), - }) event.stopPropagation() - event.nativeEvent.stopImmediatePropagation() - if (event.key === ' ') event.preventDefault() onKeyDown?.(event) }} - onFocus={(event) => { - // eslint-disable-next-line no-console - console.log('[dropdown-search][focus]', { from: describeEl(event.relatedTarget as Element | null) }) - }} - onBlur={(event) => { - // eslint-disable-next-line no-console - console.log('[dropdown-search][blur]', { to: describeEl(event.relatedTarget as Element | null) }) - }} {...rest} />
    diff --git a/src/frontend/components/_atoms/select/index.tsx b/src/frontend/components/_atoms/select/index.tsx index a84f4117f..87e7f58bf 100644 --- a/src/frontend/components/_atoms/select/index.tsx +++ b/src/frontend/components/_atoms/select/index.tsx @@ -30,22 +30,19 @@ type ISelectContentProps = ComponentPropsWithoutRef 0` so the initial open of the - // dropdown still lets Radix focus the currently-selected item. + // falls back to focusing the SelectContent listbox whenever the + // currently-focused SelectItem unmounts — which happens every + // time the user's typing filters the selected board out of the + // visible list, pulling focus off the search input. Refocus + // through `queueMicrotask` (synchronously refocusing inside a + // focusout handler is disallowed in some browsers; microtasks + // run after the current task but before paint). Gated on a + // non-empty search term so the initial open still lets Radix + // focus the currently-selected item — what the scroll-to- + // selected effect keys off. useEffect(() => { if (!deviceSelectIsOpen) return const input = deviceSearchInputRef.current if (!input) return - const handler = (event: FocusEvent) => { - // eslint-disable-next-line no-console - console.log('[input][focusout]', { - searchTerm: deviceSearchTerm, - relatedTarget: - event.relatedTarget instanceof Element - ? `${event.relatedTarget.tagName}${event.relatedTarget.id ? `#${event.relatedTarget.id}` : ''}[role=${event.relatedTarget.getAttribute('role')}]` - : 'null', - }) + const handler = () => { if (deviceSearchTerm.length === 0) return - // Re-focus synchronously in the next microtask. Doing it - // inside the focusout handler directly throws in some - // browsers; queueMicrotask defers to immediately after the - // current task without yielding to paint. - queueMicrotask(() => { - if (deviceSearchInputRef.current) { - // eslint-disable-next-line no-console - console.log('[input][refocus]', { - activeBefore: - document.activeElement instanceof Element - ? `${document.activeElement.tagName}[role=${document.activeElement.getAttribute('role')}]` - : 'null', - }) - deviceSearchInputRef.current.focus() - } - }) + queueMicrotask(() => deviceSearchInputRef.current?.focus()) } input.addEventListener('focusout', handler) return () => input.removeEventListener('focusout', handler) }, [deviceSelectIsOpen, deviceSearchTerm]) - // DEBUG: document-level focusin tracker. - useEffect(() => { - if (!deviceSelectIsOpen) return - const handler = (event: FocusEvent) => { - const target = event.target as Element | null - // eslint-disable-next-line no-console - console.log('[document][focusin]', { - tag: target?.tagName, - id: target?.id, - role: target?.getAttribute('role'), - dataState: target?.getAttribute('data-state'), - }) - } - document.addEventListener('focusin', handler) - return () => document.removeEventListener('focusin', handler) - }, [deviceSelectIsOpen]) - useEffect(() => { scrollToSelectedOption(communicationSelectRef, communicationSelectIsOpen) }, [communicationSelectIsOpen]) From 7c0d4662f64768428576b473f9192a87990d4577 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 2 Jun 2026 15:48:28 -0400 Subject: [PATCH 36/61] feat(debugger): VPP-owned debug-channel resolution via declarative DebugSpec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor's debugger no longer hard-codes per-target (runtime v3 / v4 / arduino-modbus / simulator) logic. Every catalog entry — both the three built-ins in `hals.json` and every per-device manifest in installed VPP packages — declares a `debug` block; a pure resolver in shared walks it to produce a connection-ready `DebugConnectionConfig`. Schema (`middleware/shared/ports/debug-spec-types.ts`) lives in the ports layer so `BoardInfo` can reference it. Resolver (`backend/shared/hardware/debug-spec.ts`) is pure: 22 tests cover preconditions, channel selection, picker, prompts, `$ref` walking, defaults, coercion, required-fields, and the four shapes the built-in targets ship. `hals.json` now declares specs for: - OpenPLC Simulator → single `simulator` channel - OpenPLC Runtime v3 → `tcp` + `runtimeConnected` precondition - OpenPLC Runtime v4 → `websocket` + runtimeConnected + jwtToken The matching update lands in openplc-packages: every device in all 8 VPP packages gets the appropriate `debug` block (Modbus RTU/TCP for arduino-cli devices; WebSocket for runtime-v4 plugin devices like raspberry-pi and slm-rp4). Same canonical spec — no per-package divergence. Code removed (`default.tsx`): - "Debugging Not Available" stub for non-runtime/non-simulator boards (the user's reported bug — Arduino Mega + Modbus RTU refused to debug). - The `if (isRuntimeTarget) { ... } else { ... }` cascade, replaced by a single resolver call. - The `isOpenPLCRuntimeV4Target` import (no longer needed — the resolver discriminates by channel type). - The bare `connectAndStart()` no-config call in the simulator's auto-debug path, replaced with an explicit `{ connectionType: 'simulator', connectionParams: {} }`. Restored `showDebuggerIpInput` helper (modal still exists in slice) to back the spec's `prompts` block — used for the DHCP-IP case on Modbus TCP. Renderer-local prompt cache keyed by board so a remembered IP doesn't leak across devices. `Renderer-local prompt cache for the DHCP-IP-style flows. Keyed by `||` (or `builtin||` for hals.json entries) so two boards sharing a `cacheKey` value don't see each other's last-entered IP. Lives on a ref so it survives across re-renders without triggering them.` Pipeline upload-test updated to match the `deviceContext`-no-longer-gates-Arduino-upload fix that already landed earlier on this branch. device-types test stripped the last `compileOnly` leftovers from the earlier-removed checkbox. Verification: - npx tsc --noEmit: clean - npx jest: 4672 pass / 3 skip / 2 pre-existing dev failures - npm run validate:arch: passes (DebugSpec types live in ports layer so `BoardInfo` referencing them stays within the permitted port→port boundary) Companion update lands in openplc-packages. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/hardware/hardware-module.ts | 2 + src/backend/editor/hardware/types.ts | 12 + .../shared/compile/__tests__/pipeline.test.ts | 17 +- src/backend/shared/firmware/hals.json | 39 ++ .../hardware/__tests__/debug-spec.test.ts | 423 ++++++++++++++++++ .../shared/hardware/board-info-resolver.ts | 14 + src/backend/shared/hardware/debug-spec.ts | 281 ++++++++++++ .../workspace-activity-bar/default.tsx | 185 ++++++-- .../store/__tests__/device-types.test.ts | 4 +- .../shared/ports/debug-spec-types.ts | 101 +++++ src/middleware/shared/ports/types.ts | 13 + 11 files changed, 1040 insertions(+), 51 deletions(-) create mode 100644 src/backend/shared/hardware/__tests__/debug-spec.test.ts create mode 100644 src/backend/shared/hardware/debug-spec.ts create mode 100644 src/middleware/shared/ports/debug-spec-types.ts diff --git a/src/backend/editor/hardware/hardware-module.ts b/src/backend/editor/hardware/hardware-module.ts index 819aee80b..49d5508dc 100644 --- a/src/backend/editor/hardware/hardware-module.ts +++ b/src/backend/editor/hardware/hardware-module.ts @@ -216,6 +216,7 @@ class HardwareModule { .map((pin) => pin.trim()) .filter(Boolean) ?? [], }, + ...(boardData.debug ? { debug: boardData.debug } : {}), }) }) } @@ -326,6 +327,7 @@ class HardwareModule { } : null, }, + ...(device.debug ? { debug: device.debug } : {}), }) } } diff --git a/src/backend/editor/hardware/types.ts b/src/backend/editor/hardware/types.ts index 1dca4ad21..ba9d0f7b3 100644 --- a/src/backend/editor/hardware/types.ts +++ b/src/backend/editor/hardware/types.ts @@ -1,5 +1,6 @@ import { z } from 'zod/v4' +import type { DebugSpec } from '../../../middleware/shared/ports/debug-spec-types' import type { PlatformOption, TargetCapabilities } from '../../../middleware/shared/ports/types' const SerialPortSchema = z.object({ @@ -47,6 +48,12 @@ const BoardInfoSchema = z.object({ // SoC RAM (e.g. emulated boards). max_data_size: z.number().optional(), arch: z.string().optional(), + // Declarative debug-channel resolver spec. Schema validation is + // intentionally loose (`z.any()`) — the canonical shape lives in + // `backend/shared/hardware/debug-spec.ts` as a TS interface, and + // the resolver does its own structural checks at runtime. Zod here + // just guards against shape drift in `hals.json`. + debug: z.any().optional(), // Tracking metadata — not present in shipped hals.json today; optional // so downstream entries that do carry them still validate. updatedAt: z.number().optional(), @@ -139,6 +146,11 @@ type AvailableBoards = Map< * setting `pinMapping: true`). Merged over the preset by * `resolveTargetCapabilities`. */ capabilities?: Partial + /** Declarative debug-channel resolver spec carried through to the + * renderer. Same shape on both catalogs (`hals.json` builtins + * and VPP manifest devices) — see + * `backend/shared/hardware/debug-spec.ts`. */ + debug?: DebugSpec } > diff --git a/src/backend/shared/compile/__tests__/pipeline.test.ts b/src/backend/shared/compile/__tests__/pipeline.test.ts index 32bbffd3b..e7965fa57 100644 --- a/src/backend/shared/compile/__tests__/pipeline.test.ts +++ b/src/backend/shared/compile/__tests__/pipeline.test.ts @@ -303,14 +303,21 @@ describe('runCompilePipeline — arduino direct path', () => { expect(port.uploadArduinoBoard).toHaveBeenCalledTimes(1) }) - it('skips the upload step (success with warning) when deviceContext is absent', async () => { + it('runs the upload step on the arduino-cli path without a deviceContext (serial port comes from communicationPort)', async () => { + // `deviceContext` is the editor-https / web-orchestrator + // discriminator used by the runtime-v3/v4 transports — by the + // time the pipeline reaches the Arduino-cli upload step, those + // runtime branches have already returned, so deviceContext is + // always undefined here. Gating Arduino uploads on it was the + // bug that surfaced as "uploads silently skipped" after the + // VPP migration; the serial port for arduino-cli uploads comes + // from `communicationPort`, not deviceContext. const port = makePort() - const { events, emit } = captureEvents() + const { 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) + expect(result.uploaded).toBe(true) + expect(port.uploadArduinoBoard).toHaveBeenCalledTimes(1) }) it('returns success=false when uploadArduinoBoard reports failure', async () => { diff --git a/src/backend/shared/firmware/hals.json b/src/backend/shared/firmware/hals.json index 2fb76bb4d..03272d7cd 100644 --- a/src/backend/shared/firmware/hals.json +++ b/src/backend/shared/firmware/hals.json @@ -39,6 +39,9 @@ "hasRuntimeStats": false, "isInProcessSimulator": true, "directUsbUpload": true + }, + "debug": { + "channels": [{ "label": "Simulator", "channel": "simulator", "enabledWhen": true, "params": {} }] } }, "OpenPLC Runtime v3": { @@ -59,6 +62,22 @@ "hasRuntimeStats": false, "isInProcessSimulator": false, "directUsbUpload": false + }, + "debug": { + "preconditions": ["runtimeConnected"], + "channels": [ + { + "label": "Modbus TCP", + "channel": "tcp", + "enabledWhen": true, + "params": { + "ipAddress": { + "$ref": "configuration.runtimeIpAddress", + "required": "Runtime IP address is not configured." + } + } + } + ] } }, "OpenPLC Runtime v4": { @@ -79,6 +98,26 @@ "hasRuntimeStats": true, "isInProcessSimulator": false, "directUsbUpload": false + }, + "debug": { + "preconditions": ["runtimeConnected", "jwtToken"], + "channels": [ + { + "label": "WebSocket", + "channel": "websocket", + "enabledWhen": true, + "params": { + "ipAddress": { + "$ref": "configuration.runtimeIpAddress", + "required": "Runtime IP address is not configured." + }, + "jwtToken": { + "$ref": "runtimeConnection.jwtToken", + "required": "JWT token missing. Reconnect to the runtime." + } + } + } + ] } } } diff --git a/src/backend/shared/hardware/__tests__/debug-spec.test.ts b/src/backend/shared/hardware/__tests__/debug-spec.test.ts new file mode 100644 index 000000000..70a4d83c8 --- /dev/null +++ b/src/backend/shared/hardware/__tests__/debug-spec.test.ts @@ -0,0 +1,423 @@ +import type { DebugResolverCapabilities, DebugResolverContext, DebugResolverState, DebugSpec } from '../debug-spec' +import { resolveDebugConnection } from '../debug-spec' + +function makeContext( + overrides: { + state?: Partial + capabilities?: Partial + } = {}, +): DebugResolverContext { + return { + state: { + configuration: { deviceBoard: 'Arduino Mega' }, + screens: {}, + runtimeConnection: {}, + ...(overrides.state ?? {}), + }, + capabilities: { + runtimeConnected: false, + jwtToken: false, + ...(overrides.capabilities ?? {}), + }, + } +} + +describe('resolveDebugConnection', () => { + describe('absent spec', () => { + it('returns `unsupported` when the device has no debug block', () => { + expect(resolveDebugConnection(undefined, makeContext())).toEqual({ kind: 'unsupported' }) + }) + }) + + describe('preconditions', () => { + const spec: DebugSpec = { + preconditions: ['runtimeConnected'], + channels: [{ label: 'WS', channel: 'websocket', enabledWhen: true, params: {} }], + } + + it('errors with "Connection Required" when runtimeConnected is false', () => { + const result = resolveDebugConnection(spec, makeContext({ capabilities: { runtimeConnected: false } })) + expect(result.kind).toBe('error') + if (result.kind === 'error') { + expect(result.title).toBe('Connection Required') + } + }) + + it('resolves the channel when runtimeConnected is true', () => { + const result = resolveDebugConnection(spec, makeContext({ capabilities: { runtimeConnected: true } })) + expect(result.kind).toBe('config') + }) + + it('errors with "Authentication Required" when jwtToken precondition fails', () => { + const v4Spec: DebugSpec = { + preconditions: ['runtimeConnected', 'jwtToken'], + channels: [{ label: 'WS', channel: 'websocket', enabledWhen: true, params: {} }], + } + const result = resolveDebugConnection( + v4Spec, + makeContext({ capabilities: { runtimeConnected: true, jwtToken: false } }), + ) + expect(result.kind).toBe('error') + if (result.kind === 'error') { + expect(result.title).toBe('Authentication Required') + } + }) + }) + + describe('channel selection', () => { + it('errors with `noneEnabled` message when no channel matches', () => { + const spec: DebugSpec = { + channels: [ + { label: 'RTU', channel: 'rtu', enabledWhen: { $ref: 'screens.modbus_rtu.enabled' }, params: {} }, + { label: 'TCP', channel: 'tcp', enabledWhen: { $ref: 'screens.modbus_tcp.enabled' }, params: {} }, + ], + messages: { noneEnabled: { title: 'Modbus Required', body: 'Enable RTU or TCP.' } }, + } + const result = resolveDebugConnection(spec, makeContext()) + expect(result).toEqual({ kind: 'error', title: 'Modbus Required', body: 'Enable RTU or TCP.' }) + }) + + it('returns `pick` when multiple channels match', () => { + const spec: DebugSpec = { + channels: [ + { label: 'RTU', channel: 'rtu', enabledWhen: { $ref: 'screens.modbus_rtu.enabled' }, params: {} }, + { label: 'TCP', channel: 'tcp', enabledWhen: { $ref: 'screens.modbus_tcp.enabled' }, params: {} }, + ], + messages: { pickProtocol: { title: 'Pick', body: 'Pick one.' } }, + } + const result = resolveDebugConnection( + spec, + makeContext({ + state: { screens: { modbus_rtu: { enabled: true }, modbus_tcp: { enabled: true } } }, + }), + ) + expect(result.kind).toBe('pick') + if (result.kind === 'pick') { + expect(result.channels).toEqual([ + { index: 0, label: 'RTU' }, + { index: 1, label: 'TCP' }, + ]) + expect(result.title).toBe('Pick') + } + }) + + it('auto-resolves when exactly one channel matches', () => { + const spec: DebugSpec = { + channels: [ + { label: 'RTU', channel: 'rtu', enabledWhen: { $ref: 'screens.modbus_rtu.enabled' }, params: {} }, + { label: 'TCP', channel: 'tcp', enabledWhen: { $ref: 'screens.modbus_tcp.enabled' }, params: {} }, + ], + } + const result = resolveDebugConnection( + spec, + makeContext({ state: { screens: { modbus_rtu: { enabled: true } } } }), + ) + expect(result.kind).toBe('config') + if (result.kind === 'config') { + expect(result.config.connectionType).toBe('rtu') + expect(result.channelLabel).toBe('RTU') + } + }) + + it('honors `selectedChannelIndex` to force a specific channel', () => { + const spec: DebugSpec = { + channels: [ + { label: 'RTU', channel: 'rtu', enabledWhen: true, params: {} }, + { label: 'TCP', channel: 'tcp', enabledWhen: true, params: {} }, + ], + } + const result = resolveDebugConnection(spec, makeContext(), 1) + expect(result.kind).toBe('config') + if (result.kind === 'config') { + expect(result.config.connectionType).toBe('tcp') + } + }) + + it('returns `error` for an out-of-range channel index', () => { + const spec: DebugSpec = { + channels: [{ label: 'RTU', channel: 'rtu', enabledWhen: true, params: {} }], + } + const result = resolveDebugConnection(spec, makeContext(), 5) + expect(result.kind).toBe('error') + }) + }) + + describe('params resolution', () => { + it('walks $ref into nested screen state', () => { + const spec: DebugSpec = { + channels: [ + { + label: 'RTU', + channel: 'rtu', + enabledWhen: true, + params: { + port: { $ref: 'configuration.communicationPort' }, + baudRate: { $ref: 'screens.modbus_rtu.rtu_baud_rate' }, + }, + }, + ], + } + const result = resolveDebugConnection( + spec, + makeContext({ + state: { + configuration: { deviceBoard: 'Arduino Mega', communicationPort: '/dev/cu.usb' }, + screens: { modbus_rtu: { rtu_baud_rate: '115200' } }, + }, + }), + ) + expect(result.kind).toBe('config') + if (result.kind === 'config') { + expect(result.config.connectionParams).toEqual({ port: '/dev/cu.usb', baudRate: '115200' }) + } + }) + + it('applies `default` when the ref resolves to undefined', () => { + const spec: DebugSpec = { + channels: [ + { + label: 'RTU', + channel: 'rtu', + enabledWhen: true, + params: { baudRate: { $ref: 'screens.modbus_rtu.rtu_baud_rate', default: '115200' } }, + }, + ], + } + const result = resolveDebugConnection(spec, makeContext()) + expect(result.kind).toBe('config') + if (result.kind === 'config') { + expect(result.config.connectionParams.baudRate).toBe('115200') + } + }) + + it('coerces strings to numbers via `as: number`', () => { + const spec: DebugSpec = { + channels: [ + { + label: 'RTU', + channel: 'rtu', + enabledWhen: true, + params: { + baudRate: { $ref: 'screens.modbus_rtu.rtu_baud_rate', as: 'number' }, + slaveId: { $ref: 'screens.modbus_rtu.rtu_slave_id', as: 'number' }, + }, + }, + ], + } + const result = resolveDebugConnection( + spec, + makeContext({ + state: { screens: { modbus_rtu: { rtu_baud_rate: '57600', rtu_slave_id: 7 } } }, + }), + ) + expect(result.kind).toBe('config') + if (result.kind === 'config') { + expect(result.config.connectionParams.baudRate).toBe(57600) + expect(result.config.connectionParams.slaveId).toBe(7) + } + }) + + it('drops params whose ref resolves to undefined with no default', () => { + const spec: DebugSpec = { + channels: [ + { + label: 'TCP', + channel: 'tcp', + enabledWhen: true, + params: { ipAddress: { $ref: 'screens.modbus_tcp.ip_address' } }, + }, + ], + } + const result = resolveDebugConnection(spec, makeContext()) + expect(result.kind).toBe('config') + if (result.kind === 'config') { + expect(result.config.connectionParams).toEqual({}) + } + }) + + it('errors with the `required` message when a required ref is missing', () => { + const spec: DebugSpec = { + channels: [ + { + label: 'RTU', + channel: 'rtu', + enabledWhen: true, + params: { port: { $ref: 'configuration.communicationPort', required: 'No serial port selected.' } }, + }, + ], + } + const result = resolveDebugConnection(spec, makeContext()) + expect(result).toEqual({ kind: 'error', title: 'Configuration Error', body: 'No serial port selected.' }) + }) + + it('forwards literal param values verbatim', () => { + const spec: DebugSpec = { + channels: [{ label: 'S', channel: 'simulator', enabledWhen: true, params: { someFlag: true, count: 42 } }], + } + const result = resolveDebugConnection(spec, makeContext()) + expect(result.kind).toBe('config') + if (result.kind === 'config') { + expect(result.config.connectionParams).toEqual({ someFlag: true, count: 42 }) + } + }) + }) + + describe('prompts', () => { + const tcpSpec: DebugSpec = { + channels: [ + { + label: 'TCP', + channel: 'tcp', + enabledWhen: true, + params: { ipAddress: { $ref: 'screens.modbus_tcp.ip_address' } }, + prompts: [ + { + when: { $ref: 'screens.modbus_tcp.enable_dhcp' }, + field: 'ipAddress', + title: 'Target IP', + message: 'Enter the device IP.', + cacheKey: 'lastDhcpIp', + }, + ], + }, + ], + } + + it('surfaces a prompt when its `when` matches and cache is empty', () => { + const result = resolveDebugConnection( + tcpSpec, + makeContext({ state: { screens: { modbus_tcp: { enable_dhcp: true } } } }), + ) + expect(result.kind).toBe('prompt') + if (result.kind === 'prompt') { + expect(result.fields).toEqual([ + { field: 'ipAddress', title: 'Target IP', message: 'Enter the device IP.', cacheKey: 'lastDhcpIp' }, + ]) + expect(result.channelIndex).toBe(0) + } + }) + + it('skips a prompt when the cache has its value', () => { + const result = resolveDebugConnection( + tcpSpec, + makeContext({ + state: { + screens: { modbus_tcp: { enable_dhcp: true } }, + promptCache: { lastDhcpIp: '192.168.1.50' }, + }, + }), + ) + expect(result.kind).toBe('config') + if (result.kind === 'config') { + expect(result.config.connectionParams.ipAddress).toBe('192.168.1.50') + } + }) + + it('skips a prompt entirely when its `when` is false', () => { + const result = resolveDebugConnection( + tcpSpec, + makeContext({ + state: { screens: { modbus_tcp: { enable_dhcp: false, ip_address: '10.0.0.5' } } }, + }), + ) + expect(result.kind).toBe('config') + if (result.kind === 'config') { + expect(result.config.connectionParams.ipAddress).toBe('10.0.0.5') + } + }) + + it('runs unconditional prompts (no `when`) on every resolution until cached', () => { + const spec: DebugSpec = { + channels: [ + { + label: 'TCP', + channel: 'tcp', + enabledWhen: true, + params: {}, + prompts: [{ field: 'ipAddress', title: 'IP', message: 'Enter IP.', cacheKey: 'ip' }], + }, + ], + } + const first = resolveDebugConnection(spec, makeContext()) + expect(first.kind).toBe('prompt') + + const second = resolveDebugConnection(spec, makeContext({ state: { promptCache: { ip: '1.2.3.4' } } })) + expect(second.kind).toBe('config') + if (second.kind === 'config') { + expect(second.config.connectionParams.ipAddress).toBe('1.2.3.4') + } + }) + }) + + describe('built-in target shapes', () => { + it('Simulator: always-on simulator channel', () => { + const spec: DebugSpec = { + channels: [{ label: 'Simulator', channel: 'simulator', enabledWhen: true, params: {} }], + } + const result = resolveDebugConnection(spec, makeContext()) + expect(result.kind).toBe('config') + if (result.kind === 'config') { + expect(result.config.connectionType).toBe('simulator') + } + }) + + it('Runtime v3: tcp + runtimeConnected precondition', () => { + const spec: DebugSpec = { + preconditions: ['runtimeConnected'], + channels: [ + { + label: 'TCP', + channel: 'tcp', + enabledWhen: true, + params: { ipAddress: { $ref: 'configuration.runtimeIpAddress' } }, + }, + ], + } + const result = resolveDebugConnection( + spec, + makeContext({ + state: { configuration: { deviceBoard: 'OpenPLC Runtime v3', runtimeIpAddress: '10.0.0.10' } }, + capabilities: { runtimeConnected: true }, + }), + ) + expect(result.kind).toBe('config') + if (result.kind === 'config') { + expect(result.config).toEqual({ connectionType: 'tcp', connectionParams: { ipAddress: '10.0.0.10' } }) + } + }) + + it('Runtime v4: websocket + both preconditions + jwt from runtimeConnection', () => { + const spec: DebugSpec = { + preconditions: ['runtimeConnected', 'jwtToken'], + channels: [ + { + label: 'WebSocket', + channel: 'websocket', + enabledWhen: true, + params: { + ipAddress: { $ref: 'configuration.runtimeIpAddress' }, + jwtToken: { $ref: 'runtimeConnection.jwtToken' }, + }, + }, + ], + } + const result = resolveDebugConnection( + spec, + makeContext({ + state: { + configuration: { deviceBoard: 'OpenPLC Runtime v4', runtimeIpAddress: '10.0.0.20' }, + runtimeConnection: { jwtToken: 'abc.def.ghi' }, + }, + capabilities: { runtimeConnected: true, jwtToken: true }, + }), + ) + expect(result.kind).toBe('config') + if (result.kind === 'config') { + expect(result.config).toEqual({ + connectionType: 'websocket', + connectionParams: { ipAddress: '10.0.0.20', jwtToken: 'abc.def.ghi' }, + }) + } + }) + }) +}) diff --git a/src/backend/shared/hardware/board-info-resolver.ts b/src/backend/shared/hardware/board-info-resolver.ts index d7aad2cca..0c718bd00 100644 --- a/src/backend/shared/hardware/board-info-resolver.ts +++ b/src/backend/shared/hardware/board-info-resolver.ts @@ -23,6 +23,7 @@ * conflict surface disappears. */ +import type { DebugSpec } from '../../../middleware/shared/ports/debug-spec-types' import type { InstalledPackage, PackageManifest, PlatformOption } from '../../../middleware/shared/ports/types' // --------------------------------------------------------------------------- @@ -51,6 +52,10 @@ export interface HalsBoardEntry { define?: string | string[] extra_libraries?: string[] max_data_size?: number + /** Declarative debug-channel resolver spec. See `debug-spec.ts` + * for the schema. Boards without a spec fall back to the + * "Debugging Not Available" outcome on the renderer side. */ + debug?: DebugSpec } /** @@ -167,6 +172,13 @@ export interface BoardBuildInfo { vppPackageId?: string vppDeviceId?: string vppPackagePath?: string + + // Debug-channel resolver spec -------------------------------------------- + /** Declarative debug spec consumed by `resolveDebugConnection`. + * Carries through from both `hals.json` entries and VPP manifest + * device entries. Undefined when the board didn't declare one; + * callers can fall back to "Debugging Not Available". */ + debug?: DebugSpec } // --------------------------------------------------------------------------- @@ -219,6 +231,7 @@ export class BoardInfoResolver { if (entry.define) info.define = entry.define if (entry.extra_libraries) info.extraArduinoLibraries = entry.extra_libraries if (entry.max_data_size !== undefined) info.maxDataSize = entry.max_data_size + if (entry.debug) info.debug = entry.debug return info } @@ -273,6 +286,7 @@ export class BoardInfoResolver { if (device.hal.pluginType === 'python' || device.hal.pluginType === 'native') { info.pluginType = device.hal.pluginType } + if (device.debug) info.debug = device.debug return info } diff --git a/src/backend/shared/hardware/debug-spec.ts b/src/backend/shared/hardware/debug-spec.ts new file mode 100644 index 000000000..fabd68fab --- /dev/null +++ b/src/backend/shared/hardware/debug-spec.ts @@ -0,0 +1,281 @@ +/** + * Debug-channel resolver — pure function that evaluates a + * declarative `DebugSpec` (defined in + * `middleware/shared/ports/debug-spec-types.ts`) against the + * platform-supplied state and capabilities, returning either a + * connection-ready `DebugConnectionConfig` or instructions for the + * caller to surface a picker / prompt / error dialog. + * + * The spec types live in the ports layer so `BoardInfo` (which the + * device store carries) can reference them without crossing the + * architecture's port → backend boundary. This file is the only + * place the spec is interpreted; everything downstream just + * consumes the returned `DebugConnectionConfig`. + * + * Pure: no fs I/O, no globals, no DOM. Same inputs always produce + * the same outcome. Caller (renderer) is responsible for surfacing + * dialogs and re-invoking after picker / prompt resolution. + */ + +import type { DebugCondition, DebugParam, DebugRef, DebugSpec } from '../../../middleware/shared/ports/debug-spec-types' +import type { DebugConnectionConfig } from '../../../middleware/shared/ports/types' + +// Re-export types so importers have one canonical entry point. The +// types themselves live in the ports layer (architecture rule); the +// re-export keeps callsite imports tidy. +export type { + DebugChannelSpec, + DebugCondition, + DebugParam, + DebugPrecondition, + DebugPrompt, + DebugRef, + DebugSpec, + DebugSpecMessages, +} from '../../../middleware/shared/ports/debug-spec-types' + +// --------------------------------------------------------------------------- +// Resolver — context + outcome shapes +// --------------------------------------------------------------------------- + +/** + * State bag the resolver walks via `$ref` paths. The platform + * assembles this from its store before calling + * `resolveDebugConnection`. Keep the shape flat-ish — every + * top-level key is a documented entry point for spec authors. + */ +export interface DebugResolverState { + /** Mirror of `DeviceConfiguration` minus VPP screen data, which lives under `screens`. */ + configuration: { + deviceBoard: string + communicationPort?: string + runtimeIpAddress?: string + [key: string]: unknown + } + /** VPP screen state keyed by section id → field id. Mirror of + * `DeviceConfiguration.vendorScreenData` — the editor passes it + * straight through without any unflattening. Section IDs are + * globally unique within a device by VPP convention. */ + screens: Record> + /** Runtime-connection state. Only the fields specs may reference + * appear here — packages can't reach arbitrary editor internals. + * `connectionStatus` is a free-form string so we don't have to + * drag the editor's full `ConnectionStatus` union into the shared + * zone; the resolver only compares against `'connected'`. */ + runtimeConnection: { + connectionStatus?: string + jwtToken?: string | null + } + /** Free-form bag for prompt cache lookups, scoped per + * package+device by the resolver-engine caller. */ + promptCache?: Record +} + +export interface DebugResolverCapabilities { + /** Result of each precondition the platform supports. */ + runtimeConnected: boolean + jwtToken: boolean +} + +export interface DebugResolverContext { + state: DebugResolverState + capabilities: DebugResolverCapabilities +} + +/** + * Resolver outcome. Caller drives UX based on `kind`: + * + * - `config` → hand straight to `DebuggerPort.connect()`. + * - `pick` → show a picker over `channels[]`; re-call resolver + * with the user's chosen `pickedChannel`. + * - `prompt` → show input modals for `fields`; re-call resolver + * with values folded into `state.promptCache`. + * - `error` → show `title`/`body` dialog and stop. + * - `unsupported` → no `DebugSpec` declared on the device entry. + * Caller's choice whether to refuse or fall back. + */ +export type DebugResolverOutcome = + | { kind: 'config'; config: DebugConnectionConfig; channelLabel: string } + | { kind: 'pick'; channels: Array<{ index: number; label: string }>; title: string; body: string } + | { + kind: 'prompt' + fields: Array<{ + field: string + title: string + message: string + cacheKey?: string + defaultValue?: string + }> + channelIndex: number + } + | { kind: 'error'; title: string; body: string } + | { kind: 'unsupported' } + +// --------------------------------------------------------------------------- +// Resolver +// --------------------------------------------------------------------------- + +/** + * Walk `path` into `state`. Returns `undefined` for missing + * intermediates rather than throwing — the resolver treats missing + * fields as "absent" so `default` / `required` can do their job. + */ +function lookupRef(path: string, state: DebugResolverState): unknown { + const parts = path.split('.') + let cursor: unknown = state as unknown + for (const part of parts) { + if (cursor === null || cursor === undefined || typeof cursor !== 'object') return undefined + cursor = (cursor as Record)[part] + } + return cursor +} + +function evaluateRef(ref: DebugRef, state: DebugResolverState): unknown { + const raw = lookupRef(ref.$ref, state) + const resolved = raw === undefined ? ref.default : raw + if (resolved === undefined) return undefined + if (ref.as === 'number') { + const n = typeof resolved === 'number' ? resolved : Number(resolved) + return Number.isFinite(n) ? n : undefined + } + if (ref.as === 'boolean') return Boolean(resolved) + if (ref.as === 'string') return String(resolved) + return resolved +} + +function evaluateCondition(condition: DebugCondition, state: DebugResolverState): boolean { + if (typeof condition === 'boolean') return condition + const value = evaluateRef(condition, state) + return Boolean(value) +} + +function evaluateParam(param: DebugParam, state: DebugResolverState): unknown { + if (param !== null && typeof param === 'object' && '$ref' in param) { + return evaluateRef(param, state) + } + return param +} + +/** + * Resolve a device's `DebugSpec` against the platform-supplied + * state + capabilities. Pure: same inputs always produce the same + * outcome. Caller (renderer) is responsible for surfacing dialogs + * and re-invoking after picker / prompt resolution. + * + * `selectedChannelIndex` overrides the auto-select-from-`enabledWhen` + * logic — used when the renderer's picker UI returns a user choice. + */ +export function resolveDebugConnection( + spec: DebugSpec | undefined, + context: DebugResolverContext, + selectedChannelIndex?: number, +): DebugResolverOutcome { + if (!spec) return { kind: 'unsupported' } + + // Preconditions gate the whole resolution — fail fast with a + // clear message before walking channels. + for (const precondition of spec.preconditions ?? []) { + if (!context.capabilities[precondition]) { + if (precondition === 'runtimeConnected') { + return { kind: 'error', title: 'Connection Required', body: 'Connect to the target runtime first.' } + } + if (precondition === 'jwtToken') { + return { + kind: 'error', + title: 'Authentication Required', + body: 'JWT token missing. Reconnect to the runtime to refresh credentials.', + } + } + } + } + + // Pick the active channel: explicit override (from picker), single + // eligible match, or surface a picker when multiple match. + let activeIndex: number + if (selectedChannelIndex !== undefined) { + activeIndex = selectedChannelIndex + } else { + const enabled = spec.channels + .map((channel, index) => ({ channel, index })) + .filter(({ channel }) => evaluateCondition(channel.enabledWhen, context.state)) + if (enabled.length === 0) { + const msg = spec.messages?.noneEnabled + return { + kind: 'error', + title: msg?.title ?? 'No Debug Channel', + body: msg?.body ?? 'No debug channel is enabled for this board.', + } + } + if (enabled.length > 1) { + const msg = spec.messages?.pickProtocol + return { + kind: 'pick', + channels: enabled.map(({ channel, index }) => ({ index, label: channel.label })), + title: msg?.title ?? 'Select Debug Channel', + body: msg?.body ?? 'Multiple debug channels are enabled. Which one should the debugger use?', + } + } + activeIndex = enabled[0].index + } + + const channel = spec.channels[activeIndex] + if (!channel) { + return { kind: 'error', title: 'Internal Error', body: `Invalid channel index ${activeIndex}.` } + } + + // Surface prompts that haven't been answered yet (cache miss). + // Prompts gate connection — caller fills them, re-invokes resolver, + // and resolver returns `config` on the second pass. + const pendingPrompts: Array<{ + field: string + title: string + message: string + cacheKey?: string + defaultValue?: string + }> = [] + for (const prompt of channel.prompts ?? []) { + if (prompt.when !== undefined && !evaluateCondition(prompt.when, context.state)) continue + const cachedKey = prompt.cacheKey + const cached = cachedKey ? context.state.promptCache?.[cachedKey] : undefined + if (cached) continue + pendingPrompts.push({ + field: prompt.field, + title: prompt.title, + message: prompt.message, + ...(prompt.cacheKey !== undefined ? { cacheKey: prompt.cacheKey } : {}), + }) + } + if (pendingPrompts.length > 0) { + return { kind: 'prompt', fields: pendingPrompts, channelIndex: activeIndex } + } + + // Resolve params. Prompts have already populated promptCache for + // their fields; channel params reference the cache by `cacheKey`. + const connectionParams: Record = {} + for (const [name, raw] of Object.entries(channel.params)) { + const resolved = evaluateParam(raw, context.state) + if (resolved === undefined) { + // Check for `required` annotation on the ref. + if (raw !== null && typeof raw === 'object' && '$ref' in raw && raw.required) { + return { kind: 'error', title: 'Configuration Error', body: raw.required } + } + // Otherwise drop the param silently — channel adapter handles undefined. + continue + } + connectionParams[name] = resolved + } + // Apply any prompt cache values that target this channel's params. + for (const prompt of channel.prompts ?? []) { + const cached = prompt.cacheKey ? context.state.promptCache?.[prompt.cacheKey] : undefined + if (cached) connectionParams[prompt.field] = cached + } + + return { + kind: 'config', + channelLabel: channel.label, + config: { + connectionType: channel.channel, + connectionParams: connectionParams as DebugConnectionConfig['connectionParams'], + }, + } +} diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index 2030bc39d..27f7d9f04 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -1,6 +1,11 @@ import { resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' import { useCallback, useEffect, useRef, useState } from 'react' +import { + type DebugResolverContext, + type DebugSpec, + resolveDebugConnection, +} from '../../../../backend/shared/hardware/debug-spec' import type { DebugConnectionConfig } from '../../../../middleware/shared/ports/types' import { projectCapabilities } from '../../../../middleware/shared/ports/types' import { @@ -19,7 +24,7 @@ import { useOpenPLCStore } from '../../../store' import type { RuntimeConnection } from '../../../store/slices/device/types' import { cn } from '../../../utils/cn' import { logCompilerEvent } from '../../../utils/debugger-session' -import { isArduinoTarget, isOpenPLCRuntimeTarget, isOpenPLCRuntimeV4Target } from '../../../utils/device' +import { isArduinoTarget } from '../../../utils/device' import { getErrorMessage } from '../../../utils/get-error-message' import { type BuildOption, BuildOptionsPopover } from '../../_features/[workspace]/build-options' import { ChatButton } from '../../_molecules/workspace-activity-bar/default/chat' @@ -46,6 +51,18 @@ const showDebuggerMessage = ( }) } +const showDebuggerIpInput = (title: string, message: string, defaultValue: string): Promise => { + return new Promise((resolve) => { + useOpenPLCStore.getState().modalActions.openModal('debugger-ip-input', { + title, + message, + defaultValue, + onSubmit: (value: string) => resolve(value), + onCancel: () => resolve(null), + }) + }) +} + const disabledButtonClass = 'cursor-not-allowed opacity-50 [&>*:first-child]:hover:bg-transparent' type DefaultWorkspaceActivityBarProps = { @@ -197,7 +214,13 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa addLog({ id: crypto.randomUUID(), level: 'info', message: 'Simulator is running.' }) if (pendingSimulatorDebugRef.current) { pendingSimulatorDebugRef.current = false - void debugSession.connectAndStart() + // Simulator's debug spec resolves to the trivial + // `{ connectionType: 'simulator' }` config — see + // the hals.json entry. Pass it explicitly so the + // session's downstream MD5-verification path has + // the right transport instead of falling back to + // `connectAndStart`'s internal default. + void debugSession.connectAndStart({ connectionType: 'simulator', connectionParams: {} }) } } else { pendingSimulatorDebugRef.current = false @@ -537,11 +560,120 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa } } + // --------------------------------------------------------------------------- + // Debug-spec resolver — surface picker / prompt / error dialogs and + // return a connection-ready DebugConnectionConfig, or null if the + // user cancelled or no config could be resolved. + // --------------------------------------------------------------------------- + + // Renderer-local prompt cache for the DHCP-IP-style flows. Keyed + // by `||` (or `builtin||` + // for hals.json entries) so two boards sharing a `cacheKey` value + // don't see each other's last-entered IP. Lives on a ref so it + // survives across re-renders without triggering them. + const promptCacheRef = useRef>>({}) + + const resolveDebugConfigWithUx = useCallback( + async (boardTarget: string, spec: DebugSpec | undefined): Promise => { + if (!spec) { + await showDebuggerMessage( + 'warning', + 'Debugging Not Available', + "This board hasn't declared a debug spec. The VPP package (or hals.json entry) must provide a `debug` block.", + ['OK'], + ) + return null + } + + // Build resolver context from current store state on each call — + // captures the user's freshest screen edits without forcing the + // user to save first. + const buildContext = (): DebugResolverContext => { + const store = useOpenPLCStore.getState() + const cfg = store.deviceDefinitions.configuration + const rtConn = store.runtimeConnection + // `vendorScreenData` is already keyed by section ID (e.g. + // `modbus_rtu`); resolver state's `screens` shape matches + // 1:1 so we pass it straight through. + const screens = (cfg.vendorScreenData ?? {}) as Record> + const cacheBucketKey = `${cfg.deviceBoard}` + const promptCache = promptCacheRef.current[cacheBucketKey] ?? {} + return { + state: { + configuration: { + deviceBoard: cfg.deviceBoard, + ...(cfg.communicationPort ? { communicationPort: cfg.communicationPort } : {}), + ...(cfg.runtimeIpAddress ? { runtimeIpAddress: cfg.runtimeIpAddress } : {}), + }, + screens, + runtimeConnection: { + ...(rtConn.connectionStatus ? { connectionStatus: rtConn.connectionStatus } : {}), + ...(rtConn.jwtToken ? { jwtToken: rtConn.jwtToken } : {}), + }, + promptCache, + }, + capabilities: { + runtimeConnected: runtime.isReadyForDebug?.() === true && rtConn.connectionStatus === 'connected', + jwtToken: Boolean(rtConn.jwtToken), + }, + } + } + + let selectedChannelIndex: number | undefined + // Loop: pickers/prompts re-invoke the resolver with extra state + // until it returns config or error/unsupported/cancelled. + // Capped at 8 iterations as a defensive guard against spec + // bugs that could otherwise loop forever. + for (let iteration = 0; iteration < 8; iteration += 1) { + const outcome = resolveDebugConnection(spec, buildContext(), selectedChannelIndex) + if (outcome.kind === 'config') { + return outcome.config + } + if (outcome.kind === 'error') { + await showDebuggerMessage('warning', outcome.title, outcome.body, ['OK']) + return null + } + if (outcome.kind === 'unsupported') { + // Defensive — buildContext already errored at top-level on + // missing spec, so we shouldn't reach here normally. + return null + } + if (outcome.kind === 'pick') { + const buttons = outcome.channels.map((c) => c.label) + const choice = await showDebuggerMessage('question', outcome.title, outcome.body, buttons) + if (choice < 0 || choice >= outcome.channels.length) return null + selectedChannelIndex = outcome.channels[choice].index + continue + } + if (outcome.kind === 'prompt') { + const bucketKey = boardTarget + const bucket = (promptCacheRef.current[bucketKey] ??= {}) + for (const field of outcome.fields) { + const previous = field.cacheKey ? bucket[field.cacheKey] : undefined + const result = await showDebuggerIpInput(field.title, field.message, previous ?? field.defaultValue ?? '') + if (result === null) return null + const trimmed = result.trim() + if (!trimmed) return null + if (field.cacheKey) bucket[field.cacheKey] = trimmed + } + selectedChannelIndex = outcome.channelIndex + continue + } + } + return null + }, + [runtime], + ) + // --------------------------------------------------------------------------- // Debugger click — full orchestration for non-simulator targets // --------------------------------------------------------------------------- const handleDebuggerClick = useCallback(async () => { + // Simulator targets debug through the Start Simulator button + // (compile + load firmware + connect), so the Debugger button + // is hidden for them at the JSX level — but guard here too in + // case the gate ever flips. if (isSimulatorBoard) return const { workspace, project, deviceDefinitions: devDefs, consoleActions } = useOpenPLCStore.getState() @@ -567,48 +699,9 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const boardTarget = devDefs.configuration.deviceBoard const projectPath = project.meta.path const boardInfo = availableBoards.get(boardTarget) - const isRuntimeTarget = isOpenPLCRuntimeTarget(boardInfo) - // Resolve connection config - let debugConfig: DebugConnectionConfig = { connectionType: 'tcp', connectionParams: {} } - - if (isRuntimeTarget) { - const rtConn = useOpenPLCStore.getState().runtimeConnection - const runtimeIpAddress = devDefs.configuration.runtimeIpAddress - if (!runtime.isReadyForDebug?.() || rtConn.connectionStatus !== 'connected') { - await showDebuggerMessage('warning', 'Connection Required', 'Connect to the target first.', ['OK']) - setIsDebuggerProcessing(false) - return - } - if (isOpenPLCRuntimeV4Target(boardTarget, boardInfo)) { - const token = rtConn.jwtToken || undefined - if (!token) { - await showDebuggerMessage( - 'error', - 'Authentication Required', - 'JWT token missing. Reconnect to the runtime.', - ['OK'], - ) - setIsDebuggerProcessing(false) - return - } - debugConfig = { - connectionType: 'websocket', - connectionParams: { ipAddress: runtimeIpAddress, jwtToken: token }, - } - } else { - debugConfig = { connectionType: 'tcp', connectionParams: { ipAddress: runtimeIpAddress } } - } - } else { - // Non-runtime, non-simulator boards are expected to come back as - // VPP Arduino-family packages, each owning its own debug-connection - // surface. Refuse gracefully until that's wired in. - await showDebuggerMessage( - 'warning', - 'Debugging Not Available', - "Debugging for this target is not supported in the core editor. The selected board's VPP package must provide a debug adapter.", - ['OK'], - ) + const debugConfig = await resolveDebugConfigWithUx(boardTarget, boardInfo?.debug) + if (!debugConfig) { setIsDebuggerProcessing(false) return } @@ -628,6 +721,11 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa return } + // `isRuntimeTarget` here only gates the "PLC stopped, start it?" + // dialog inside MD5 verification. Tied to whether the active + // channel needs the runtime alive — websocket/tcp targets do, + // rtu/simulator targets don't. + const isRuntimeTarget = debugConfig.connectionType === 'websocket' || debugConfig.connectionType === 'tcp' void handleMd5Verification(projectPath, boardTarget, debugConfig, isRuntimeTarget) } catch (error: unknown) { consoleActions.addLog({ @@ -651,6 +749,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa editingState, executeSave, addLog, + resolveDebugConfigWithUx, ]) // --------------------------------------------------------------------------- diff --git a/src/frontend/store/__tests__/device-types.test.ts b/src/frontend/store/__tests__/device-types.test.ts index 9ead86ed8..f557ce10e 100644 --- a/src/frontend/store/__tests__/device-types.test.ts +++ b/src/frontend/store/__tests__/device-types.test.ts @@ -168,7 +168,6 @@ describe('Device slice types', () => { configuration: { deviceBoard: '', communicationPort: '', - compileOnly: false, }, pinMapping: { pins: [], currentSelectedPinTableRow: -1 }, }, @@ -249,7 +248,6 @@ describe('Device slice types', () => { 'updatePin', 'setDeviceBoard', 'setCommunicationPort', - 'setCompileOnly', 'setRuntimeIpAddress', 'setRuntimeJwtToken', 'setRuntimeConnectionStatus', @@ -260,7 +258,7 @@ describe('Device slice types', () => { 'setIncludeTimingStatsInPolling', 'clearRuntimeConnection', ] - expect(actionKeys).toHaveLength(20) + expect(actionKeys).toHaveLength(19) }) }) }) diff --git a/src/middleware/shared/ports/debug-spec-types.ts b/src/middleware/shared/ports/debug-spec-types.ts new file mode 100644 index 000000000..15fc3f205 --- /dev/null +++ b/src/middleware/shared/ports/debug-spec-types.ts @@ -0,0 +1,101 @@ +/** + * Debug-channel resolution spec — types only. Lives in the ports + * layer so it can be referenced by `BoardInfo` (which the device + * store carries) without crossing into `backend/shared/`. The + * resolver implementation that consumes these types lives at + * `backend/shared/hardware/debug-spec.ts`. + * + * Pattern mirrors how the rest of the manifest declares board + * behavior: pure data, no code, `$ref` strings point into the + * runtime state the editor already tracks + * (`configuration.*`, `screens..`, + * `runtimeConnection.*`). `` is the VPP screen + * section's `id` field (e.g. `modbus_rtu`) — the same key + * `DeviceConfiguration.vendorScreenData` is indexed by. + * + * Same `DebugSpec` shape sits in both byte-identical `hals.json` + * entries and per-device VPP manifest entries. + */ + +import type { DebugConnectionType } from './types' + +/** + * Closed enum of editor-known preconditions. Each name maps to a + * boolean the platform supplies via `DebugResolverContext.capabilities`. + * Packages reference these by name; the editor decides what each one + * means on its platform (editor desktop: runtime store; web: same + * store after the orchestrator handshake). + * + * Adding a new precondition is an editor change (new enum value + + * matching platform shim) — intentionally narrow so packages can't + * gate on arbitrary platform internals. + */ +export type DebugPrecondition = 'runtimeConnected' | 'jwtToken' + +/** + * `$ref` pointer plus optional defaulting / coercion / validation. + * Path is a dot-separated walk into the resolver-context state bag + * (e.g. `configuration.communicationPort`, + * `screens.modbus_rtu.enabled`, + * `runtimeConnection.jwtToken`). + * + * `default` kicks in when the resolved value is `undefined`. + * `as` coerces strings to numbers (used for baud rate and slave id + * — both stored as strings on screens but consumed as numbers by + * the debugger transport). `required` produces an error result + * with the given message when the field is missing after defaults. + */ +export interface DebugRef { + $ref: string + default?: string | number | boolean + as?: 'number' | 'string' | 'boolean' + required?: string +} + +/** Literal boolean or a `$ref` resolving to one — used by `enabledWhen`. */ +export type DebugCondition = boolean | DebugRef + +/** Channel param value: literal or `$ref`. */ +export type DebugParam = string | number | boolean | DebugRef + +/** + * Prompt the editor will surface when its `when` condition resolves + * truthy AFTER channel params are otherwise resolved. Result is + * stored into the channel's params at `field` and (when `cacheKey` + * is set) cached in renderer-local memory so the user doesn't + * re-enter the same value every debug session. + */ +export interface DebugPrompt { + /** Conditional gate. Omit for unconditional prompts. */ + when?: DebugCondition + /** Which channel param to populate. */ + field: string + title: string + message: string + /** Optional renderer-local cache key. */ + cacheKey?: string +} + +export interface DebugChannelSpec { + /** Human-readable picker label (shown when multiple channels match). */ + label: string + /** Which transport `DebuggerPort.connect` will receive. */ + channel: DebugConnectionType + /** Truthy → channel is eligible. Multiple eligible → picker. */ + enabledWhen: DebugCondition + /** Channel params, each literal or `$ref`. */ + params: Record + /** Optional input prompts surfaced before connecting. */ + prompts?: DebugPrompt[] +} + +export interface DebugSpecMessages { + noneEnabled?: { title: string; body: string } + pickProtocol?: { title: string; body: string } +} + +export interface DebugSpec { + preconditions?: DebugPrecondition[] + channels: DebugChannelSpec[] + messages?: DebugSpecMessages +} diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index 89cc68d43..d4408c9ca 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -627,6 +627,13 @@ export interface BoardInfo { * declare it. */ platformOptions?: PlatformOption[] + /** + * Declarative debug-channel resolver spec carried through from the + * source catalog (hals.json or VPP manifest). Consumed by + * `backend/shared/hardware/debug-spec.ts#resolveDebugConnection`. + * Absent → the renderer surfaces "Debugging Not Available". + */ + debug?: import('./debug-spec-types').DebugSpec } // --------------------------------------------------------------------------- @@ -752,6 +759,12 @@ export interface PackageManifest { } } screens?: Record + /** Declarative debug-channel resolver spec, consumed by + * `backend/shared/hardware/debug-spec.ts`. Same shape as + * the `debug` field on built-in hals.json entries — the + * editor's resolver doesn't care which catalog the device + * came from. Absence means no debug capability is declared. */ + debug?: import('./debug-spec-types').DebugSpec /** Optional target capability overrides for this device, merged over * the preset the editor derives from the target type. A runtime-v4 * board exposing physical GPIO (e.g. the Raspberry Pi) sets From 0d00598d999b2c8073a936e1aa5e0ecf6d23bd52 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 2 Jun 2026 16:08:53 -0400 Subject: [PATCH 37/61] fix(compile): thread vppModbusState into runCompilePipeline + drop dead method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Arduino-Mega-with-Modbus-RTU debug bug had a different root cause than the resolver-side wiring landed previously: the FIRMWARE itself never enabled Modbus. Trace: 1. User enables Modbus RTU in the device's Modbus screen. Editor persists `vendorScreenData.modbus_rtu.enabled = true` in `devices/configuration.json`. 2. User clicks Build. `compileProgram` calls `runCompilePipeline`. The pipeline calls `generateDefinesContent` to author `defines.h` — but receives `vppModbusState: undefined` because `compileProgram` never read the screen state. 3. `generateDefinesContent` falls through the Modbus block (no state to emit), `defines.h` ships with NO `MBSERIAL_*` / `MBTCP_*` macros. 4. `ModbusSlave.cpp` compiles its `#ifdef MBSERIAL` blocks to empty — the firmware has no Modbus serial running. 5. Upload succeeds. Board boots, no Modbus listener. 6. User clicks Debug. Editor's debug resolver picks the Modbus RTU channel correctly (the spec wiring works), opens the serial port at the configured baud, sends an MD5 query — but the board never responds because it has no Modbus listener. 7. After N retries, the debugger gives up: "Failed to get MD5 hash after retries." The Modbus screen state was being read inside `handleGenerateDefinitionsFile`, but that method is dead code — not called from anywhere since the compile pipeline moved to the shared `generateDefinesContent` step. The pipeline's `vppModbusState` arg was correctly threaded through to the shared authoring step but the editor's caller never populated it. Fix: read `vendorScreenData.modbus_rtu/_tcp` from disk inside `compileProgram` (same as the old method did) and pass through to `runCompilePipeline`. Delete the dead 70-line `handleGenerateDefinitionsFile` method + its now-unused `generateDefinesContent` import. Affects compileProgram only — `compileForDebugger` doesn't run arduino-cli compile so `defines.h` isn't needed on the debug path; the firmware on the board (from the prior Build) is what the debugger talks to. Verification: - npx tsc --noEmit: clean - npx eslint: clean - npx jest: 313 pass / 1 pre-existing failure on this branch (editor-compiler-platform-port TS issue, same on development) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/compiler/compiler-module.ts | 115 ++++-------------- 1 file changed, 27 insertions(+), 88 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 9538fe4e2..c4461483b 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -79,7 +79,6 @@ 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 { 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 { readHalsFile } from '@root/backend/shared/firmware/hals-loader' import type { DeviceConfiguration, DevicePin } from '@root/backend/shared/types/PLC/devices' @@ -1153,93 +1152,6 @@ 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, - boardTarget, - boardRuntime, - _handleOutputData, - }: { - projectPath: string - boardTarget: string - buildMD5Hash: string - boardRuntime: string - _handleOutputData: HandleOutputDataCallback - }) { - const devicesPinMappingFilePath = join(projectPath, 'devices', 'pin-mapping.json') - const buildTargetDirectoryPath = join(projectPath, 'build', boardTarget) - const stProgramFilePath = join(buildTargetDirectoryPath, 'src', 'program.st') - const definitionsFilePath = join(buildTargetDirectoryPath, 'src', 'defines.h') - - // Resolve board info uniformly across hals.json + installed VPP - // packages so `boardInfo.define` covers BOARD_ESP8266 / BOARD_ESP32 / - // BOARD_WIFININA contributions from either source — ModbusSlave.h's - // board-detection chain relies on these macros being present - // regardless of catalog. - const resolver = await this.#createBoardInfoResolver() - const boardInfo = resolver.resolve(boardTarget) - - const devicePinMapping = await CompilerModule.readJSONFile(devicesPinMappingFilePath) - const stProgramFileContent = await readFile(stProgramFilePath, 'utf-8') - - // VPP Modbus screen state — only read for non-simulator Arduino - // targets. Simulator emits a fixed RTU-over-USART0 block (handled - // inside `generateDefinesContent`); runtime-v3/v4 route Modbus - // through `conf/modbus_slave.json` in the upload bundle and emit - // no macros here. - let vppModbusState: VppModbusScreenState | undefined - if (boardRuntime !== 'simulator' && boardRuntime !== 'openplc-compiler') { - const devicesConfigurationFilePath = join(projectPath, 'devices', 'configuration.json') - try { - const deviceConfig = await CompilerModule.readJSONFile(devicesConfigurationFilePath) - const vendorScreenData = deviceConfig.vendorScreenData ?? {} - vppModbusState = { - modbus_rtu: vendorScreenData['modbus_rtu'] as VppModbusScreenState['modbus_rtu'], - modbus_tcp: vendorScreenData['modbus_tcp'] as VppModbusScreenState['modbus_tcp'], - } - } catch { - // Missing configuration.json leaves vppModbusState undefined — - // the shared `generateDefinesContent` then skips the Modbus - // block (no MODBUS_ENABLED), matching the pre-VPP behaviour - // for boards that never had a comms config persisted. - } - } - - const definesContent = generateDefinesContent({ - boardEntry: { define: boardInfo.define }, - devicePinMapping, - stProgramFileContent, - buildMD5Hash, - boardRuntime, - ...(vppModbusState ? { vppModbusState } : {}), - }) - - try { - await writeFile(definitionsFilePath, definesContent, { encoding: 'utf8' }) - _handleOutputData(`Defines file created at: ${definitionsFilePath}`, 'info') - } catch (_error) { - _handleOutputData('Error writing defines.h file', 'error') - } - } - // handlePatchGeneratedFiles is no longer needed. // STruC++ generates clean C++ files (generated.cpp + generated.hpp) that don't require // patching or unity build renaming. @@ -2635,6 +2547,32 @@ class CompilerModule { ? { kind: 'editor-https' as const, ip: runtimeIpAddress, jwt: runtimeJwtToken } : undefined + // Pull the persisted VPP Modbus screen state from + // `devices/configuration.json` so non-runtime / non-simulator + // targets get the matching `MBSERIAL_*` / `MBTCP_*` defines + // baked into the firmware. Without this, ModbusSlave.cpp's + // `#ifdef MBSERIAL` blocks compile to nothing and the board + // never enables Modbus — at which point the debugger can't + // talk to it (failing MD5 verification after retries). + let vppModbusState: VppModbusScreenState | undefined + if (boardRuntime !== 'simulator' && boardRuntime !== 'openplc-compiler') { + const devicesConfigurationFilePath = join(normalizedProjectPath, 'devices', 'configuration.json') + try { + const deviceConfig = + await CompilerModule.readJSONFile(devicesConfigurationFilePath) + const vendorScreenData = deviceConfig.vendorScreenData ?? {} + vppModbusState = { + modbus_rtu: vendorScreenData['modbus_rtu'] as VppModbusScreenState['modbus_rtu'], + modbus_tcp: vendorScreenData['modbus_tcp'] as VppModbusScreenState['modbus_tcp'], + } + } catch { + // No configuration.json — leave undefined so the shared + // pipeline skips the Modbus block entirely (matches the + // pre-VPP behaviour for boards that never had a comms + // config persisted). + } + } + // --- Run the shared pipeline --- const result = await runCompilePipeline( { @@ -2659,6 +2597,7 @@ class CompilerModule { arduinoCliParallel: true, deviceContext, communicationPort: communicationPort ?? undefined, + ...(vppModbusState ? { vppModbusState } : {}), }, platformPort, (event) => { From 73ccf8a00b22f75e312edb948d34c195964378ae Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 2 Jun 2026 20:32:21 -0400 Subject: [PATCH 38/61] fix(precompile): order `-I avr-libstdcpp` before core/variant -I's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `handlePrecompileUserLib` was placing the bundled `-I .../avr-libstdcpp/ include` after the core/variant `-I`s, so `#include ` resolved to Arduino's `cores/arduino/new` (declared with `[[gnu::weak]]`) instead of the modm-io port's `` (declared with `__externally_visible__`). That weak declaration propagated to every `_Znaj` / `_Znwj` reference emitted by `new T[]` / `new T` in the precompiled archive. ld does not scan archives for weak undefined refs, so the link left them resolved to address 0 — every `new` expression in `arduino_runtime_glue.cpp` (notably `runtime_discover_tasks`) compiled to `call 0`, jumping to the AVR reset vector and producing an infinite reset loop the moment the simulator booted. The Arduino Mega target masked the bug by accident: its HAL (`mega_due.cpp`) pulls in AVR_PWM, which emits a strong `_Znwj` reference that triggered the archive scan and dragged `core.a/new.cpp.o` in (which defines both operators). The simulator HAL drops the PWM path on purpose (`avr8js` can't drive real PWM hardware), so no strong `new` reference existed to mask the weak-ref issue. Fix: split `extraCxxFlags` into `-I` flags (which now prepend to `includeArgs`, mirroring arduino-cli's stock recipe where `{compiler.cpp.extra_flags}` is interpolated before `{includes}`) and non-include flags (which stay trailing so the last `-std=` still wins over the recipe's `-std=gnu++11`). Two regression tests added in `compiler-module.spec.ts`: - pins the include ordering with a comment citing the weak `` declaration so the next refactor knows what would break - pins that `-std=` and friends stay trailing so VPP-package cxx_flags overrides still apply Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/compiler/compiler-module.spec.ts | 73 +++++++++++++++++++ .../editor/compiler/compiler-module.ts | 28 ++++++- 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.spec.ts b/src/backend/editor/compiler/compiler-module.spec.ts index 5ac5ef302..6340249b1 100644 --- a/src/backend/editor/compiler/compiler-module.spec.ts +++ b/src/backend/editor/compiler/compiler-module.spec.ts @@ -715,6 +715,79 @@ describe('CompilerModule', () => { expect(compileCmd).not.toMatch(/-I(\s|$)/) }) + it('places extraCxxFlags `-I` paths BEFORE the core/variant `-I`s (avr-libstdcpp must shadow Arduino core )', async () => { + // Load-bearing ordering: Arduino's `cores/arduino/new` declares + // `operator new[]` as `[[gnu::weak]]`, while modm-io/avr-libstdcpp's + // `` declares it without the weak attribute. Whichever header + // the preprocessor finds first determines whether `_Znaj` references + // emitted from `new T[]` are strong or weak. Weak undefined refs do + // NOT pull the matching definition from `core.a/new.cpp.o` during + // link — the call resolves to address 0 (the AVR reset vector), + // resulting in an infinite reset the moment any precompiled TU + // executes a `new` expression. + // + // arduino-cli's stock recipe interpolates `{compiler.cpp.extra_flags}` + // (which carries the cxx_flags `-I .../avr-libstdcpp/include`) + // BEFORE `{includes}` (the core/variant paths), so the avr-libstdcpp + // `` wins. The precompile must mirror that ordering — this + // test pins the contract. + fs.writeFileSync(join(srcDir, 'pou_MAIN.cpp'), '// pou\n', 'utf-8') + + const execCalls: string[] = [] + execImpl.current = async (cmd) => { + execCalls.push(cmd) + return { stdout: '', stderr: '' } + } + + await compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:avr:uno', + extraCxxFlags: ['-std=gnu++17', '-I/fake/openplc-avr-libstdcpp/include'], + handleOutputData: noopLog, + }) + + const compileCmd = execCalls.find((c) => c.includes('pou_MAIN.cpp')) ?? '' + const libStdCppPos = compileCmd.indexOf('-I/fake/openplc-avr-libstdcpp/include') + const corePos = compileCmd.indexOf('-I/fake/avr/cores/arduino') + const variantPos = compileCmd.indexOf('-I/fake/avr/variants/standard') + + expect(libStdCppPos).toBeGreaterThan(-1) + expect(corePos).toBeGreaterThan(-1) + expect(variantPos).toBeGreaterThan(-1) + // avr-libstdcpp must come before BOTH core and variant -I paths. + expect(libStdCppPos).toBeLessThan(corePos) + expect(libStdCppPos).toBeLessThan(variantPos) + }) + + it('keeps non-`-I` flags from extraCxxFlags as trailing args so the last `-std=` wins over the recipe default', async () => { + // The precompile appends `-std=gnu++17 -fno-rtti` as trailing flags + // to override the AVR core's recipe-baked `-std=gnu++11`. Any + // additional `-std=` or `-f*` flags from VPP-package cxx_flags + // must end up trailing too, otherwise a `-std=` from cxx_flags + // gets shadowed by the recipe default and strucpp templates that + // require C++17 fail to compile. + fs.writeFileSync(join(srcDir, 'pou_MAIN.cpp'), '// pou\n', 'utf-8') + + const execCalls: string[] = [] + execImpl.current = async (cmd) => { + execCalls.push(cmd) + return { stdout: '', stderr: '' } + } + + await compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:avr:uno', + extraCxxFlags: ['-std=gnu++17', '-I/fake/openplc-avr-libstdcpp/include'], + handleOutputData: noopLog, + }) + + const compileCmd = execCalls.find((c) => c.includes('pou_MAIN.cpp')) ?? '' + // -I lands before the source-file end of the recipe; -std= lands after. + const stdPos = compileCmd.lastIndexOf('-std=gnu++17') + const sourcePos = compileCmd.indexOf('pou_MAIN.cpp') + expect(stdPos).toBeGreaterThan(sourcePos) + }) + it('hard-fails with an actionable error when build.core.path is missing from --show-properties', async () => { extractSpy.mockResolvedValue({ ...cannedProps, diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index c4461483b..830572991 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -1416,16 +1416,36 @@ class CompilerModule { `The board's core is likely not installed.`, ) } + // `-I` flags from `extraCxxFlags` (canonically: `-I` + // and any VPP-package -I directives) must be ordered BEFORE the + // core/variant `-I`s — mirroring arduino-cli's recipe, which + // interpolates `{compiler.cpp.extra_flags}` ahead of `{includes}`. + // + // Why this is load-bearing: modm-io/avr-libstdcpp's `` declares + // `operator new` / `operator new[]` with `__externally_visible__` + // (strong linkage), whereas Arduino's `cores/arduino/new` declares + // the same operators with `[[gnu::weak]]`. Whichever header the + // preprocessor finds first determines the linkage of `_Znaj` / + // `_Znwj` references emitted from `new T[]` / `new T` in this TU. + // Weak undefined references DO NOT pull the matching definition + // from `core.a/new.cpp.o` during link (ld only scans archives for + // strong refs), so the call site resolves to address 0 (the AVR + // reset vector) — manifesting as an infinite reset loop the + // moment any precompiled TU executes a `new` expression. + // + // Non-include flags (`-std=`, `-fno-rtti`, anything else from VPP + // `cxx_flags`) stay trailing so the last `-std=` wins over the + // core's implicit gnu++11. + const extraIncludeFlags = extraCxxFlags.filter((flag) => flag.startsWith('-I')) + const extraNonIncludeFlags = extraCxxFlags.filter((flag) => !flag.startsWith('-I')) const includeArgs = [ + ...extraIncludeFlags, `-I${corePath}`, ...(variantPath ? [`-I${variantPath}`] : []), `-I${srcDir}`, `-I${baremetalDir}`, ] - - // Appended after the recipe so the last `-std=` wins over the core's - // implicit gnu++14. extraCxxFlags carries VPP per-board cxx_flags. - const trailingFlags = ['-std=gnu++17', '-fno-rtti', ...extraCxxFlags] + const trailingFlags = ['-std=gnu++17', '-fno-rtti', ...extraNonIncludeFlags] const execMaxBuffer = 16 * 1024 * 1024 From ba9956612fea571d9a128fd85bf99becabb5eb99 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 2 Jun 2026 21:38:51 -0400 Subject: [PATCH 39/61] fix(variables-table): scope location dropdown by active target capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The variables-table location-cell dropdown was sourcing options directly from `pinSelectors / remoteDeviceSelectors / vendorIoSelectors`, bypassing the target-capability gate the shared address pool already enforces. The project file legitimately persists every producer's state on disk so that switching targets doesn't lose work — but without the gate, the dropdown was offering addresses the active target couldn't actually drive. User-visible repro: a project authored against SLM-RP4 (Runtime v4 with `vppIo: true`) defined a vendor-IO entry at `%QX0.0` on slot 1. Switching the target to Arduino Mega correctly relocated the project's `%QX0.0` to an Arduino pin, but clicking the location cell still showed BOTH the Arduino pin's `%QX0.0` AND the stale SLM-RP4 slot 1 `%QX0.0` in the picker. Fix --- - `useTargetCapabilities` (new hook, module-level cached) returns the active board's `TargetCapabilities` via the same resolution path `useAliasRegistry` ran inline. Both hooks now share one canonical answer. - `buildLocationDropdownOptions` (new pure util) builds the dropdown's group list with the same gating semantics `buildAddressPool` uses: pin groups on `caps.pinMapping`, remote groups on `caps.modbusTcpRemote || caps.ethercat`, vendor groups on `caps.vppIo`. - `EditableLocationCell.selectableValues()` collapses to a thin call into `buildLocationDropdownOptions(...)`. Net 36-line reduction in the cell. - `useAliasRegistry` reuses `useTargetCapabilities` (no behavior change; dedup). Regression coverage ------------------- Ten new tests in `location-dropdown-options.test.ts` (100% util coverage) pin the gating contract: - SLM-RP4 → Arduino Mega: VPP entries dropped - Arduino Mega → SLM-RP4: pin entries dropped - Remote/EtherCAT hidden on arduino-cli, surfaced on v4 / sim - Runtime v3 baseline: every producer dropped - Label / id / group-ordering contracts preserved Co-Authored-By: Claude Opus 4.7 (1M context) --- .../variables-table/editable-cell.tsx | 68 ++--- src/frontend/hooks/use-alias-registry.ts | 28 +- src/frontend/hooks/use-target-capabilities.ts | 39 +++ .../location-dropdown-options.test.ts | 243 ++++++++++++++++++ .../utils/location-dropdown-options.ts | 93 +++++++ 5 files changed, 414 insertions(+), 57 deletions(-) create mode 100644 src/frontend/hooks/use-target-capabilities.ts create mode 100644 src/frontend/utils/__tests__/location-dropdown-options.test.ts create mode 100644 src/frontend/utils/location-dropdown-options.ts diff --git a/src/frontend/components/_molecules/variables-table/editable-cell.tsx b/src/frontend/components/_molecules/variables-table/editable-cell.tsx index 8a94376a2..a84f2491b 100644 --- a/src/frontend/components/_molecules/variables-table/editable-cell.tsx +++ b/src/frontend/components/_molecules/variables-table/editable-cell.tsx @@ -1,5 +1,6 @@ import * as PrimitivePopover from '@radix-ui/react-popover' import { useAliasRegistry } from '@root/frontend/hooks/use-alias-registry' +import { useTargetCapabilities } from '@root/frontend/hooks/use-target-capabilities' import type { CellContext, RowData } from '@tanstack/react-table' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -9,7 +10,7 @@ import { useOpenPLCStore } from '../../../store' import { ProjectResponse } from '../../../store/slices/project' import { cn } from '../../../utils/cn' import { isLegalIdentifier, sanitizeVariableInput } from '../../../utils/keywords' -import { buildRemoteDeviceOptionGroups, buildVendorIoOptionGroups } from '../../../utils/remote-device-options' +import { buildLocationDropdownOptions } from '../../../utils/location-dropdown-options' import { findAllReferencesToVariable, propagateVariableRename, @@ -441,6 +442,16 @@ const EditableLocationCell = ({ const existingPins = pinSelectors.usePins() const remoteIOPoints = remoteDeviceSelectors.useRemoteDeviceIOPoints() const vendorIoEntries = vendorIoSelectors.useVendorIoEntries() + // Target-capability gate: the project file can carry persisted state + // from previously-active targets (e.g. SLM-RP4 VPP-module entries + // left over from a project authored against runtime v4, kept on + // disk so switching back doesn't lose work). The address pool + // already scopes claims by `caps.`; mirror that here so + // the dropdown only surfaces addresses the active target can + // actually drive. Without this filter, switching SLM-RP4 → Arduino + // Mega leaves both `%QX0.0` rows (Arduino pin + stale VPP slot 1) + // in the picker. + const capabilities = useTargetCapabilities() // We need to keep and update the state of the cell normally const [cellValue, setCellValue] = useState(initialValue) @@ -487,50 +498,17 @@ const EditableLocationCell = ({ ) }, [editor.meta.name, index, table.options.data, scope, getVariable]) - const selectableValues = useCallback(() => { - const ainPins = existingPins - .filter((pin) => pin.pinType === 'analogInput') - .map((pin) => ({ - id: `${id}-${pin.pin}`, - value: pin.address, - label: `${pin.address} ${pin.alias ? `(${pin.alias})` : ''}`, - })) - const aoutPins = existingPins - .filter((pin) => pin.pinType === 'analogOutput') - .map((pin) => ({ - id: `${id}-${pin.pin}`, - value: pin.address, - label: `${pin.address} ${pin.alias ? `(${pin.alias})` : ''}`, - })) - - const dinPins = existingPins - .filter((pin) => pin.pinType === 'digitalInput') - .map((pin) => ({ - id: `${id}-${pin.pin}`, - value: pin.address, - label: `${pin.address} ${pin.alias ? `(${pin.alias})` : ''}`, - })) - - const doutPins = existingPins - .filter((pin) => pin.pinType === 'digitalOutput') - .map((pin) => ({ - id: `${id}-${pin.pin}`, - value: pin.address, - label: `${pin.address} ${pin.alias ? `(${pin.alias})` : ''}`, - })) - - const remoteGroups = buildRemoteDeviceOptionGroups(id, remoteIOPoints) - const vendorGroups = buildVendorIoOptionGroups(id, vendorIoEntries) - - return [ - { label: 'Analog Inputs', options: ainPins }, - { label: 'Analog Outputs', options: aoutPins }, - { label: 'Digital Inputs', options: dinPins }, - { label: 'Digital Outputs', options: doutPins }, - ...remoteGroups, - ...vendorGroups, - ] - }, [id, variable, existingPins, remoteIOPoints, vendorIoEntries]) + const selectableValues = useCallback( + () => + buildLocationDropdownOptions({ + cellId: id, + pins: existingPins, + remoteIOPoints, + vendorIoEntries, + capabilities, + }), + [id, existingPins, remoteIOPoints, vendorIoEntries, capabilities], + ) // Combined display: when the variable carries an alias, show it // alongside the raw address as "alias (address)" — same shape diff --git a/src/frontend/hooks/use-alias-registry.ts b/src/frontend/hooks/use-alias-registry.ts index 66d00cb4f..02563e90b 100644 --- a/src/frontend/hooks/use-alias-registry.ts +++ b/src/frontend/hooks/use-alias-registry.ts @@ -11,17 +11,18 @@ */ import { useOpenPLCStore } from '@root/frontend/store' -import type { BoardInfo, DevicePin, PLCRemoteDevice } from '@root/middleware/shared/ports/types' +import type { DevicePin, PLCRemoteDevice } from '@root/middleware/shared/ports/types' import type { AliasRegistry } from '@root/middleware/shared/utils/iec-address' import { buildAddressPool, buildAliasRegistry } from '@root/middleware/shared/utils/iec-address' -import { resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' +import type { TargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' + +import { useTargetCapabilities } from './use-target-capabilities' interface RegistryCache { pins: DevicePin[] vsd: Record | undefined remoteDevices: PLCRemoteDevice[] | undefined - deviceBoard: string - availableBoards: Map + capabilities: TargetCapabilities registry: AliasRegistry } @@ -34,24 +35,27 @@ interface RegistryCache { let cache: RegistryCache | null = null export function useAliasRegistry(): AliasRegistry { - const pins = useOpenPLCStore((s) => s.deviceDefinitions.pinMapping.pins) + // The pin-mapping dict is keyed by board id (see DevicePinMapping). + // The active board's bucket is what the alias registry should see — + // pins for any non-active board are persisted on disk but don't + // contribute claims to the address pool. + const pinsByBoard = useOpenPLCStore((s) => s.deviceDefinitions.pinMapping.pinsByBoard) + const deviceBoard = useOpenPLCStore((s) => s.deviceDefinitions.configuration.deviceBoard) + const pins = pinsByBoard[deviceBoard] ?? [] const vsd = useOpenPLCStore((s) => s.deviceDefinitions.configuration.vendorScreenData) const remoteDevices = useOpenPLCStore((s) => s.project.data.remoteDevices) - const deviceBoard = useOpenPLCStore((s) => s.deviceDefinitions.configuration.deviceBoard) - const availableBoards = useOpenPLCStore((s) => s.deviceAvailableOptions.availableBoards) + const capabilities = useTargetCapabilities() if ( cache && cache.pins === pins && cache.vsd === vsd && cache.remoteDevices === remoteDevices && - cache.deviceBoard === deviceBoard && - cache.availableBoards === availableBoards + cache.capabilities === capabilities ) { return cache.registry } - const boardInfo = availableBoards.get(deviceBoard) const ioMapping = ( vsd?.['io-mapping'] as @@ -64,10 +68,10 @@ export function useAliasRegistry(): AliasRegistry { vendorIoMapping: { entries: ioMapping }, remoteDevices, }, - resolveTargetCapabilities(boardInfo), + capabilities, ) const registry = buildAliasRegistry(pool) - cache = { pins, vsd, remoteDevices, deviceBoard, availableBoards, registry } + cache = { pins, vsd, remoteDevices, capabilities, registry } return registry } diff --git a/src/frontend/hooks/use-target-capabilities.ts b/src/frontend/hooks/use-target-capabilities.ts new file mode 100644 index 000000000..6e0ce706d --- /dev/null +++ b/src/frontend/hooks/use-target-capabilities.ts @@ -0,0 +1,39 @@ +/** + * Selector hook that resolves the active target's `TargetCapabilities` + * from the live store state — the same resolution path + * `useAliasRegistry` runs to scope the address pool, exposed as a + * standalone hook so UI surfaces that gate their content on the active + * target (location-cell dropdown, board picker, debugger selector) + * share one canonical answer. + * + * Backed by a module-level single-entry cache keyed on the + * `(deviceBoard, availableBoards)` pair so dozens of cells calling + * this in the same render-pass share one resolution. Zustand + * guarantees referential stability on unchanged state so the cache + * hit is the steady-state path. + */ + +import { useOpenPLCStore } from '@root/frontend/store' +import type { BoardInfo } from '@root/middleware/shared/ports/types' +import { resolveTargetCapabilities, type TargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' + +interface CapabilitiesCache { + deviceBoard: string + availableBoards: Map + capabilities: TargetCapabilities +} + +let cache: CapabilitiesCache | null = null + +export function useTargetCapabilities(): TargetCapabilities { + const deviceBoard = useOpenPLCStore((s) => s.deviceDefinitions.configuration.deviceBoard) + const availableBoards = useOpenPLCStore((s) => s.deviceAvailableOptions.availableBoards) + + if (cache && cache.deviceBoard === deviceBoard && cache.availableBoards === availableBoards) { + return cache.capabilities + } + + const capabilities = resolveTargetCapabilities(availableBoards.get(deviceBoard)) + cache = { deviceBoard, availableBoards, capabilities } + return capabilities +} diff --git a/src/frontend/utils/__tests__/location-dropdown-options.test.ts b/src/frontend/utils/__tests__/location-dropdown-options.test.ts new file mode 100644 index 000000000..4ba6007c0 --- /dev/null +++ b/src/frontend/utils/__tests__/location-dropdown-options.test.ts @@ -0,0 +1,243 @@ +/** + * Tests for `buildLocationDropdownOptions` — the variables-table + * location-cell dropdown source. The core contract is target-scoping: + * the picker must offer only addresses the active target's capability + * block authorizes, even when the project file still carries persisted + * state from previously-active targets. + * + * The user-facing bug this regression-tests against: switching a + * project from SLM-RP4 (Runtime v4 + VPP IO) to Arduino Mega + * (arduino-cli + pin-mapping) was leaving the dropdown showing both + * the Arduino pin's `%QX0.0` AND the stale SLM-RP4 slot 1 `%QX0.0`, + * because the dropdown wasn't honoring `caps.vppIo === false`. + */ + +import type { DevicePin, IoMappingEntry } from '../../../middleware/shared/ports/types' +import { + ARDUINO_CLI_CAPABILITIES, + RUNTIME_V3_CAPABILITIES, + RUNTIME_V4_CAPABILITIES, + SIMULATOR_CAPABILITIES, + type TargetCapabilities, +} from '../../../middleware/shared/utils/target-capabilities' +import { buildLocationDropdownOptions } from '../location-dropdown-options' +import type { RemoteDeviceIOPoint } from '../remote-device-options' + +const ARDUINO_PIN: DevicePin = { + pin: '13', + address: '%QX0.0', + pinType: 'digitalOutput', + alias: 'arduino-led', +} + +const VPP_VENDOR_ENTRY: IoMappingEntry = { + slot: 1, + moduleId: 'slm-acdci-8np-rly8', + moduleName: 'SLM-ACDCI-8NP-RLY8', + channelName: 'RLY1', + channelType: 'digitalOutput', + dataType: 'BOOL', + iecAddress: '%QX0.0', + alias: 'slm-rp4-relay-1', +} + +const REMOTE_POINT: RemoteDeviceIOPoint = { + deviceName: 'remote-rtu-1', + ioGroupName: 'group-a', + ioPointId: 'point-1', + ioPointName: 'flow-sensor', + ioPointType: 'analogInput', + iecLocation: '%IW0', + alias: 'flow-sensor-alias', +} + +// SLM-RP4 has a VPP capability override on top of RUNTIME_V4 — model +// it explicitly so the test pins what the manifest actually declares. +const SLM_RP4_CAPABILITIES: TargetCapabilities = { ...RUNTIME_V4_CAPABILITIES, vppIo: true } + +describe('buildLocationDropdownOptions', () => { + describe('target-scoping by capability block', () => { + it('drops VPP vendor IO entries when the active target has `vppIo: false` (the SLM-RP4 → Arduino Mega regression)', () => { + const groups = buildLocationDropdownOptions({ + cellId: 'loc-1', + pins: [ARDUINO_PIN], + remoteIOPoints: [], + vendorIoEntries: [VPP_VENDOR_ENTRY], + capabilities: ARDUINO_CLI_CAPABILITIES, + }) + + // The Arduino pin's %QX0.0 surfaces under Digital Outputs. + const digitalOutputs = groups.find((g) => g.label === 'Digital Outputs') + expect(digitalOutputs?.options.map((o) => o.value)).toEqual(['%QX0.0']) + + // The stale SLM-RP4 entry must NOT appear — neither as a group nor + // as an option in any group. + const allOptionValues = groups.flatMap((g) => g.options.map((o) => o.value)) + const vppGroupCount = groups.filter((g) => + // VPP groups carry a "slot-N module-name" pattern; pin/remote + // groups use fixed labels. The exact convention is set by + // `buildVendorIoOptionGroups`, so we test the user-visible + // contract instead: when vppIo is off, no group should + // reference a slot number. + /slot/i.test(g.label), + ).length + expect(vppGroupCount).toBe(0) + // Belt-and-suspenders: even if a VPP group did slip through with a + // non-matching label, the entry's specific alias must not appear. + expect(allOptionValues.filter((v) => v === '%QX0.0')).toHaveLength(1) + }) + + it('drops Arduino pin entries when the active target has `pinMapping: false` (the Arduino Mega → SLM-RP4 reverse)', () => { + const groups = buildLocationDropdownOptions({ + cellId: 'loc-2', + pins: [ARDUINO_PIN], + remoteIOPoints: [], + vendorIoEntries: [VPP_VENDOR_ENTRY], + capabilities: SLM_RP4_CAPABILITIES, + }) + + // No pin groups should carry options — the pin entries are + // disabled. Group headings stay (empty) so the picker keeps a + // stable shape regardless of active target. + for (const label of ['Analog Inputs', 'Analog Outputs', 'Digital Inputs', 'Digital Outputs']) { + const group = groups.find((g) => g.label === label) + expect(group?.options).toEqual([]) + } + + // The VPP entry surfaces under whatever group the vendor-io + // builder produces for slot 1; assert via the option value. + const allOptionValues = groups.flatMap((g) => g.options.map((o) => o.value)) + expect(allOptionValues).toContain('%QX0.0') + }) + + it('drops remote-device IO points when both `modbusTcpRemote` and `ethercat` are disabled', () => { + // Arduino targets disable both. The remote point persists in the + // project but must not surface. + const groups = buildLocationDropdownOptions({ + cellId: 'loc-3', + pins: [], + remoteIOPoints: [REMOTE_POINT], + vendorIoEntries: [], + capabilities: ARDUINO_CLI_CAPABILITIES, + }) + + const allOptionValues = groups.flatMap((g) => g.options.map((o) => o.value)) + expect(allOptionValues).not.toContain('%IW0') + }) + + it('surfaces remote-device IO points when EITHER `modbusTcpRemote` OR `ethercat` is enabled', () => { + // Both flags on the SLM-RP4 capability block — typical Runtime v4 + // setup. The point should appear. + const groups = buildLocationDropdownOptions({ + cellId: 'loc-4', + pins: [], + remoteIOPoints: [REMOTE_POINT], + vendorIoEntries: [], + capabilities: SLM_RP4_CAPABILITIES, + }) + + const allOptionValues = groups.flatMap((g) => g.options.map((o) => o.value)) + expect(allOptionValues).toContain('%IW0') + }) + + it('drops every IO producer when the target has nothing enabled (Runtime v3 baseline)', () => { + const groups = buildLocationDropdownOptions({ + cellId: 'loc-5', + pins: [ARDUINO_PIN], + remoteIOPoints: [REMOTE_POINT], + vendorIoEntries: [VPP_VENDOR_ENTRY], + capabilities: RUNTIME_V3_CAPABILITIES, + }) + + // Pin headings remain (empty); no remote or vendor groups exist. + const allOptionValues = groups.flatMap((g) => g.options.map((o) => o.value)) + expect(allOptionValues).toEqual([]) + }) + + it('surfaces persisted remote/EtherCAT points on the simulator (mirrors v4 semantics for project compatibility)', () => { + // The simulator preset enables modbusTcpRemote + ethercat as + // no-ops at the bytecode level so v4-authored projects don't + // get nagged when simulated. Pin-mapping stays off — the + // simulator has no real hardware pins to drive. + const groups = buildLocationDropdownOptions({ + cellId: 'loc-6', + pins: [ARDUINO_PIN], + remoteIOPoints: [REMOTE_POINT], + vendorIoEntries: [VPP_VENDOR_ENTRY], + capabilities: SIMULATOR_CAPABILITIES, + }) + + const allOptionValues = groups.flatMap((g) => g.options.map((o) => o.value)) + // Pin (Arduino-specific) dropped, vendor (VPP-specific) dropped, + // remote points retained. + expect(allOptionValues).toContain('%IW0') + expect(allOptionValues).not.toContain('%QX0.0') + }) + }) + + describe('option construction (per-row behavior, unchanged by the gating refactor)', () => { + it('formats pin labels as `address (alias)` when an alias is present', () => { + const groups = buildLocationDropdownOptions({ + cellId: 'cell', + pins: [ARDUINO_PIN], + remoteIOPoints: [], + vendorIoEntries: [], + capabilities: ARDUINO_CLI_CAPABILITIES, + }) + + const digitalOutputs = groups.find((g) => g.label === 'Digital Outputs') + expect(digitalOutputs?.options[0].label).toBe('%QX0.0 (arduino-led)') + }) + + it('formats pin labels as just the address when no alias is set', () => { + const groups = buildLocationDropdownOptions({ + cellId: 'cell', + pins: [{ ...ARDUINO_PIN, alias: '' }], + remoteIOPoints: [], + vendorIoEntries: [], + capabilities: ARDUINO_CLI_CAPABILITIES, + }) + + const digitalOutputs = groups.find((g) => g.label === 'Digital Outputs') + expect(digitalOutputs?.options[0].label).toBe('%QX0.0 ') + }) + + it('folds the cellId into option ids to keep React keys unique across multiple dropdowns', () => { + const groups = buildLocationDropdownOptions({ + cellId: 'unique-cell-id', + pins: [ARDUINO_PIN], + remoteIOPoints: [], + vendorIoEntries: [], + capabilities: ARDUINO_CLI_CAPABILITIES, + }) + + const digitalOutputs = groups.find((g) => g.label === 'Digital Outputs') + expect(digitalOutputs?.options[0].id).toContain('unique-cell-id') + }) + + it('preserves group ordering: pin groups → remote groups → vendor groups', () => { + const groups = buildLocationDropdownOptions({ + cellId: 'cell', + pins: [ARDUINO_PIN], + remoteIOPoints: [REMOTE_POINT], + vendorIoEntries: [VPP_VENDOR_ENTRY], + capabilities: SLM_RP4_CAPABILITIES, + }) + + // Find positional indices of the fixed pin-group labels — they + // come first. Then the remote-device group (named after the + // device) and the vendor group (named after the module/slot) + // follow in that order. We don't pin exact remote/vendor labels + // because the underlying builders own that contract; we just + // assert relative ordering. + const labels = groups.map((g) => g.label) + expect(labels.indexOf('Analog Inputs')).toBe(0) + expect(labels.indexOf('Analog Outputs')).toBe(1) + expect(labels.indexOf('Digital Inputs')).toBe(2) + expect(labels.indexOf('Digital Outputs')).toBe(3) + // Anything else is remote/vendor and must come after the four + // pin-group headings. + expect(labels.length).toBeGreaterThan(4) + }) + }) +}) diff --git a/src/frontend/utils/location-dropdown-options.ts b/src/frontend/utils/location-dropdown-options.ts new file mode 100644 index 000000000..48a2a317b --- /dev/null +++ b/src/frontend/utils/location-dropdown-options.ts @@ -0,0 +1,93 @@ +/** + * Build the option groups that fill the variables-table "Location" + * dropdown. Pure derivation from the live producer data plus the + * active target's `TargetCapabilities` — same gating semantics the + * shared address pool uses, surfaced at the UI layer so the picker + * only offers addresses the active target can actually drive. + * + * Why this gate matters: the project file persists every producer's + * state on disk regardless of which target is active (so switching + * SLM-RP4 → Arduino Mega → back doesn't lose work). Without the gate, + * the dropdown would offer addresses from producers the active target + * has deactivated — e.g. an SLM-RP4 slot 1 entry still showing while + * the user is targeting an Arduino Mega. + */ + +import type { DevicePin, IoMappingEntry } from '../../middleware/shared/ports/types' +import type { TargetCapabilities } from '../../middleware/shared/utils/target-capabilities' +import { + buildRemoteDeviceOptionGroups, + buildVendorIoOptionGroups, + type RemoteDeviceIOPoint, +} from './remote-device-options' + +type DropdownOption = { + id: string + value: string + label: string +} + +type DropdownGroup = { + label: string + options: DropdownOption[] +} + +export interface BuildLocationDropdownOptionsInput { + /** Stable identifier for the cell — folded into each option's `id` + * so React keys don't collide when multiple cells render the same + * dropdown content. */ + cellId: string + /** Pin-mapping claims (Arduino-style fixed addresses). Honored only + * when `capabilities.pinMapping` is true. */ + pins: DevicePin[] + /** Modbus TCP slave / EtherCAT remote IO points, already flattened + * by `remoteDeviceSelectors.useRemoteDeviceIOPoints`. Honored only + * when EITHER `capabilities.modbusTcpRemote` or + * `capabilities.ethercat` is true. */ + remoteIOPoints: RemoteDeviceIOPoint[] + /** VPP module IO mappings from `vendorScreenData['io-mapping']`. + * Honored only when `capabilities.vppIo` is true. */ + vendorIoEntries: IoMappingEntry[] + /** Active target's capability block (from `useTargetCapabilities`). */ + capabilities: TargetCapabilities +} + +/** + * Returns the dropdown's option groups, in the order the picker + * expects: pin groups first (Analog Inputs → Analog Outputs → + * Digital Inputs → Digital Outputs), then remote-device groups + * (one per device), then vendor IO groups. Empty groups are kept + * in the output (e.g. "Analog Outputs" stays as a heading with no + * options when the active board has no analog outputs) — the + * picker UI hides empties so callers don't need to filter. + */ +export function buildLocationDropdownOptions(input: BuildLocationDropdownOptionsInput): DropdownGroup[] { + const { cellId, pins, remoteIOPoints, vendorIoEntries, capabilities } = input + + const pinsForActiveTarget = capabilities.pinMapping ? pins : [] + const pinGroup = (pinType: DevicePin['pinType']): DropdownOption[] => + pinsForActiveTarget + .filter((pin) => pin.pinType === pinType) + .map((pin) => ({ + id: `${cellId}-${pin.pin}`, + value: pin.address, + label: `${pin.address} ${pin.alias ? `(${pin.alias})` : ''}`, + })) + + // EtherCAT and Modbus TCP slave I/O points share one bucket in the + // dropdown — the underlying builder doesn't separate them. Surface + // the bucket when EITHER capability is on; the pool drops claims + // the active target can't drive at sync time. + const remoteGroups = + capabilities.modbusTcpRemote || capabilities.ethercat ? buildRemoteDeviceOptionGroups(cellId, remoteIOPoints) : [] + const vendorGroups = capabilities.vppIo ? buildVendorIoOptionGroups(cellId, vendorIoEntries) : [] + + return [ + { label: 'Analog Inputs', options: pinGroup('analogInput') }, + { label: 'Analog Outputs', options: pinGroup('analogOutput') }, + { label: 'Digital Inputs', options: pinGroup('digitalInput') }, + { label: 'Digital Outputs', options: pinGroup('digitalOutput') }, + ...remoteGroups, + ...vendorGroups, + ] +} From 81220fcef0bf441cc20b3b072ae8b21272250407 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 2 Jun 2026 21:39:20 -0400 Subject: [PATCH 40/61] fix(pin-mapping): scope pins per target board (preserve on switch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin mappings were stored as a flat `DevicePin[]` in the project, which meant they leaked across boards: defining pin 13 on Arduino Mega and then switching to Arduino MKR left pin 13 in the mapping table even though the MKR doesn't have a pin 13 at all. The address pool already scopes claims by `caps.pinMapping`, but the underlying data wasn't keyed by board — so the pins persisted across switches and the user couldn't tell which target a saved row belonged to. Storage model: per-board keyed dict, preserved on switch -------------------------------------------------------- Picked the same persistence pattern VPP `vendorScreenData` uses — each board's pin mapping survives a target switch and reappears on return, matching the user-facing expectation that switching board shouldn't destroy work. Concretely: `DevicePinMapping.pins: DevicePin[]` → `DevicePinMapping.pinsByBoard: Record` Each entry's key is `configuration.deviceBoard` (the `BoardInfo.name`). The active selector pulls the bucket for the current board; slice actions mutate that bucket only. Boards the user hasn't touched yet behave like `[]` (the slice creates the entry on first write). On-disk migration ----------------- `devices/pin-mapping.json` becomes `Record`. The schema is a Zod union of dict + legacy flat array so projects saved before this change continue loading. When the parser sees a legacy flat array it forwards it to `setDeviceDefinitions` verbatim; the store keys it under whatever `configuration.deviceBoard` names as the active target. The next save rewrites the file in the dict shape. No manual migration required. Touched files ------------- - `DevicePinMapping` shape + `setDeviceDefinitions` accept both shapes for migration (`slices/device/types.ts`, `slices/device/slice.ts`). - `getActivePinsDraft` helper centralises "operate on the current board's bucket" across `createNewPin` / `removePin` / `updatePin`. - `setDeviceBoard` clears `currentSelectedPinTableRow` on board change so dangling row pointers don't crash the table render. - `pinSelectors.usePins` and every other in-tree consumer (alias-registry hook, vendor-screen layouts, EtherCAT editor, project-slice pool builders) read the active board's bucket via `pinsByBoard[deviceBoard] ?? []`. - Save flow serialises `pinsByBoard` directly. - Parser schema (`pinMappingFileSchema`) is a Zod union accepting both shapes. Regression coverage ------------------- 6 new tests in `device-slice.test.ts`: - Mega → MKR: pin 13 doesn't leak to MKR - Mega → MKR → Mega: pin 13 (with alias) intact on return - Board switch clears `currentSelectedPinTableRow` - createNewPin / removePin / updatePin all isolated to the active board's bucket - Legacy flat-array migration keys under active board on load - Canonical dict shape passes through verbatim 2 new tests in `parse-project-files.test.ts` pin the parser's union-shape contract (flat array + dict both round-trip). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../shared/project/project-files-schema.ts | 6 +- src/backend/shared/types/PLC/devices/pin.ts | 27 ++- .../__tests__/parse-project-files.test.ts | 31 +++ .../shared/utils/parse-project-files.ts | 28 ++- .../vendor-screen/layouts/io-table-layout.tsx | 3 +- .../layouts/module-slots-layout.tsx | 4 +- .../ethercat/ethercat-device-editor.tsx | 4 +- .../editor/device/ethercat/index.tsx | 4 +- src/frontend/hooks/use-store-selectors.ts | 21 +- src/frontend/services/save-actions.ts | 12 +- .../store/__tests__/device-slice.test.ts | 189 ++++++++++++++++-- .../store/__tests__/device-types.test.ts | 8 +- src/frontend/store/slices/device/slice.ts | 107 +++++++--- src/frontend/store/slices/device/types.ts | 28 ++- src/frontend/store/slices/project/slice.ts | 15 +- src/frontend/store/slices/shared/types.ts | 5 +- src/middleware/shared/ports/project-port.ts | 7 +- 17 files changed, 417 insertions(+), 82 deletions(-) diff --git a/src/backend/shared/project/project-files-schema.ts b/src/backend/shared/project/project-files-schema.ts index db2d36ce3..418ac6329 100644 --- a/src/backend/shared/project/project-files-schema.ts +++ b/src/backend/shared/project/project-files-schema.ts @@ -1,10 +1,12 @@ -import { deviceConfigurationSchema, devicePinSchema } from '@root/backend/shared/types/PLC/devices' +import { deviceConfigurationSchema, pinMappingFileSchema } from '@root/backend/shared/types/PLC/devices' import { PLCProjectSchema } from '@root/backend/shared/types/PLC/open-plc' export const projectDefaultFilesMapSchema = { 'project.json': PLCProjectSchema, 'devices/configuration.json': deviceConfigurationSchema, - 'devices/pin-mapping.json': devicePinSchema.array(), + // Accepts both the per-board dict (canonical) and the legacy flat + // array. See `pinMappingFileSchema` for the migration contract. + 'devices/pin-mapping.json': pinMappingFileSchema, } as const export type ProjectDefaultFilesMapKeys = keyof typeof projectDefaultFilesMapSchema export type ProjectDefaultFilesMapValues = (typeof projectDefaultFilesMapSchema)[ProjectDefaultFilesMapKeys] diff --git a/src/backend/shared/types/PLC/devices/pin.ts b/src/backend/shared/types/PLC/devices/pin.ts index 926101084..34ff494ee 100644 --- a/src/backend/shared/types/PLC/devices/pin.ts +++ b/src/backend/shared/types/PLC/devices/pin.ts @@ -38,5 +38,30 @@ const devicePinSchema = z.preprocess( ) type DevicePin = z.infer -export { devicePinSchema, pinTypes } +/** + * On-disk schema for `devices/pin-mapping.json`. Accepts both shapes + * the codebase has historically emitted so older projects keep + * loading without manual migration: + * + * - **Per-board dict** (`Record`) — the + * canonical post-migration shape. Each entry's key is a + * `BoardInfo.name` (the value of `configuration.deviceBoard`). + * Pin configuration is preserved per target so switching + * Mega ↔ MKR ↔ back doesn't lose work. + * - **Legacy flat array** (`DevicePin[]`) — what the editor wrote + * before per-board scoping landed. The store-side reload action + * (`setDeviceDefinitions`) takes the array verbatim and keys it + * under whatever `configuration.deviceBoard` names as the active + * target on first load; once the user saves again the file is + * rewritten in the dict shape. + * + * The legacy branch is kept as a union member rather than wrapped in + * preprocess so consumers can introspect which shape was on disk + * (the project-files parser doesn't need that today, but the + * cleaner contract avoids a "what did this just become?" footgun + * if a migration tool needs the distinction later). + */ +const pinMappingFileSchema = z.union([z.record(z.string(), devicePinSchema.array()), devicePinSchema.array()]) + +export { devicePinSchema, pinMappingFileSchema, pinTypes } export type { DevicePin, PinTypes } diff --git a/src/backend/shared/utils/__tests__/parse-project-files.test.ts b/src/backend/shared/utils/__tests__/parse-project-files.test.ts index 17c3e52d5..f474300df 100644 --- a/src/backend/shared/utils/__tests__/parse-project-files.test.ts +++ b/src/backend/shared/utils/__tests__/parse-project-files.test.ts @@ -482,6 +482,37 @@ describe('parseProjectFiles — pin mapping error paths', () => { expect(result.warnings).toBeDefined() expect(result.warnings!.some((w) => w.includes('pin-mapping.json') && w.includes('malformed'))).toBe(true) }) + + it('accepts the legacy flat-array shape and forwards it for store-side migration', () => { + // Pre-per-board-scoping projects wrote `DevicePin[]` to disk. The + // store's `setDeviceDefinitions` keys that array under the active + // board on load. Here we just verify the parser passes the flat + // array through verbatim — the migration responsibility is the + // store's, not the parser's (the parser doesn't know what the + // active board is from the schema alone). + const legacy = JSON.stringify([{ pin: '13', pinType: 'digitalOutput', address: '%QX0.0', alias: 'led' }]) + const result = parseProjectFiles('/p', makeProjectJson(), makeDeviceConfig(), legacy, [], [], []) + expect(Array.isArray(result.devicePinMapping)).toBe(true) + expect(result.devicePinMapping).toEqual([ + { pin: '13', pinType: 'digitalOutput', address: '%QX0.0', alias: 'led' }, + ]) + }) + + it('accepts the canonical per-board dict shape (post-migration)', () => { + // Projects saved by post-migration editors write a per-board dict. + // Each key is a `BoardInfo.name`, each value is that board's pin + // array. The parser passes it through verbatim. + const dict = JSON.stringify({ + 'Arduino Mega': [{ pin: '13', pinType: 'digitalOutput', address: '%QX0.0', alias: 'led' }], + 'Arduino MKR WiFi 1010': [{ pin: 'A0', pinType: 'analogInput', address: '%IW0', alias: 'sensor' }], + }) + const result = parseProjectFiles('/p', makeProjectJson(), makeDeviceConfig(), dict, [], [], []) + expect(Array.isArray(result.devicePinMapping)).toBe(false) + expect(result.devicePinMapping).toEqual({ + 'Arduino Mega': [{ pin: '13', pinType: 'digitalOutput', address: '%QX0.0', alias: 'led' }], + 'Arduino MKR WiFi 1010': [{ pin: 'A0', pinType: 'analogInput', address: '%IW0', alias: 'sensor' }], + }) + }) }) // --------------------------------------------------------------------------- diff --git a/src/backend/shared/utils/parse-project-files.ts b/src/backend/shared/utils/parse-project-files.ts index 6a5d746b4..47e801548 100644 --- a/src/backend/shared/utils/parse-project-files.ts +++ b/src/backend/shared/utils/parse-project-files.ts @@ -27,7 +27,7 @@ import type { PLCTask, PLCVariable, } from '../../../middleware/shared/ports/types' -import { deviceConfigurationSchema, devicePinSchema } from '../types/PLC/devices' +import { deviceConfigurationSchema, pinMappingFileSchema } from '../types/PLC/devices' import { PLCProjectSchema, PLCRemoteDeviceSchema, PLCServerSchema } from '../types/PLC/open-plc' import { getDefaultSchemaValues } from './default-zod-schema-values' @@ -70,7 +70,13 @@ export interface ParsedProjectData { debugVariables?: { global?: string[]; pous?: Record } } deviceConfiguration?: DeviceConfiguration - devicePinMapping?: DevicePin[] + /** Pin mappings parsed from `devices/pin-mapping.json`. Forwarded + * to the store's `setDeviceDefinitions`, which accepts BOTH: + * - `DevicePin[]` (legacy flat array, pre-per-board-scoping) — + * gets keyed under `deviceConfiguration.deviceBoard` on load. + * - `Record` (per-board dict, canonical) — + * taken verbatim, one entry per target the user has touched. */ + devicePinMapping?: DevicePin[] | Record /** Warnings collected during parsing (e.g. dropped files that failed validation). */ warnings?: string[] } @@ -383,26 +389,30 @@ export function parseProjectFiles( deviceConfiguration = getDefaultSchemaValues(deviceConfigurationSchema) as DeviceConfiguration } - // Parse and Zod-validate pin mapping - const pinMappingSchema = devicePinSchema.array() - let devicePinMapping: DevicePin[] | undefined + // Parse and Zod-validate pin mapping. The on-disk schema is a union + // of `Record` (canonical per-board dict) and + // `DevicePin[]` (legacy flat array). The store-side + // `setDeviceDefinitions` accepts both shapes; the legacy branch is + // keyed under whatever `configuration.deviceBoard` resolves to on + // first load and rewritten in the dict shape on next save. + let devicePinMapping: DevicePin[] | Record | undefined try { const raw = pinMapping ? (JSON.parse(pinMapping) as unknown) : null if (raw) { - const result = pinMappingSchema.safeParse(raw) + const result = pinMappingFileSchema.safeParse(raw) if (result.success) { devicePinMapping = result.data } else { console.error('[parseProjectFiles] devices/pin-mapping.json Zod errors:', result.error.issues) warnings.push('devices/pin-mapping.json has invalid structure and was loaded with defaults.') - devicePinMapping = getDefaultSchemaValues(pinMappingSchema) as DevicePin[] + devicePinMapping = {} } } else { - devicePinMapping = getDefaultSchemaValues(pinMappingSchema) as DevicePin[] + devicePinMapping = {} } } catch { warnings.push('devices/pin-mapping.json is malformed and could not be read. Using defaults.') - devicePinMapping = getDefaultSchemaValues(pinMappingSchema) as DevicePin[] + devicePinMapping = {} } // Deduplicate POU files (prefer text-based over JSON when both exist) diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/io-table-layout.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/io-table-layout.tsx index e5d24ca62..634da9df0 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/io-table-layout.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/io-table-layout.tsx @@ -35,7 +35,8 @@ function IoTableLayout({ section, moduleSystem }: IoTableLayoutProps) { slotsConfig: moduleConfig?.slotsConfig ?? {}, storedMapping, remoteDevices: state.project.data.remoteDevices ?? [], - pinMappingPins: state.deviceDefinitions.pinMapping.pins, + pinMappingPins: + state.deviceDefinitions.pinMapping.pinsByBoard[state.deviceDefinitions.configuration.deviceBoard] ?? [], capabilities: resolveTargetCapabilities(boardInfo), } }, [persistenceKey]) diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/module-slots-layout.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/module-slots-layout.tsx index 864094184..a44398d8d 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/module-slots-layout.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/module-slots-layout.tsx @@ -334,8 +334,10 @@ function ModuleSlotsLayout({ section, moduleSystem }: ModuleSlotsLayoutProps) { // VPP slots are being regenerated, so the pool excludes the // current vpp-io claims and includes everything else (pin mapping, // modbus remote, EtherCAT) when active for the target. + const activePins = + state.deviceDefinitions.pinMapping.pinsByBoard[state.deviceDefinitions.configuration.deviceBoard] ?? [] const pool = buildAddressPool( - { pinMapping: { pins: state.deviceDefinitions.pinMapping.pins }, remoteDevices }, + { pinMapping: { pins: activePins }, remoteDevices }, capabilities, { ignoreSource: 'vpp-io' }, ) diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx index 460054d68..36f60b4d4 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx @@ -117,9 +117,11 @@ const EtherCATDeviceEditor = ({ busName: propBusName, deviceId: propDeviceId }: | { entries?: { iecAddress: string; alias?: string; slot: number; channelName: string }[] } | undefined )?.entries ?? [] + const activePins = + state.deviceDefinitions.pinMapping.pinsByBoard[state.deviceDefinitions.configuration.deviceBoard] ?? [] const pool = buildAddressPool( { - pinMapping: { pins: state.deviceDefinitions.pinMapping.pins }, + pinMapping: { pins: activePins }, vendorIoMapping: { entries: ioMapping }, remoteDevices: project.data.remoteDevices, }, diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx index 7eeb1c87c..91a4b4a6f 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -52,9 +52,11 @@ function buildClaimedAddressSet( | { entries?: { iecAddress: string; alias?: string; slot: number; channelName: string }[] } | undefined )?.entries ?? [] + const activePins = + state.deviceDefinitions.pinMapping.pinsByBoard[state.deviceDefinitions.configuration.deviceBoard] ?? [] const pool = buildAddressPool( { - pinMapping: { pins: state.deviceDefinitions.pinMapping.pins }, + pinMapping: { pins: activePins }, vendorIoMapping: { entries: ioMapping }, remoteDevices, }, diff --git a/src/frontend/hooks/use-store-selectors.ts b/src/frontend/hooks/use-store-selectors.ts index 36e575aab..65de31ac0 100644 --- a/src/frontend/hooks/use-store-selectors.ts +++ b/src/frontend/hooks/use-store-selectors.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react' -import type { IoMappingEntry, VendorIoMapping } from '../../middleware/shared/ports/types' +import type { DevicePin, IoMappingEntry, VendorIoMapping } from '../../middleware/shared/ports/types' import { useOpenPLCStore } from '../store' type RemoteDeviceIOPoint = { @@ -23,6 +23,12 @@ type RemoteDeviceIOPoint = { // where the field could transiently be undefined. const EMPTY_SELECTED_PLATFORM_OPTIONS: Record = Object.freeze({}) as Record +// Same Zustand-stability rationale as EMPTY_SELECTED_PLATFORM_OPTIONS: +// `pinSelectors.usePins` falls back to this when the active board has +// no entry yet in the per-board pin-mapping dict. A fresh `[]` literal +// per render would churn every component subscribed to the pins. +const EMPTY_PINS: readonly DevicePin[] = Object.freeze([]) as readonly DevicePin[] + const boardSelectors = { useAvailableBoards: () => useOpenPLCStore((state) => state.deviceAvailableOptions.availableBoards), useAvailableCommunicationPorts: () => @@ -40,7 +46,18 @@ const boardSelectors = { } const pinSelectors = { - usePins: () => useOpenPLCStore((state) => state.deviceDefinitions.pinMapping.pins), + /** Active board's pin array — the bucket keyed by + * `configuration.deviceBoard` in the per-board pin-mapping dict. + * Returns the canonical empty array when the active board has no + * entry yet so consumers can render an "empty" pin table without + * null-checks. Same empty reference is reused across renders + * (Zustand re-renders only when the selector return-value identity + * changes; a fresh `[]` literal would churn every render). */ + usePins: () => + useOpenPLCStore( + (state) => + state.deviceDefinitions.pinMapping.pinsByBoard[state.deviceDefinitions.configuration.deviceBoard] ?? EMPTY_PINS, + ), useCreateNewPin: () => useOpenPLCStore((state) => state.deviceActions.createNewPin), useRemovePin: () => useOpenPLCStore((state) => state.deviceActions.removePin), useUpdatePin: () => useOpenPLCStore((state) => state.deviceActions.updatePin), diff --git a/src/frontend/services/save-actions.ts b/src/frontend/services/save-actions.ts index c5003ca6d..d27dcdc59 100644 --- a/src/frontend/services/save-actions.ts +++ b/src/frontend/services/save-actions.ts @@ -147,7 +147,13 @@ function* iterateProjectFiles(state: StoreState): Generator { yield { path: 'devices/pin-mapping.json', - content: JSON.stringify(deviceDefinitions.pinMapping.pins, null, 2), + // Serialise the full per-board dict — each board's pins are + // preserved on disk even when it's not the active target, so a + // user switching Mega → MKR → back to Mega gets their Mega + // pin-mapping work back. The parser accepts both this dict + // shape and the legacy flat array (which it auto-migrates by + // keying under the active board on load). + content: JSON.stringify(deviceDefinitions.pinMapping.pinsByBoard, null, 2), category: 'pin-mapping', } } @@ -202,7 +208,9 @@ function serializeProjectFile( }, { path: 'devices/pin-mapping.json', - content: JSON.stringify(deviceDefinitions.pinMapping.pins, null, 2), + // Per-board dict — see the matching comment in + // serializeAllProjectFiles for the rationale. + content: JSON.stringify(deviceDefinitions.pinMapping.pinsByBoard, null, 2), category: 'pin-mapping', }, ] diff --git a/src/frontend/store/__tests__/device-slice.test.ts b/src/frontend/store/__tests__/device-slice.test.ts index 43add0489..4e5413b82 100644 --- a/src/frontend/store/__tests__/device-slice.test.ts +++ b/src/frontend/store/__tests__/device-slice.test.ts @@ -31,6 +31,18 @@ function makeStore() { })) } +/** + * Returns the active board's pin array — the post-refactor shape + * keys pins by `configuration.deviceBoard`, so tests that used to + * read `pinMapping.pins` directly look up the active bucket here. + * Defaults the empty array so tests against a fresh store (where + * no actions have created the bucket yet) still get `[]`. + */ +function activePins(state: { deviceDefinitions: DeviceSlice['deviceDefinitions'] }): DevicePin[] { + const board = state.deviceDefinitions.configuration.deviceBoard + return state.deviceDefinitions.pinMapping.pinsByBoard[board] ?? [] +} + function makePin(overrides?: Partial): DevicePin { return { pin: overrides?.pin ?? '', @@ -114,7 +126,7 @@ describe('createDeviceSlice', () => { const store = makeStore() const s = store.getState() expect(s.deviceDefinitions.configuration).toEqual(defaultDeviceConfiguration) - expect(s.deviceDefinitions.pinMapping.pins).toEqual([]) + expect(activePins(s)).toEqual([]) expect(s.deviceDefinitions.pinMapping.currentSelectedPinTableRow).toBe(-1) }) @@ -209,7 +221,7 @@ describe('createDeviceSlice', () => { const store = makeStore() const pins: DevicePin[] = [makePin({ pin: 'A0', pinType: 'analogInput', address: '%IW0', alias: 'sensor' })] store.getState().deviceActions.setDeviceDefinitions({ pinMapping: pins }) - expect(store.getState().deviceDefinitions.pinMapping.pins).toEqual(pins) + expect(activePins(store.getState())).toEqual(pins) expect(store.getState().deviceDefinitions.pinMapping.currentSelectedPinTableRow).toBe(-1) }) @@ -232,11 +244,11 @@ describe('createDeviceSlice', () => { it('handles call with neither configuration nor pinMapping', () => { const store = makeStore() - const before = store.getState().deviceDefinitions + const beforePins = activePins(store.getState()) store.getState().deviceActions.setDeviceDefinitions({}) - const after = store.getState().deviceDefinitions - expect(after.configuration).toEqual(before.configuration) - expect(after.pinMapping.pins).toEqual(before.pinMapping.pins) + const afterPins = activePins(store.getState()) + expect(store.getState().deviceDefinitions.configuration).toEqual(defaultDeviceConfiguration) + expect(afterPins).toEqual(beforePins) }) }) @@ -257,7 +269,7 @@ describe('createDeviceSlice', () => { pinMapping: [makePin()], }) store.getState().deviceActions.clearDeviceDefinitions() - expect(store.getState().deviceDefinitions.pinMapping.pins).toEqual([]) + expect(activePins(store.getState())).toEqual([]) expect(store.getState().deviceDefinitions.pinMapping.currentSelectedPinTableRow).toBe(-1) }) @@ -332,7 +344,7 @@ describe('createDeviceSlice', () => { it('creates a pin in empty table', () => { const store = makeStore() store.getState().deviceActions.createNewPin() - const { pins, currentSelectedPinTableRow } = store.getState().deviceDefinitions.pinMapping + const pins = activePins(store.getState()); const { currentSelectedPinTableRow } = store.getState().deviceDefinitions.pinMapping expect(pins).toHaveLength(1) expect(pins[0].pinType).toBe('digitalInput') expect(pins[0].address).toBe('%IX0.0') @@ -349,7 +361,7 @@ describe('createDeviceSlice', () => { store.getState().deviceActions.selectPinTableRow(0) store.getState().deviceActions.createNewPin() - const { pins, currentSelectedPinTableRow } = store.getState().deviceDefinitions.pinMapping + const pins = activePins(store.getState()); const { currentSelectedPinTableRow } = store.getState().deviceDefinitions.pinMapping expect(pins).toHaveLength(2) expect(pins[1].address).toBe('%IX0.1') expect(pins[1].pinType).toBe('digitalInput') @@ -369,7 +381,7 @@ describe('createDeviceSlice', () => { store.getState().deviceActions.selectPinTableRow(0) store.getState().deviceActions.createNewPin() - const { pins, currentSelectedPinTableRow } = store.getState().deviceDefinitions.pinMapping + const pins = activePins(store.getState()); const { currentSelectedPinTableRow } = store.getState().deviceDefinitions.pinMapping expect(pins).toHaveLength(3) // New pin should be after the highest address (%IX0.1) -> %IX0.2 expect(pins[2].address).toBe('%IX0.2') @@ -384,7 +396,7 @@ describe('createDeviceSlice', () => { // Keep selection at -1 store.getState().deviceActions.createNewPin() - const { pins } = store.getState().deviceDefinitions.pinMapping + const pins = activePins(store.getState()) expect(pins).toHaveLength(2) // It should be pushed to end, using the highest existing + 1 expect(pins[1].address).toBe('%IX0.1') @@ -402,7 +414,7 @@ describe('createDeviceSlice', () => { }) // row is -1 by default from setDeviceDefinitions store.getState().deviceActions.removePin() - expect(store.getState().deviceDefinitions.pinMapping.pins).toHaveLength(1) + expect(activePins(store.getState())).toHaveLength(1) }) it('removes the selected pin and decrements higher addresses', () => { @@ -417,7 +429,7 @@ describe('createDeviceSlice', () => { store.getState().deviceActions.selectPinTableRow(0) store.getState().deviceActions.removePin() - const { pins, currentSelectedPinTableRow } = store.getState().deviceDefinitions.pinMapping + const pins = activePins(store.getState()); const { currentSelectedPinTableRow } = store.getState().deviceDefinitions.pinMapping expect(pins).toHaveLength(2) // Addresses shifted down expect(pins[0].address).toBe('%IX0.0') @@ -434,7 +446,7 @@ describe('createDeviceSlice', () => { store.getState().deviceActions.selectPinTableRow(0) store.getState().deviceActions.removePin() - expect(store.getState().deviceDefinitions.pinMapping.pins).toHaveLength(0) + expect(activePins(store.getState())).toHaveLength(0) expect(store.getState().deviceDefinitions.pinMapping.currentSelectedPinTableRow).toBe(-1) }) @@ -449,7 +461,7 @@ describe('createDeviceSlice', () => { store.getState().deviceActions.selectPinTableRow(1) // last row store.getState().deviceActions.removePin() - expect(store.getState().deviceDefinitions.pinMapping.pins).toHaveLength(1) + expect(activePins(store.getState())).toHaveLength(1) expect(store.getState().deviceDefinitions.pinMapping.currentSelectedPinTableRow).toBe(0) }) @@ -465,7 +477,7 @@ describe('createDeviceSlice', () => { store.getState().deviceActions.selectPinTableRow(0) // remove D0 store.getState().deviceActions.removePin() - const pins = store.getState().deviceDefinitions.pinMapping.pins + const pins = activePins(store.getState()) expect(pins).toHaveLength(2) // analog should be untouched const analog = pins.find((p) => p.pinType === 'analogInput') @@ -497,7 +509,7 @@ describe('createDeviceSlice', () => { const result = store.getState().deviceActions.updatePin({ pin: 'D3' }) expect(result.ok).toBe(true) expect(result.data?.pin).toBe('D3') - expect(store.getState().deviceDefinitions.pinMapping.pins[0].pin).toBe('D3') + expect(activePins(store.getState())[0].pin).toBe('D3') }) it('returns error for empty pin', () => { @@ -552,7 +564,7 @@ describe('createDeviceSlice', () => { expect(result.message).toContain('Pin type changed') // Verify sorting and current selection - const { pins, currentSelectedPinTableRow } = store.getState().deviceDefinitions.pinMapping + const pins = activePins(store.getState()); const { currentSelectedPinTableRow } = store.getState().deviceDefinitions.pinMapping const movedPin = pins.find((p) => p.pin === 'D0') expect(movedPin?.pinType).toBe('analogInput') expect(movedPin?.address).toBe('%IW1') @@ -571,7 +583,7 @@ describe('createDeviceSlice', () => { store.getState().deviceActions.selectPinTableRow(0) // move D0 to analog store.getState().deviceActions.updatePin({ pinType: 'analogInput' }) - const pins = store.getState().deviceDefinitions.pinMapping.pins + const pins = activePins(store.getState()) const digitalPins = pins.filter((p) => p.pinType === 'digitalInput') // After removing D0 (%IX0.0), D1 should be %IX0.0, D2 should be %IX0.1 expect(digitalPins[0].address).toBe('%IX0.0') @@ -614,7 +626,7 @@ describe('createDeviceSlice', () => { const result = store.getState().deviceActions.updatePin({ alias: 'Sensor1' }) expect(result.ok).toBe(true) expect(result.data?.alias).toBe('Sensor1') - expect(store.getState().deviceDefinitions.pinMapping.pins[0].alias).toBe('Sensor1') + expect(activePins(store.getState())[0].alias).toBe('Sensor1') }) it('returns error for empty name', () => { @@ -671,7 +683,7 @@ describe('createDeviceSlice', () => { const result = store.getState().deviceActions.updatePin({ pin: undefined }) expect(result.ok).toBe(true) expect(result.data?.pin).toBe('') - expect(store.getState().deviceDefinitions.pinMapping.pins[0].pin).toBe('') + expect(activePins(store.getState())[0].pin).toBe('') spy.mockRestore() }) }) @@ -757,6 +769,141 @@ describe('createDeviceSlice', () => { }) }) + // ----------------------------------------------------------------------- + // Per-target pin scoping — regression for the SLM-RP4 → Mega → MKR + // → Mega chain. Each target has its own pinout (a Mega's pin 13 + // doesn't exist on a MKR), so pins must NEVER leak between boards. + // Per-board persistence is the chosen contract: a user's work on + // board A survives a switch to board B and reappears when they + // come back to A. + // ----------------------------------------------------------------------- + describe('per-target pin-mapping scoping', () => { + it('isolates pin entries across boards: pin 13 defined on Mega does NOT appear on MKR', () => { + const store = makeStore() + const actions = store.getState().deviceActions + + actions.setDeviceBoard('Arduino Mega') + actions.setDeviceDefinitions({ + pinMapping: [makePin({ pin: '13', pinType: 'digitalOutput', address: '%QX0.0' })], + }) + expect(activePins(store.getState())).toHaveLength(1) + expect(activePins(store.getState())[0].pin).toBe('13') + + actions.setDeviceBoard('Arduino MKR WiFi 1010') + expect(activePins(store.getState())).toHaveLength(0) + }) + + it('preserves each board’s pins across a board switch: Mega → MKR → back to Mega restores pin 13', () => { + const store = makeStore() + const actions = store.getState().deviceActions + + actions.setDeviceBoard('Arduino Mega') + actions.setDeviceDefinitions({ + pinMapping: [makePin({ pin: '13', pinType: 'digitalOutput', address: '%QX0.0', alias: 'led-13' })], + }) + + actions.setDeviceBoard('Arduino MKR WiFi 1010') + expect(activePins(store.getState())).toHaveLength(0) + // Adding a pin on MKR mutates MKR's bucket only. + actions.createNewPin() + expect(activePins(store.getState())).toHaveLength(1) + + // Back to Mega — pin 13 with its alias must be intact. + actions.setDeviceBoard('Arduino Mega') + const megaPins = activePins(store.getState()) + expect(megaPins).toHaveLength(1) + expect(megaPins[0].pin).toBe('13') + expect(megaPins[0].alias).toBe('led-13') + // And MKR's bucket still carries its own pin (untouched by the + // Mega-side mutations). + expect(store.getState().deviceDefinitions.pinMapping.pinsByBoard['Arduino MKR WiFi 1010']).toHaveLength(1) + }) + + it('resets the selected-row pointer when the board changes so the new board’s table starts unselected', () => { + const store = makeStore() + const actions = store.getState().deviceActions + + actions.setDeviceBoard('Arduino Mega') + actions.setDeviceDefinitions({ + pinMapping: [makePin({ pin: '13', pinType: 'digitalOutput', address: '%QX0.0' })], + }) + actions.selectPinTableRow(0) + expect(store.getState().deviceDefinitions.pinMapping.currentSelectedPinTableRow).toBe(0) + + // Switching boards must clear the row pointer — the new board's + // bucket may be empty or have a different row count, and a + // dangling pointer would crash the table's "currently selected + // pin" rendering. + actions.setDeviceBoard('Arduino MKR WiFi 1010') + expect(store.getState().deviceDefinitions.pinMapping.currentSelectedPinTableRow).toBe(-1) + }) + + it('createNewPin / removePin / updatePin all mutate only the active board’s bucket', () => { + const store = makeStore() + const actions = store.getState().deviceActions + + // Seed Mega with one pin so it's identifiable. + actions.setDeviceBoard('Arduino Mega') + actions.setDeviceDefinitions({ + pinMapping: [makePin({ pin: '13', pinType: 'digitalOutput', address: '%QX0.0', alias: 'led-13' })], + }) + + // Switch to MKR and drive a representative mutating action. + actions.setDeviceBoard('Arduino MKR WiFi 1010') + actions.createNewPin() + actions.selectPinTableRow(0) + actions.updatePin({ pin: 'A0' }) + + // Mega's bucket is unchanged by the MKR-side mutation. + const megaBucket = store.getState().deviceDefinitions.pinMapping.pinsByBoard['Arduino Mega'] + expect(megaBucket).toHaveLength(1) + expect(megaBucket[0].pin).toBe('13') + expect(megaBucket[0].alias).toBe('led-13') + + // MKR's bucket has the new pin under its own key. + const mkrBucket = store.getState().deviceDefinitions.pinMapping.pinsByBoard['Arduino MKR WiFi 1010'] + expect(mkrBucket).toHaveLength(1) + expect(mkrBucket[0].pin).toBe('A0') + + // Removing the MKR pin doesn't touch Mega. + actions.removePin() + expect(store.getState().deviceDefinitions.pinMapping.pinsByBoard['Arduino MKR WiFi 1010']).toHaveLength(0) + expect(store.getState().deviceDefinitions.pinMapping.pinsByBoard['Arduino Mega']).toHaveLength(1) + }) + + it('migrates a legacy flat-array `pinMapping` to the active board’s bucket on load', () => { + // Projects saved before per-board scoping wrote a flat array. + // The store-side action keys that array under whatever board + // the accompanying configuration names — so a legacy project + // continues to work without manual migration. + const store = makeStore() + store.getState().deviceActions.setDeviceDefinitions({ + configuration: { deviceBoard: 'Arduino Mega' }, + pinMapping: [makePin({ pin: '13', pinType: 'digitalOutput', address: '%QX0.0' })], + }) + + const byBoard = store.getState().deviceDefinitions.pinMapping.pinsByBoard + expect(Object.keys(byBoard)).toEqual(['Arduino Mega']) + expect(byBoard['Arduino Mega']).toHaveLength(1) + expect(byBoard['Arduino Mega'][0].pin).toBe('13') + }) + + it('accepts the canonical per-board dict shape verbatim', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceDefinitions({ + configuration: { deviceBoard: 'Arduino Mega' }, + pinMapping: { + 'Arduino Mega': [makePin({ pin: '13', pinType: 'digitalOutput', address: '%QX0.0' })], + 'Arduino MKR WiFi 1010': [makePin({ pin: 'A0', pinType: 'analogInput', address: '%IW0' })], + }, + }) + + const byBoard = store.getState().deviceDefinitions.pinMapping.pinsByBoard + expect(byBoard['Arduino Mega']).toHaveLength(1) + expect(byBoard['Arduino MKR WiFi 1010']).toHaveLength(1) + }) + }) + describe('setSelectedPlatformOption', () => { it('stores a single key/value and marks updated', () => { const store = makeStore() diff --git a/src/frontend/store/__tests__/device-types.test.ts b/src/frontend/store/__tests__/device-types.test.ts index f557ce10e..a1ed67098 100644 --- a/src/frontend/store/__tests__/device-types.test.ts +++ b/src/frontend/store/__tests__/device-types.test.ts @@ -37,12 +37,12 @@ describe('Device slice types', () => { // DevicePinMapping // ----------------------------------------------------------------------- describe('DevicePinMapping', () => { - it('has pins array and selected row', () => { + it('has per-board pins dict and selected row', () => { const mapping: DevicePinMapping = { - pins: [], + pinsByBoard: {}, currentSelectedPinTableRow: -1, } - expect(mapping.pins).toEqual([]) + expect(mapping.pinsByBoard).toEqual({}) expect(mapping.currentSelectedPinTableRow).toBe(-1) }) }) @@ -169,7 +169,7 @@ describe('Device slice types', () => { deviceBoard: '', communicationPort: '', }, - pinMapping: { pins: [], currentSelectedPinTableRow: -1 }, + pinMapping: { pinsByBoard: {}, currentSelectedPinTableRow: -1 }, }, deviceUpdated: { updated: false }, runtimeConnection: { diff --git a/src/frontend/store/slices/device/slice.ts b/src/frontend/store/slices/device/slice.ts index 71a81b3a7..1b7ceb4c5 100644 --- a/src/frontend/store/slices/device/slice.ts +++ b/src/frontend/store/slices/device/slice.ts @@ -12,6 +12,25 @@ import { removeAddressPrefix, } from './validation/pins' +/** + * Lazily resolve the active board's pin array on an Immer draft, + * creating the entry the first time a board claims pins. Returns a + * reference that's safe to mutate (push / splice / index-assign) — + * the surrounding `produce()` call captures the changes. + * + * Centralising this keeps every action's "operate on the current + * board's pins" intent obvious and prevents a stale write when the + * dict didn't yet have a key for the active board (which would + * otherwise crash with `Cannot read properties of undefined`). + */ +function getActivePinsDraft(draft: DeviceSlice): DevicePin[] { + const board = draft.deviceDefinitions.configuration.deviceBoard + if (!draft.deviceDefinitions.pinMapping.pinsByBoard[board]) { + draft.deviceDefinitions.pinMapping.pinsByBoard[board] = [] + } + return draft.deviceDefinitions.pinMapping.pinsByBoard[board] +} + const createDeviceSlice: StateCreator = (setState, getState) => ({ deviceAvailableOptions: { availableBoards: new Map(), @@ -20,7 +39,7 @@ const createDeviceSlice: StateCreator = (s deviceDefinitions: { configuration: defaultDeviceConfiguration, pinMapping: { - pins: [], + pinsByBoard: {}, currentSelectedPinTableRow: -1, }, }, @@ -85,7 +104,18 @@ const createDeviceSlice: StateCreator = (s } } if (pinMapping) { - deviceDefinitions.pinMapping.pins = pinMapping + // Two shapes are accepted by design — see DeviceActions.setDeviceDefinitions. + // Legacy flat array attaches to whatever board the + // accompanying configuration names (or the current + // store value if no configuration was passed). This + // is the migration path for projects saved before + // per-board scoping landed. + if (Array.isArray(pinMapping)) { + const targetBoard = deviceDefinitions.configuration.deviceBoard + deviceDefinitions.pinMapping.pinsByBoard = targetBoard ? { [targetBoard]: pinMapping } : {} + } else { + deviceDefinitions.pinMapping.pinsByBoard = { ...pinMapping } + } deviceDefinitions.pinMapping.currentSelectedPinTableRow = -1 } }), @@ -96,7 +126,7 @@ const createDeviceSlice: StateCreator = (s produce(({ deviceDefinitions, runtimeConnection }: DeviceSlice) => { deviceDefinitions.configuration = defaultDeviceConfiguration deviceDefinitions.pinMapping = { - pins: [], + pinsByBoard: {}, currentSelectedPinTableRow: -1, } runtimeConnection.jwtToken = null @@ -129,12 +159,14 @@ const createDeviceSlice: StateCreator = (s createNewPin: (): void => { setState( - produce(({ deviceDefinitions: { pinMapping }, deviceUpdated }: DeviceSlice) => { - deviceUpdated.updated = true + produce((draft: DeviceSlice) => { + draft.deviceUpdated.updated = true + const pins = getActivePinsDraft(draft) + const { pinMapping } = draft.deviceDefinitions - const referencePin = pinMapping.pins[pinMapping.currentSelectedPinTableRow] + const referencePin = pins[pinMapping.currentSelectedPinTableRow] const defaultPinType = 'digitalInput' - const nextHighestPinAddress = getHighestPinAddress(pinMapping.pins, defaultPinType) + const nextHighestPinAddress = getHighestPinAddress(pins, defaultPinType) const nextAddress = createNewAddress('INCREMENT', nextHighestPinAddress) let newPin: DevicePin = { @@ -145,23 +177,23 @@ const createDeviceSlice: StateCreator = (s } if (pinMapping.currentSelectedPinTableRow === -1 || !referencePin) { - pinMapping.pins.push(newPin) - pinMapping.currentSelectedPinTableRow = pinMapping.pins.length - 1 + pins.push(newPin) + pinMapping.currentSelectedPinTableRow = pins.length - 1 return } const newAddress = createNewAddress('INCREMENT', referencePin.address) - const pinExists = pinMapping.pins.find((pin) => pin.address === newAddress) + const pinExists = pins.find((pin) => pin.address === newAddress) if (!pinExists) { newPin = { pin: '', pinType: referencePin.pinType, address: newAddress, alias: '' } - pinMapping.pins.splice(pinMapping.currentSelectedPinTableRow + 1, 0, newPin) + pins.splice(pinMapping.currentSelectedPinTableRow + 1, 0, newPin) pinMapping.currentSelectedPinTableRow += 1 return } - const highestPinAddress = getHighestPinAddress(pinMapping.pins, pinExists.pinType) - const indexOfHighestPinAddress = pinMapping.pins.findIndex((pin) => pin.address === highestPinAddress) + const highestPinAddress = getHighestPinAddress(pins, pinExists.pinType) + const indexOfHighestPinAddress = pins.findIndex((pin) => pin.address === highestPinAddress) const newAddressForHighestPinAddress = createNewAddress('INCREMENT', highestPinAddress) const newPinForHighestPinAddress = { pin: '', @@ -170,23 +202,25 @@ const createDeviceSlice: StateCreator = (s alias: '', } - pinMapping.pins.splice(indexOfHighestPinAddress + 1, 0, newPinForHighestPinAddress) + pins.splice(indexOfHighestPinAddress + 1, 0, newPinForHighestPinAddress) pinMapping.currentSelectedPinTableRow = indexOfHighestPinAddress + 1 }), ) }, removePin: (): void => { setState( - produce(({ deviceDefinitions: { pinMapping }, deviceUpdated }: DeviceSlice) => { - deviceUpdated.updated = true + produce((draft: DeviceSlice) => { + draft.deviceUpdated.updated = true + const pins = getActivePinsDraft(draft) + const { pinMapping } = draft.deviceDefinitions - const referencePin = pinMapping.pins[pinMapping.currentSelectedPinTableRow] + const referencePin = pins[pinMapping.currentSelectedPinTableRow] if (pinMapping.currentSelectedPinTableRow === -1 || !referencePin) return const referencePinType = referencePin.pinType const referencePinAddressPosition = Number(removeAddressPrefix(referencePin.address)) - pinMapping.pins.forEach((pin) => { + pins.forEach((pin) => { if ( pin.pinType === referencePinType && Number(removeAddressPrefix(pin.address)) > referencePinAddressPosition @@ -196,13 +230,13 @@ const createDeviceSlice: StateCreator = (s }) const selectedRow = - pinMapping.pins.length - 1 > 0 - ? pinMapping.pins.length - 1 === pinMapping.currentSelectedPinTableRow + pins.length - 1 > 0 + ? pins.length - 1 === pinMapping.currentSelectedPinTableRow ? Math.max(pinMapping.currentSelectedPinTableRow - 1, 0) : pinMapping.currentSelectedPinTableRow : -1 - pinMapping.pins.splice(pinMapping.currentSelectedPinTableRow, 1) + pins.splice(pinMapping.currentSelectedPinTableRow, 1) pinMapping.currentSelectedPinTableRow = selectedRow }), ) @@ -215,10 +249,13 @@ const createDeviceSlice: StateCreator = (s data: { pin: '', pinType: '', address: '', alias: '' }, } setState( - produce(({ deviceDefinitions: { pinMapping }, deviceUpdated }: DeviceSlice) => { - deviceUpdated.updated = true + produce((draft: DeviceSlice) => { + draft.deviceUpdated.updated = true + const pins = getActivePinsDraft(draft) + const { pinMapping } = draft.deviceDefinitions + const activeBoard = draft.deviceDefinitions.configuration.deviceBoard - const currentPin = pinMapping.pins[pinMapping.currentSelectedPinTableRow] + const currentPin = pins[pinMapping.currentSelectedPinTableRow] if (!currentPin) { returnMessage.ok = false @@ -230,7 +267,7 @@ const createDeviceSlice: StateCreator = (s for (const key in updatedData) { switch (key) { case 'pin': { - const validation = checkIfPinIsValid(pinMapping.pins, updatedData.pin) + const validation = checkIfPinIsValid(pins, updatedData.pin) if (!validation.ok) { returnMessage.ok = false returnMessage.title = validation.title @@ -251,7 +288,7 @@ const createDeviceSlice: StateCreator = (s const originalIndex = pinMapping.currentSelectedPinTableRow - const newPinsArray = pinMapping.pins + const newPinsArray = pins .filter((_, index) => index !== originalIndex) .map((p) => { if (p.pinType === oldPinType && Number(removeAddressPrefix(p.address)) > oldAddressPosition) { @@ -284,9 +321,11 @@ const createDeviceSlice: StateCreator = (s return Number(removeAddressPrefix(a.address)) - Number(removeAddressPrefix(b.address)) }) - pinMapping.pins = newPinsArray - - pinMapping.currentSelectedPinTableRow = pinMapping.pins.findIndex((p) => p === currentPin) + // Replace the active board's bucket with the + // re-sorted array. Re-resolve currentPin's index + // by identity — the sort moved it. + pinMapping.pinsByBoard[activeBoard] = newPinsArray + pinMapping.currentSelectedPinTableRow = newPinsArray.findIndex((p) => p === currentPin) returnMessage.data!.pinType = newPinType returnMessage.data!.address = finalAddress @@ -308,7 +347,7 @@ const createDeviceSlice: StateCreator = (s break case 'alias': { - const validation = checkIfPinAliasIsValid(pinMapping.pins, updatedData.alias) + const validation = checkIfPinAliasIsValid(pins, updatedData.alias) if (!validation.ok) { returnMessage.ok = false returnMessage.title = validation.title @@ -337,8 +376,16 @@ const createDeviceSlice: StateCreator = (s // choice from a previous Nano session shouldn't bleed into a fresh // Mega/Opta/etc. setup. Compile-time code falls back to each // manifest's `default` when the record is empty. + // + // Pin mappings on the other hand stay on disk per-board (the + // `pinsByBoard` dict), so switching here just changes which + // bucket the active selector pulls from — the previous board's + // pins are preserved for when the user switches back. The + // selected-row pointer is reset because the new board's + // bucket has its own row count. if (deviceDefinitions.configuration.deviceBoard !== deviceBoard) { deviceDefinitions.configuration.selectedPlatformOptions = {} + deviceDefinitions.pinMapping.currentSelectedPinTableRow = -1 } deviceDefinitions.configuration.deviceBoard = deviceBoard }), diff --git a/src/frontend/store/slices/device/types.ts b/src/frontend/store/slices/device/types.ts index 07f4f5a70..6275ae351 100644 --- a/src/frontend/store/slices/device/types.ts +++ b/src/frontend/store/slices/device/types.ts @@ -21,8 +21,23 @@ export type DeviceAvailableOptions = { // Pin mapping // --------------------------------------------------------------------------- +/** + * Pin mappings are scoped per target board: each board has its own + * pinout (a Mega's pin 13 is not a thing on a MKR), so a flat array + * shared across boards would either leak pins between targets or + * lose work whenever the user switched. The dict is keyed by + * `deviceConfiguration.deviceBoard`; the active board's array is + * pulled out by `pinSelectors.usePins`, slice actions mutate the + * active board's entry in place. Boards with no entry yet behave + * like an empty array — actions create the entry on first write. + * + * Legacy projects saved with a flat `pins: DevicePin[]` get migrated + * on load: the parser keys the legacy array under whatever board + * `devices/configuration.json` names as the active one. See + * `parse-project-files.ts` for the migration path. + */ export type DevicePinMapping = { - pins: DevicePin[] + pinsByBoard: Record currentSelectedPinTableRow: number } @@ -101,7 +116,16 @@ export type DeviceActions = { }) => void setDeviceDefinitions: (definitions: { configuration?: Partial - pinMapping?: DevicePin[] + /** Pin mappings to seed the store with. Two shapes accepted: + * - `DevicePin[]`: legacy flat array. Keyed under whatever + * `configuration.deviceBoard` resolves to (or the store's + * current `deviceBoard` if the caller didn't pass config). + * The parser routes legacy projects through this branch on + * load so projects saved before per-board scoping continue + * to work without manual migration. + * - `Record`: per-board dict, the + * canonical post-migration shape. */ + pinMapping?: DevicePin[] | Record }) => void clearDeviceDefinitions: () => void resetDeviceUpdated: () => void diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index 3e3ae705c..44350d819 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -541,7 +541,10 @@ const createProjectSlice: StateCreator = )?.entries ?? [] const pool = buildAddressPool( { - pinMapping: { pins: live.deviceDefinitions.pinMapping.pins }, + pinMapping: { + pins: + live.deviceDefinitions.pinMapping.pinsByBoard[live.deviceDefinitions.configuration.deviceBoard] ?? [], + }, vendorIoMapping: { entries: ioMapping }, remoteDevices: live.project.data.remoteDevices, }, @@ -699,7 +702,10 @@ const createProjectSlice: StateCreator = )?.entries ?? [] const pool = buildAddressPool( { - pinMapping: { pins: live.deviceDefinitions.pinMapping.pins }, + pinMapping: { + pins: + live.deviceDefinitions.pinMapping.pinsByBoard[live.deviceDefinitions.configuration.deviceBoard] ?? [], + }, vendorIoMapping: { entries: ioMapping }, remoteDevices: live.project.data.remoteDevices, }, @@ -1334,7 +1340,10 @@ const createProjectSlice: StateCreator = const pool = buildAddressPool( { - pinMapping: { pins: live.deviceDefinitions.pinMapping.pins }, + pinMapping: { + pins: + live.deviceDefinitions.pinMapping.pinsByBoard[live.deviceDefinitions.configuration.deviceBoard] ?? [], + }, vendorIoMapping: { entries: ioMapping }, remoteDevices: live.project.data.remoteDevices, }, diff --git a/src/frontend/store/slices/shared/types.ts b/src/frontend/store/slices/shared/types.ts index e5879f368..ba3efb36a 100644 --- a/src/frontend/store/slices/shared/types.ts +++ b/src/frontend/store/slices/shared/types.ts @@ -126,7 +126,10 @@ export type OpenProjectResponseData = { meta: ProjectMeta projectData: PLCProjectData deviceConfiguration?: DeviceConfiguration - devicePinMapping?: DevicePin[] + /** Pin mappings parsed from disk. The store accepts both shapes + * (per-board dict and legacy flat array); see + * `DeviceActions.setDeviceDefinitions` for the migration. */ + devicePinMapping?: DevicePin[] | Record /** Warnings from parsing (e.g. dropped files that failed validation). */ warnings?: string[] /** diff --git a/src/middleware/shared/ports/project-port.ts b/src/middleware/shared/ports/project-port.ts index 7c1111e35..ab1ecefd3 100644 --- a/src/middleware/shared/ports/project-port.ts +++ b/src/middleware/shared/ports/project-port.ts @@ -45,7 +45,12 @@ export interface ProjectResponse { meta: ProjectMeta projectData: PLCProjectData deviceConfiguration?: DeviceConfiguration - devicePinMapping?: DevicePin[] + /** Pin mappings parsed from `devices/pin-mapping.json`. The + * per-board dict (`Record`) is the + * canonical shape; the legacy flat array is still accepted + * on load and auto-migrated by the store on the next save. + * See `pinMappingFileSchema` for the on-disk contract. */ + devicePinMapping?: DevicePin[] | Record /** Warnings from parsing (e.g. dropped files that failed validation). */ warnings?: string[] /** From 2db529e0c90b0ed0068a57db3d3d44f68c405732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Wed, 3 Jun 2026 11:00:10 +0200 Subject: [PATCH 41/61] fix(vpp-screen): honor conditional visibility + scope field DOM ids per section The declarative `form` layout (used by VPP Modbus screens) had three linked UI bugs: - Clicking a section's "Enable" label toggled another section's checkbox and scrolled to the top. Both `modbus_rtu` and `modbus_tcp` own a field with id `enabled`, so `vendor-field-${field.id}` produced duplicate DOM ids; a label's `htmlFor` resolved to the first match (the RTU checkbox) and focusing it jumped the scroll. Scope the id by `section.id`. - Fields never hid: `form-layout` ignored each field's `visible` clause. Port the evaluator so RTU/TCP sub-fields, RS485 EN Pin, Wi-Fi fields and the static-IP fields appear only when their condition holds. - The "Use DHCP" inverse behavior follows for free once `visible` is honored (the screen JSON already declares it). Extract `evalVisible` + `VisibleCondition` into a shared `utils/vpp/eval-visible` util (100% covered) reused by both `form` and `module-slots` layouts. The shared evaluator strips the canonical `fields.` reference prefix before value lookup, which also fixes `module-slots` visibility that never matched that syntax. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../vendor-screen/layouts/form-layout.tsx | 238 ++++++++++-------- .../layouts/module-slots-layout.tsx | 28 +-- .../utils/vpp/__tests__/eval-visible.test.ts | 121 +++++++++ src/frontend/utils/vpp/eval-visible.ts | 69 +++++ 4 files changed, 318 insertions(+), 138 deletions(-) create mode 100644 src/frontend/utils/vpp/__tests__/eval-visible.test.ts create mode 100644 src/frontend/utils/vpp/eval-visible.ts diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx index 032e1bc4f..620c031bb 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx @@ -3,6 +3,7 @@ import { Label } from '@root/frontend/components/_atoms/label' import { Select, SelectContent, SelectItem, SelectTrigger } from '@root/frontend/components/_atoms/select' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@root/frontend/components/_atoms/tooltip' import { useOpenPLCStore } from '@root/frontend/store' +import { evalVisible, type VisibleCondition } from '@root/frontend/utils/vpp/eval-visible' import { getSectionPersistenceKey } from '@root/frontend/utils/vpp/persistence-keys' import type { ScreenSection } from '../index' @@ -25,6 +26,9 @@ type FieldDef = { placeholder?: string maxLength?: number validation?: string + // Optional conditional-visibility clause (VPP screen schema). Fields + // without it always render; see `evalVisible`. + visible?: VisibleCondition } // Shared input styling for every branch (text, number, password, @@ -96,121 +100,133 @@ function FormLayout({ section }: FormLayoutProps) { return (
    - {fields.map((field) => ( -
    - {field.type === 'boolean' ? ( - <> - updateField(field.id, checked as boolean)} - className={ - values[field.id] === true - ? 'h-[14px] w-[14px] border-brand' - : 'h-[14px] w-[14px] border-neutral-300' - } - /> - - {field.help && } - - ) : ( - <> - - {field.type === 'number' ? ( -
    + {fields.map((field) => { + // Honor the field's conditional-visibility clause. Fields with + // no `visible` clause always render. + if (!evalVisible(field.visible, values)) return null + // DOM id must be unique across the whole screen — sections can + // reuse a field id (e.g. both modbus_rtu and modbus_tcp own an + // `enabled` field). Scope by section.id so a label's `htmlFor` + // can't target a same-named checkbox in another section. + const fieldDomId = `vendor-field-${section.id}-${field.id}` + return ( +
    + {field.type === 'boolean' ? ( + <> + updateField(field.id, checked as boolean)} + className={ + values[field.id] === true + ? 'h-[14px] w-[14px] border-brand' + : 'h-[14px] w-[14px] border-neutral-300' + } + /> + + {field.help && } + + ) : ( + <> + + {field.type === 'number' ? ( +
    + updateField(field.id, Number(e.target.value))} + className='flex h-[30px] w-24 items-center rounded-md border border-neutral-100 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300' + /> + {field.unit && ( + {field.unit} + )} +
    + ) : field.type === 'select' ? ( + + ) : field.type === 'password' ? ( updateField(field.id, Number(e.target.value))} - className='flex h-[30px] w-24 items-center rounded-md border border-neutral-100 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300' + onChange={(e) => updateField(field.id, e.target.value)} + placeholder={field.placeholder} + maxLength={field.maxLength} + pattern={field.validation} + autoComplete='new-password' + className={TEXT_INPUT_CLASS} /> - {field.unit && {field.unit}} -
    - ) : field.type === 'select' ? ( - - ) : field.type === 'password' ? ( - updateField(field.id, e.target.value)} - placeholder={field.placeholder} - maxLength={field.maxLength} - pattern={field.validation} - autoComplete='new-password' - className={TEXT_INPUT_CLASS} - /> - ) : field.type === 'ip-address' ? ( - updateField(field.id, e.target.value)} - placeholder={field.placeholder ?? '0.0.0.0'} - maxLength={field.maxLength ?? 15} - pattern={field.validation ?? IPV4_PATTERN} - className={TEXT_INPUT_CLASS} - /> - ) : field.type === 'mac-address' ? ( - updateField(field.id, e.target.value)} - placeholder={field.placeholder ?? 'AA:BB:CC:DD:EE:FF'} - maxLength={field.maxLength ?? 17} - pattern={field.validation ?? MAC_PATTERN} - className={TEXT_INPUT_CLASS} - /> - ) : ( - updateField(field.id, e.target.value)} - placeholder={field.placeholder} - maxLength={field.maxLength} - pattern={field.validation} - className={TEXT_INPUT_CLASS} - /> - )} - {field.help && } - - )} -
    - ))} + ) : field.type === 'mac-address' ? ( + updateField(field.id, e.target.value)} + placeholder={field.placeholder ?? 'AA:BB:CC:DD:EE:FF'} + maxLength={field.maxLength ?? 17} + pattern={field.validation ?? MAC_PATTERN} + className={TEXT_INPUT_CLASS} + /> + ) : ( + updateField(field.id, e.target.value)} + placeholder={field.placeholder} + maxLength={field.maxLength} + pattern={field.validation} + className={TEXT_INPUT_CLASS} + /> + )} + {field.help && } + + )} +
    + ) + })}
    ) diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/module-slots-layout.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/module-slots-layout.tsx index 864094184..399f70cc0 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/module-slots-layout.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/module-slots-layout.tsx @@ -9,6 +9,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@root/ import { Modal, ModalContent, ModalTitle } from '@root/frontend/components/_molecules/modal' import { boardSelectors } from '@root/frontend/hooks/use-store-selectors' import { useOpenPLCStore } from '@root/frontend/store' +import { evalVisible, type VisibleCondition } from '@root/frontend/utils/vpp/eval-visible' import { getSectionPersistenceKey } from '@root/frontend/utils/vpp/persistence-keys' import { resolveModuleChannels, type ResolverModuleDef } from '@root/frontend/utils/vpp/resolve-module-channels' import type { IoMappingEntry } from '@root/middleware/shared/ports/types' @@ -71,10 +72,6 @@ type ConfigFieldDef = { encoding?: unknown } -type VisibleCondition = - | { condition: string; operator: string; value?: unknown } - | { operator: 'and' | 'or'; conditions: VisibleCondition[] } - type ConfigScreenDefinition = { sections?: Array<{ id: string; title?: string; layout?: string; fields?: ConfigFieldDef[] }> } @@ -94,29 +91,6 @@ function collectConfigFields(def: ConfigScreenDefinition | undefined | null): Co return out } -function evalVisible(visible: VisibleCondition | undefined, values: Record): boolean { - if (!visible) return true - if ('conditions' in visible) { - const results = visible.conditions.map((c) => evalVisible(c, values)) - return visible.operator === 'and' ? results.every(Boolean) : results.some(Boolean) - } - const v = values[visible.condition] - switch (visible.operator) { - case 'equals': - return v === visible.value - case 'not-equals': - return v !== visible.value - case 'in': - return Array.isArray(visible.value) && (visible.value as unknown[]).includes(v) - case 'exists': - return v !== undefined && v !== null && v !== '' - case 'not-exists': - return v === undefined || v === null || v === '' - default: - return true - } -} - type SortableSlotButtonProps = { idx: number moduleName: string | undefined diff --git a/src/frontend/utils/vpp/__tests__/eval-visible.test.ts b/src/frontend/utils/vpp/__tests__/eval-visible.test.ts new file mode 100644 index 000000000..4c699411b --- /dev/null +++ b/src/frontend/utils/vpp/__tests__/eval-visible.test.ts @@ -0,0 +1,121 @@ +/** + * Tests for the VPP screen conditional-visibility evaluator. + * + * Drives the show/hide behaviour of declarative `form` and + * `module-slots` fields. Failures here mean a screen author's + * `visible` clause stops matching the form values — fields that + * should hide stay visible (or vice-versa). + */ + +import { evalVisible, type VisibleCondition } from '../eval-visible' + +describe('evalVisible', () => { + it('treats a missing clause as always visible', () => { + expect(evalVisible(undefined, {})).toBe(true) + }) + + describe('fields. prefix handling', () => { + it('strips the canonical fields. prefix before lookup', () => { + const clause: VisibleCondition = { condition: 'fields.enabled', operator: 'equals', value: true } + expect(evalVisible(clause, { enabled: true })).toBe(true) + expect(evalVisible(clause, { enabled: false })).toBe(false) + }) + + it('honors a bare (un-prefixed) reference', () => { + const clause: VisibleCondition = { condition: 'enabled', operator: 'equals', value: true } + expect(evalVisible(clause, { enabled: true })).toBe(true) + }) + }) + + describe('leaf operators', () => { + it('equals', () => { + const clause: VisibleCondition = { condition: 'mode', operator: 'equals', value: 'advanced' } + expect(evalVisible(clause, { mode: 'advanced' })).toBe(true) + expect(evalVisible(clause, { mode: 'simple' })).toBe(false) + }) + + it('not-equals', () => { + const clause: VisibleCondition = { condition: 'mode', operator: 'not-equals', value: 'simple' } + expect(evalVisible(clause, { mode: 'advanced' })).toBe(true) + expect(evalVisible(clause, { mode: 'simple' })).toBe(false) + }) + + it('in', () => { + const clause: VisibleCondition = { condition: 'protocol', operator: 'in', value: ['SPI', 'I2C'] } + expect(evalVisible(clause, { protocol: 'SPI' })).toBe(true) + expect(evalVisible(clause, { protocol: 'UART' })).toBe(false) + }) + + it('in returns false when the clause value is not an array', () => { + const clause: VisibleCondition = { condition: 'protocol', operator: 'in', value: 'SPI' } + expect(evalVisible(clause, { protocol: 'SPI' })).toBe(false) + }) + + it('exists', () => { + const clause: VisibleCondition = { condition: 'name', operator: 'exists' } + expect(evalVisible(clause, { name: 'foo' })).toBe(true) + expect(evalVisible(clause, { name: '' })).toBe(false) + expect(evalVisible(clause, {})).toBe(false) + }) + + it('not-exists', () => { + const clause: VisibleCondition = { condition: 'name', operator: 'not-exists' } + expect(evalVisible(clause, {})).toBe(true) + expect(evalVisible(clause, { name: '' })).toBe(true) + expect(evalVisible(clause, { name: 'foo' })).toBe(false) + }) + + it('greater-than', () => { + const clause: VisibleCondition = { condition: 'channels', operator: 'greater-than', value: 0 } + expect(evalVisible(clause, { channels: 4 })).toBe(true) + expect(evalVisible(clause, { channels: 0 })).toBe(false) + // Non-numeric operands never satisfy a numeric comparison. + expect(evalVisible(clause, { channels: 'four' })).toBe(false) + }) + + it('less-than', () => { + const clause: VisibleCondition = { condition: 'retries', operator: 'less-than', value: 10 } + expect(evalVisible(clause, { retries: 3 })).toBe(true) + expect(evalVisible(clause, { retries: 10 })).toBe(false) + expect(evalVisible(clause, { retries: 'three' })).toBe(false) + }) + + it('greater-than returns false when the clause value is not numeric', () => { + const clause: VisibleCondition = { condition: 'channels', operator: 'greater-than', value: 'lots' } + expect(evalVisible(clause, { channels: 4 })).toBe(false) + }) + + it('shows the field for an unknown operator (forgiving default)', () => { + const clause: VisibleCondition = { condition: 'x', operator: 'weird-op', value: 1 } + expect(evalVisible(clause, { x: 1 })).toBe(true) + }) + }) + + describe('composite operators', () => { + const enabled: VisibleCondition = { condition: 'fields.enabled', operator: 'equals', value: true } + const dhcpOff: VisibleCondition = { condition: 'fields.enable_dhcp', operator: 'equals', value: false } + + it('and requires every condition', () => { + const clause: VisibleCondition = { operator: 'and', conditions: [enabled, dhcpOff] } + expect(evalVisible(clause, { enabled: true, enable_dhcp: false })).toBe(true) + expect(evalVisible(clause, { enabled: true, enable_dhcp: true })).toBe(false) + expect(evalVisible(clause, { enabled: false, enable_dhcp: false })).toBe(false) + }) + + it('or requires at least one condition', () => { + const clause: VisibleCondition = { operator: 'or', conditions: [enabled, dhcpOff] } + expect(evalVisible(clause, { enabled: false, enable_dhcp: false })).toBe(true) + expect(evalVisible(clause, { enabled: true, enable_dhcp: true })).toBe(true) + expect(evalVisible(clause, { enabled: false, enable_dhcp: true })).toBe(false) + }) + + it('nests composites', () => { + const clause: VisibleCondition = { + operator: 'and', + conditions: [enabled, { operator: 'or', conditions: [dhcpOff] }], + } + expect(evalVisible(clause, { enabled: true, enable_dhcp: false })).toBe(true) + expect(evalVisible(clause, { enabled: true, enable_dhcp: true })).toBe(false) + }) + }) +}) diff --git a/src/frontend/utils/vpp/eval-visible.ts b/src/frontend/utils/vpp/eval-visible.ts new file mode 100644 index 000000000..811c11f16 --- /dev/null +++ b/src/frontend/utils/vpp/eval-visible.ts @@ -0,0 +1,69 @@ +/** + * VPP screen conditional-visibility evaluation — the single source of + * truth for resolving a field/section `visible` clause against the + * current form values. + * + * Shared by the declarative layouts (`form`, `module-slots`) so the + * semantics stay identical no matter where a `visible` clause appears. + * + * Screen-author convention: conditions reference other fields with a + * `fields.` prefix (e.g. `"fields.enabled"`), as documented in the VPP + * `screen-definition-schema`. The stored value map, however, is keyed + * by the bare field id (`enabled`), so we strip the prefix before + * lookup. Bare references (no prefix) are honored too. + */ + +export type FieldValue = string | number | boolean + +/** + * A `visible` clause: either a single leaf comparison, or a composite + * `and`/`or` over nested clauses. + */ +export type VisibleCondition = + | { condition: string; operator: string; value?: unknown } + | { operator: 'and' | 'or'; conditions: VisibleCondition[] } + +const FIELDS_PREFIX = 'fields.' + +/** + * Resolve a `visible` clause to a boolean. + * + * - Missing clause → always visible (`true`). + * - Composite (`and`/`or`) → recurse and combine. + * - Leaf → compare the referenced field's current value. + * + * Unknown operators fall through to `true` so a malformed vendor + * package shows the field rather than silently hiding it. + */ +export function evalVisible(visible: VisibleCondition | undefined, values: Record): boolean { + if (!visible) return true + + if ('conditions' in visible) { + const results = visible.conditions.map((c) => evalVisible(c, values)) + return visible.operator === 'and' ? results.every(Boolean) : results.some(Boolean) + } + + const key = visible.condition.startsWith(FIELDS_PREFIX) + ? visible.condition.slice(FIELDS_PREFIX.length) + : visible.condition + const v = values[key] + + switch (visible.operator) { + case 'equals': + return v === visible.value + case 'not-equals': + return v !== visible.value + case 'in': + return Array.isArray(visible.value) && (visible.value as unknown[]).includes(v) + case 'exists': + return v !== undefined && v !== null && v !== '' + case 'not-exists': + return v === undefined || v === null || v === '' + case 'greater-than': + return typeof v === 'number' && typeof visible.value === 'number' && v > visible.value + case 'less-than': + return typeof v === 'number' && typeof visible.value === 'number' && v < visible.value + default: + return true + } +} From 3b6282f0ff1ee7076cfa85a562a7cfcc565dd243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Wed, 3 Jun 2026 14:23:19 +0200 Subject: [PATCH 42/61] feat(vpp-screen): expandable cards + toggle switches for form sections Align the declarative VPP `form` screens (e.g. the Modbus screen) with the S7Comm server editor look: - New shared atom `_atoms/collapsible-card`: a bordered, expandable card (Radix Accordion) with a clickable header, rotating chevron and slideUp/slideDown body animation. Each card is its own single-item root so multiple cards open independently. - New shared atom `_atoms/toggle-switch`: the S7Comm sliding switch (native checkbox + styled track/thumb). Forwards `id` so an external `