feat: VPP compile pipeline + signing + debug resolver + per-target pin mapping - #795
Merged
Merged
Conversation
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) <noreply@anthropic.com>
Compile every strucpp-touching TU under <build>/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<archDir> -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 <cstdint>.
handleGenerateCBlocksCode now writes the dynamic c_blocks_code.cpp
into <build>/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
<build>/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) <noreply@anthropic.com>
handleCompileArduinoProgram was setting compiler.libraries.ldflags
(-L<archDir> -lOpenPLCUserLib) but missed `--library <precompiledLibDir>`.
arduino-cli's library discovery is header-based: without the staging
directory on its library search path, the `#include <OpenPLCUserLib.h>`
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) <noreply@anthropic.com>
…ver and stage the precompiled archive per-core
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/<arch>/ 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) <noreply@anthropic.com>
…canonical declaration
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) <noreply@anthropic.com>
…l handshake `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) <noreply@anthropic.com>
…le-pipeline-port # Conflicts: # src/backend/editor/hardware/hardware-module.ts
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 `<Arduino.h>`, `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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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
`<lib>/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) <noreply@anthropic.com>
…aremetal 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 c379c7a ("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) <noreply@anthropic.com>
…6/ESP32 etc.) Two follow-ups to the previous Modbus VPP wiring (97f4956) 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 <WiFi.h>`) then fell through to the default branch, where `<WiFi.h>` 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) <noreply@anthropic.com>
…pes in form layout
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 `<input type='text'>`. 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` → `<input type='password' autoComplete='new-password'>`
Native browser masking. Suppresses credential autofill since this
is a device config form, not a sign-in.
- `ip-address` → `<input type='text' inputMode='decimal' pattern=…>`
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) <noreply@anthropic.com>
… on top
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) <noreply@anthropic.com>
Contributor
|
Caution Review failedPull request was closed or merged during review WalkthroughAdds unified board resolver, Arduino precompile and library staging, VPP catalog with signature verification, capability-driven UI, per-board pin mapping, debug-spec resolution, IPC/adapters updates, and extensive tests. ChangesEnd-to-end VPP, compile, and UI integration
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
|
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) <noreply@anthropic.com>
… flag 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…de finds Arduino.h
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 <Arduino.h>,
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) <noreply@anthropic.com>
Arduino.h defines `min` and `max` as preprocessor macros, which collide with the `std::min` / `std::max` function templates and `numeric_limits<T>::min()` / `max()` static members declared by <algorithm> / <limits> (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 <Arduino.h>` and before the strucpp runtime headers. Back-port of 6a5fbf6 (already on origin/development via #794) — this branch (feat/vpp-compile-pipeline-port) diverged at b181234 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Brings the feat/vpp-compile-pipeline-port branch onto current
development. Auto-merging covers ~50 files (Baremetal sources,
package-manager adapters, device store, catalog browser UI,
hardware-module updates). The compiler-module conflict resolution
is the load-bearing change: dev refactored compiler-module into the
shared `runCompilePipeline` while vpp built VPP support on the
pre-refactor shape.
Architectural call (per agreed plan): everything VPP-related lives
in shared so a future VPP-on-web rollout has a single canonical
implementation to extend.
Shared zone changes
-------------------
- `backend/shared/hardware/board-info-resolver.ts` (new):
BoardInfoResolver class with `BoardInfoResolverConfig` —
platform injects `halsContent`, `packageManager` (no-op stub on
web), `resolveHalSourcePath`, `resolvePackageRelativePath`.
Returns a uniform `BoardBuildInfo` shape regardless of whether
the board comes from hals.json or a VPP manifest, plus the
classification flags (`isSimulator` / `isRuntimeV3` /
`isRuntimeV4`) the pipeline branches on. Synchronous API.
- `backend/shared/compile/steps/modbus-defines.ts` (moved from
editor): emit `MBSERIAL_*` / `MBTCP_*` macros from VPP screen
state. Pure function; full test matrix moved alongside.
- `backend/shared/compile/steps/generate-defines.ts`: optional
`vppModbusState` input; when set on non-simulator/non-v4
targets the comms block comes from `generateModbusDefines`
instead of being skipped.
- `backend/shared/compile/pipeline.ts`: `vppModbusState` threaded
through `RunCompilePipelineArgs` into `generateDefinesContent`.
- `backend/shared/compile/steps/resolve-board-selection.ts`
DELETED: superseded by `BoardInfoResolver` (which now also
carries the runtime classification flags). Same for the test.
Editor wiring
-------------
- `compiler-module.ts`:
- `#createBoardInfoResolver()` helper builds a resolver wired
with `readHalsFile()` + `PackageManagerModule` + filesystem-
backed path helpers. All callsites now reach for this.
- `handleGenerateDefinitionsFile` uses the resolver for board
defines AND reads `vendorScreenData.modbus_rtu/_tcp` from
`devices/configuration.json` to populate `vppModbusState`,
then hands both to the shared `generateDefinesContent`.
- `handleGenerateArduinoCppFile` sources `halSourceFile` from
the resolver (covers VPP-installed boards).
- `compileProgram` board-selection: single resolver call
replaces the dev-side `resolveBoardSelection` + inline VPP
fallback AND the vpp-side lazy `getResolvedBoardInfo`.
`BoardBuildInfo` → `BoardHalsBuildEntry` adaptation feeds
the pipeline. Simulator hex-path derivation uses
`boardEntry.platform` (populated for VPP too).
- Dropped the vpp-side inline upload block at the end of
`compileProgram` — `runCompilePipeline` handles uploads
via `platformPort.uploadArduinoBoard` (compileOnly-aware).
- Dropped `#getBoardRuntime` (replaced by `resolver.resolve(...)
.boardRuntime` in the debug-compile path).
- `hardware-module.ts`: `getBoardBuildInfo()` rewired to the
shared resolver with the editor's filesystem adapters.
- `editor/hardware/board-info-resolver.ts` and its test DELETED
(moved to shared).
Verification
------------
- npx tsc --noEmit: clean
- npx jest (full): 4652 pass / 3 skip; 2 pre-existing failures on
dev (editor-compiler-platform-port + use-runtime-polling) — not
introduced by this merge.
- npx eslint: 0 errors (236 pre-existing warnings)
- npx prettier --check: clean
- npm run validate:arch: passed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The HAL Settings screen's "Enable Bus Fault Detection" toggle label was being cropped: the form-layout's Label used a fixed `w-32` (128px) box with `whitespace-nowrap`, so any label wider than the gutter overflowed into the toggle/control sitting at `gap-4` distance — the right edge of the text disappeared underneath the neighbouring widget. Swapped `w-32` for `min-w-32` on both label sites (the boolean branch and the non-boolean branch) so short labels still align at the 128 px gutter the screen layouts assume, but long labels grow horizontally to fit instead of clipping. The change is screen-wide because every form-layout field uses this same Label class — matches the existing convention now to fit ANY label, not just the short ones the original sizing happened to accommodate. Also adds a regression test in `generate-vendor-plugin-config.test.ts` pinning the wiring contract for the toggle that prompted this investigation: `vendorScreenData['hal-config'].fault_detection_enabled = false` MUST flow through the generator to the emitted JSON as `"fault_detection_enabled":false` (verbatim), not get dropped by a truthiness check. Test passes against the existing generator; this locks the contract so a future tightening of the falsy-skip logic in `Object.assign(result, value)` doesn't silently regress the toggle's wire path to the runtime plugin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The vendor-screen save flow had a race: `setVendorScreenData` set
`deviceUpdated.updated = true` on the store, and a `useEffect` on
the `DeviceEditor` component THEN called
`handleFileAndWorkspaceSavedState('Configuration')` one render
later to flip `editingState` to 'unsaved'. The upload flow gates
on `editingState === 'unsaved'` before saving — so a quick
sequence of "change dropdown, click Upload" (or any change made
while the device editor wasn't mounted) skipped the save, and
the compile-pipeline read stale `devices/configuration.json`
from disk.
User-visible bug: changing the SLM-RP4 HAL Settings fault-action
dropdown from "Log warning only" to "Zero outputs and stop PLC"
and uploading kept the runtime running with `fault_action=log_only`
because the new value never reached disk.
Fix: `setVendorScreenData` now calls
`sharedWorkspaceActions.handleFileAndWorkspaceSavedState('Configuration')`
directly, right after the store mutation. The dirty-flag flip is
concurrent with the mutation, so the upload gate sees the change
no matter how fast the user clicks. The `DeviceEditor`'s useEffect
becomes redundant (both paths now do the same thing) but is left
in place — it's the fallback for any code path that bypasses the
action (none today, but the symmetry is cheap).
The call is optional-chained because slice tests compose a subset
of the store (device + project + console + editor + library)
without the shared slice. The store mutation is the load-bearing
part; the dirty-flag side effect silently no-ops when shared isn't
in the root, keeping those tests hermetic.
Regression test in `shared-slice.test.ts` (which composes the full
store including shared) pins the contract: after
`setVendorScreenData`, both `Configuration.saved === false` and
`workspace.editingState === 'unsaved'` are true synchronously,
with no awaits or re-renders.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ion dirty markers Reframes the previous vendor-screen-specific dirty-flag fix (c726fed) into the cleaner architectural fix: the build button now ALWAYS saves the full project before kicking off the compile, regardless of `editingState`. Same for the debugger start. This mirrors what the library-build path was already doing — see the comment block at ~line 285 in this file. Why the gate was wrong ---------------------- The build/debug handlers used to gate on `editingState === 'unsaved'`, which is an effect-driven workspace- level boolean derived from each editor's individual dirty flag. That summary lags real store mutations by at least one render (useEffect tick) and only flips when the relevant editor is mounted. Vendor-screen edits, Monaco buffers, ladder/FBD node edits, the manifest editor — each has its own dirty-tracking path with its own race-against-the-build-button. Plugins added later would have to remember to wire into the same flag too. Always-save makes that whole class of races go away: the compile pipeline reads source from disk (`project.json`, `devices/*.json`, `pous/**`, …), so flushing the store to disk right before the compile reads from it is the load-bearing invariant. Cost is a few JSON.stringify + file writes; save is idempotent when nothing changed, and the library-build path has been doing this every build for a while without complaint. Reverts ------- The previous commit's c726fed per-action change to `setVendorScreenData` (synchronously calling `handleFileAndWorkspaceSavedState`) is removed along with its regression test. With the build-always-saves invariant in place, that per-action flush is treating a symptom at the wrong layer — and forcing the device slice to know about the shared slice's workspace handler was adding cross-slice coupling that pays no dividend now. The `DeviceEditor`'s useEffect that flips `editingState` on `deviceUpdated` change stays — it still drives the title-bar "unsaved" indicator and Save shortcut affordances. Just the build's reliance on it is gone. Read-only projects still skip the save (Monaco/graphical editors are locked, nothing to flush; gate keeps a stray dirty flag from 403-ing the build). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eat/vpp-compile-pipeline-port
Post-merge fixup: the vpp-package-signing branch added
`openModal('debugger-message', ...)` calls in `handleImportFromFile`
and `handleUninstall` (error surfaces for failed signature
verification / uninstall) but didn't bind `openModal` to the
component — the merge with feat/vpp-compile-pipeline-port broke
typecheck with `Cannot find name 'openModal'`. Same pattern the
sibling `catalog-browser.tsx` uses: `useOpenPLCStore((s) =>
s.modalActions.openModal)`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… project)
Adds a hover-revealed 3-dot menu to every card in the start screen's
recent-projects grid, with two actions:
- **Remove from list** — drops the entry from `projects.json`
immediately, no confirmation, disk untouched. Re-opening the
project by path later re-adds it to recents.
- **Delete project** — surfaces a destructive-action confirmation
modal (`confirm-delete-project`) showing the project name + full
path + an explicit "cannot be undone" line. On confirm:
recursively rm's the project directory AND drops it from
`projects.json`. On cancel: no-op.
Backend
-------
`ProjectService.deleteProject(projectPath)` (new) gates the
recursive `fs.rm` on the directory actually containing a top-level
`project.json` — without it, a stale history entry pointing at
e.g. `/Users/foo/Documents` would let the renderer wipe that
directory by passing its path. The safety check matches what
"is this an OpenPLC project root" actually means.
Stale entries (project.json missing) get evicted from the recent
list without touching disk — the entry was already pointing at
deleted data; keeping it serves no one. Other fs errors
(permission denied, busy) leave the entry in place so the user can
retry after fixing the cause.
Plumbing
--------
- IPC handlers `project:remove-from-recent` + `project:delete`
registered on `MainProcessBridge`.
- Preload exposes `window.bridge.removeProjectFromRecent` +
`window.bridge.deleteProject`.
- `ProjectPort` gains two methods (`removeRecentProject`,
`deleteProject`); editor adapter wires them to `window.bridge`.
Web adapter doesn't exist in this repo yet — the port contract
is the canonical surface, web's stubs land when the web repo
catches up.
- New `ConfirmDeleteProjectModal` (`'confirm-delete-project'` modal
type) mounted in the app-layout next to the existing
`ConfirmDeleteElementModal`. Distinct from the per-POU /
per-datatype delete confirmation because the data shape is
different (raw `{ projectName, projectPath }` off the recents
list rather than a live store element) and the blast radius is
much bigger (`rm -rf` on a directory vs deleting one file).
- `DisplayRecentProjects` wraps each `File` card in a `group
relative` container with an absolute-positioned Radix
DropdownMenu trigger. Click on the 3-dot button stops
propagation so the menu opens without also triggering the
card's onClick (which opens the project).
After either action, the recents list is re-read from
`projects.json` via `getRecentProjects()` and fed into the
`setRecent` store action. Re-reading rather than mutating
client-side keeps the renderer's view of the list aligned with
whatever the service actually wrote (covers the stale-entry
auto-eviction path among other things).
Regression tests in `project-adapter.test.ts` (4 new) pin the
adapter's pass-through behaviour for both methods, including the
safety-gate failure shape so a future bridge refactor doesn't
accidentally swallow the error message.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two UX tweaks on the recent-project card's overflow menu:
- `top-2` → `top-10`: the button used to sit on the tab portion
of the folder shape (the small lip above the body). Moved it
down ~32px so it lands on the blue body — the folder SVG's
body starts at y≈33 inside the 160px card, so top-10 (40px)
sits just inside it.
- Dropped `opacity-0` + `group-hover:opacity-100` + the
transition: the menu is now always visible. Discoverability
over minimalism — users shouldn't have to hover to find out
the menu exists.
Cosmetic only; the menu's behaviour and the two actions
("Remove from list", "Delete project") are unchanged.
CI's Format Check ran `npx prettier --check './src/**/*.{ts,tsx}'`
and flagged 5 files that had drifted from the project's prettier
config (120-col width, no semicolons, single quotes, trailing
commas). All five were touched by recent commits in this branch:
- compiler-module.ts (precompile -I order fix)
- parse-project-files.test.ts (per-target pin-mapping migration
test additions)
- module-slots-layout.tsx (per-target pin scoping)
- device-slice.test.ts (per-target pin scoping regression tests)
- project/slice.ts (per-target pin scoping pool builders)
Net change is 17 insertions / 19 deletions — purely whitespace and
line-folding, no logic touched. `npx prettier --check './src/**/*.
{ts,tsx}'` now reports "All matched files use Prettier code style!"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two CI debts that were inherited via the development merge:
1. `use-runtime-polling.test.ts` was authored against Vitest's
`vi.hoisted` + `vi.mock` and fails to compile on Jest (TS2339
"Property 'hoisted' does not exist on type 'typeof jest'").
Rewritten using Jest's babel-plugin-jest-hoist exception:
`mock*`-prefixed locals get hoisted into the `jest.mock`
factories the same way Vitest's `vi.hoisted` would. All 4
suites pass.
2. `editor-compiler-platform-port.test.ts` failed type-check on
the two `xml2stArgs`-forwarding cases because the test's
`jest.fn(async () => undefined)` couldn't match the strict
`Promise<MethodsResult<string | Buffer>>` shape introduced by
the recent handler signature change. Typed jest.fn explicitly
via `jest.fn<ReturnType<...>, Parameters<...>>` so the call-
tuple indexing (`callArgs[2]`) and resolved-value type stay in
lockstep with the handler type.
Coverage gap closure (hybrid strategy — only files touched by this
PR's commits; pre-existing 0%-coverage debt on dev is filed for
follow-up):
- `generate-defines.ts` — 3 new tests for the VPP Modbus block
branch (the arduino-cli path that emits MBSERIAL/MBTCP defines
from `vppModbusState`)
- `board-info-resolver.ts` — 6 new tests (partial compilerFlags
combinations, optional core/platform/source omission, debug
spec propagation on both hals + VPP paths)
- `debug-spec.ts` — 5 new tests (number coerce of non-finite,
boolean / string coercion, fallback copy on missing
noneEnabled/pickProtocol message blocks, prompts without
cacheKey)
- `device/slice.ts` — 4 new tests for `restoreVendorScreenSlice`
(key-present, key-absent-deletes, ownedKeys scope isolation,
fresh-store initialization)
- `project/slice.ts` — 5 new tests: `updateLibraryManifest`
setter; `updateEthercatConfig` failure shapes (no
remoteDevices / device-not-found / not-ethercat) and happy
path. Fixed the pre-existing "buffer matches serialized
variables" test that was hardcoding a wrong indentation
template — now uses the real serializer via
`generateIecVariablesToString`.
Istanbul ignore markers added to genuinely-defensive branches that
guard schema-drift or working-tree-only dev toggles:
- `package-adapter.ts` USE_LOCAL_MOCK = false branches (the
constant is committed-false and the comment says it MUST stay
that way for the catalog UI)
- `parse-project-files.ts` unknown-language body fallback,
fallback-also-failed branch, schema-required resource fields
- `generate-vendor-plugin-config.ts` malformed-address guards
(callers always pass io-mapping-validated addresses)
- `project/slice.ts` pouName-undefined guards on
reconcile/regenerateVariablesText (callers gate on local
scope), `code` non-string guard (TS-guaranteed by union),
`pou.interface?.variables` defensive in syncVariableAliases
These are paired with the explanatory `-- reason` comment Istanbul
expects, so future readers see why the branch is excluded rather
than what's missing.
All tests pass (4814 passed, 3 skipped). `npm run lint` and
`npm run validate:arch` clean. Branch is up-to-date with
`origin/development` via the merge in `a5470e8df`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The editor was doing the board lookup + runtime-flag derivation + BoardBuildInfo → BoardHalsBuildEntry adapt inline at the top of `compileProgram` — ~30 lines that the shared `runCompilePipeline` expects pre-derived. Web had already extracted the same logic into `backend/shared/compile/steps/resolve-board-selection.ts` (in a hals-only flavor), so the two repos diverged on a piece of code that's supposed to be byte-identical. Centralised on editor's validated flow (BoardInfoResolver-backed, covers hals.json + installed VPP packages) so a future tweak (new runtime kind, new VPP capability) lands once and editor + web compile entrypoints stay in lockstep. Web's existing hals-only version will be replaced by this one in the paired web PR; web's no-op packageManager means the same code naturally degrades to hals-only there until the VPP catalog ships on web. Editor's `compiler-module.ts` shrinks from the inline lookup-and- adapt to one helper call. The `boardInfo.halSourceFile` read later in the compile path stays inline (calls `resolver.resolve` again); threading the full BoardBuildInfo through the selection result would have widened its surface area beyond what the pipeline actually branches on. Tests: 8 unit tests covering simulator / runtime-v3 / runtime-v4 / arduino-cli classification, partial / complete compilerFlags adaptation, max_data_size pass-through, and VPP-package resolution through the same shape. All 350 compile-suite tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The per-target pin-mapping refactor (81220fc) changed `devices/pin-mapping.json` from a flat `DevicePin[]` to a per-board dict `{ [boardName]: DevicePin[] }`, but the compile path was still casting the parsed JSON to `DevicePin[]` and handing it to `generateDefinesContent`, which calls `.filter` on it. Real-world projects saved after the refactor crash during simulator / Arduino builds with: TypeError: devicePinMapping.filter is not a function at generateDefinesContent Two read sites needed updating in `compiler-module.ts`: 1. The main compile path (`compileProgram` → pipeline args). Now reads `DevicePin[] | Record<string, DevicePin[]>`, indexes the dict by `boardTarget`, falls back to legacy flat-array projects, and `[]` for missing/empty. 2. The VPP packaging path (`packageVendorPlugin` → plugin config's `pins[]`). Pre-refactor it only handled the array branch via `Array.isArray`, which meant new projects fed the packager an empty pin list and the generated plugin config came out with no GPIO mappings. Both sites now match the union shape declared by `pinMappingFileSchema` (z.union of dict + legacy array) in `backend/shared/types/PLC/devices/pin.ts`. The shared `generateDefinesContent` itself doesn't change — it's the right consumer; the editor caller was the layer that forgot to extract the active board's pins. Also adds a module-local `declare const APP_VERSION: string` inside `catalog-browser.tsx` so the file resolves on web too (where `src/globals.d.ts` doesn't exist). Same effective type as editor's top-level declaration; TS merges identical `declare const` declarations so editor is unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…le-pipeline-port # Conflicts: # src/frontend/components/_organisms/workspace-activity-bar/default.tsx
Two follow-ups from the development merge (#840 — "make public projects editable; gate only backend writes"): 1. Resolve the build-save conflict in `workspace-activity-bar`. Dev added a `canEdit` capability so viewers of a public project (no write permission) can compile locally without their save getting 401'd by the backend. Our branch had already removed the lossy `editingState` gate so the build always saves. Merged pattern keeps the always-save behavior but gates it on `canEdit` instead of `!isReadOnly` — the previous `isReadOnly` flag was removed in dev with the now-deleted `ReadOnlyProjectModal`. Same gate applied to the debug-start path (mirrors the build). 2. Add `notify-no-write-permission.test.ts` to jest's `testPathIgnorePatterns`. Dev authored it as a Vitest-native test (top-level `vi.mock` with relative path, expecting the runner to hoist). Vitest does hoist `vi.mock` natively; Jest's `babel-plugin-jest-hoist` only recognises literal `jest.mock` and the test fails to load on editor. Mirror of the same asymmetric-runner pattern web already uses for 6 jest-only tests in `vitest.config.ts` (`python-lsp`, library-build- orchestrator, pipeline, generate-confs, etc.). All 4822 editor tests pass, arch + format + lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Library Manager and Package (Board) Manager popovers both had an
"Add from the internet..." entry sibling to "Add from file...".
"Catalog" is the noun the CDN actually exposes (the package /
library manager tabs in the UI both use "Catalog" as the section
name), and the renamed entry reads cleaner — "from the internet"
was vague about what's being downloaded.
Updated:
- frontend/components/_features/[workspace]/editor/package-
manager/index.tsx (VPP / Board Manager)
- frontend/components/_features/[workspace]/editor/library-
manager/system-libraries-tab.tsx (Library Manager)
- middleware/shared/ports/library-port.ts (comment reference)
- frontend/components/_organisms/modals/missing-libraries-
modal.tsx (comment reference)
Shared zone byte-identity preserved — both editor and web ship
the same labels.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Editor-side support for the Arduino Opta v0.2/0.3 VPP package: - vpp_config.h: pure emitter step that converts vendorScreenData into C preprocessor #defines for arduino-cli targets that opt into vppIo. Object-arrays also emit a _FOREACH(X) helper macro the driver can use to unroll per-index defines into a struct-literal array. Placeholder stub shipped in the firmware skeleton so HAL files can #include "vpp_config.h" unconditionally. - Module-slots layout: surfaces a per-row "Mode" select on the IO Mapping table when the channel was resolved out of the resolver's new perChannelChoices entry. Flipping the mode writes back to slotsConfig[slot][modeFieldId], the address allocator re-runs, and the IEC address re-allocates. Adds the fixed-module concept: a module flagged `fixed: true` auto-pins to slot 1, hides from the picker, can't be removed/replaced, and stays put through Clear All. - Capability plumbing: forwards `compiler` / `vpp` / `capabilities` from BoardInfoResolver through resolveBoardSelection into the pipeline's BoardHalsBuildEntry. Without this, vppIo collapsed to false and the emitter never fired for VPP arduino-cli boards. - Per-board arduino libraries: BoardBuildInfo.extraArduinoLibraries now flows through to handleLibraryInstallation, which merges them with GLOBAL_LIBRARIES. Install failures are warnings (the user may have the library installed elsewhere) so the build continues and arduino-cli compile is the source of truth for missing headers. Drops the hardcoded P1AM placeholder. - Build button: upload gate keys off `directUsbUpload` capability instead of the legacy `isArduinoTarget` predicate (which read `pinMapping` — Opta sets that false). Upload now enabled for every arduino-cli target including VPP-IO boards. - Arduino runtime: openplc.h gains MAX_REAL_INPUT / MAX_REAL_OUTPUT and IEC_REAL **real_input / **real_output (non-AVR only). Baremetal.ino allocates them. arduino_runtime_glue binds LocatedSize::DWord on Input/Output to these buffers — OpenPLC convention treats %ID / %QD as REAL on arduino-cli. Lets drivers deliver engineering-unit readings instead of raw ADC counts. 250 tests pass across the pipeline + resolver + capability + adapter suites. tsc -b clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-scroll
Three small bugs reported against the new VPP UI:
1. Package Manager: selected row had no visual indication. Mirrored
the Library Manager's selected styling — `bg-brand/20` background,
inset 3-px left border (`shadow-[inset_3px_0_0_var(--primary-default)]`),
bolded package id, `cursor-pointer` on every row.
2. Stale alias persisted after re-pick. When the user renamed an
alias on the pin-mapping / backplane / IO Table screen, the orphan
warning correctly fired on the POU variables table — but clicking
the location dropdown and picking the same address from it did
nothing, because the cell's `onBlur` short-circuited on
`value === initialValue`. The auto-adopt path in `updateVariable`
never ran, so the variable's stored `alias` field kept pointing at
the now-deleted name. Two fixes:
- `editable-cell.tsx` (local + global tables): lift the
`isOrphaned` computation above `onBlur` and skip the
short-circuit when the alias is orphaned, so re-picking the
same address forces the alias re-resolve through to
`updateVariable`'s auto-adopt branch.
- `validation/variables.ts`: `checkIfLocationExists` now takes
an optional `exclude: PLCVariable` so the update path can
skip the variable being mutated. Without this, the re-pick
would be rejected with "Location already exists" because the
uniqueness check found the variable's own existing entry.
- Regression test: re-setting a variable to its current
location must return `ok: true`.
3. Console auto-scroll was laggy / skipped messages. Root cause was
the prior implementation's 75ms `lodash.debounce` plus a
`scrollIntoView({behavior: 'instant'})` call — `'instant'` isn't
a standard option and silently falls back to smooth-scroll on
some Chromium builds, which IS the lag. Replaced the whole logic
with a VSCode-style sticky-bottom:
- `useLayoutEffect` writes `scrollTop = scrollHeight` synchronously
after each render where logs changed (no debounce, no
`scrollIntoView` indirection, no flash of old scroll position).
- `stickToBottomRef` flips false when the user scrolls up; flips
back true the moment they manually reach the bottom again.
Programmatic scrolls land at the bottom and keep stick=true,
so no feedback loop.
- 4-px slack on the at-bottom check accounts for sub-pixel layout.
80 validation tests pass (+1 regression). Console + log tests still
green. tsc -b clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… rename
Two related issues that surfaced together when users tried to rename
the template-seeded "main" program.
1. Remove the hardcoded "main" POU requirement.
The compiler hasn't required a POU named "main" for a long time —
any program POU name compiles, and the entry program is picked
from the configuration's `instances[]`. The editor still had a
v3-era guard in `XmlGenerator` that returned `Main POU not found`
when no program POU was called "main", which is what users hit
the moment they renamed the template default.
- `xml-generator.ts`: dropped the guard (the `mainPou` variable
wasn't used downstream anyway — pous get iterated by
`oldEditorParsePousToXML(pous)` regardless).
- `updatePouName`: cascades program POU renames into matching
`configurations.resource.instances[]`, so the instance
binding follows the rename and the IEC compile still finds
its entry program.
- `shared/slice.ts` auto-open-tab: prefers "main" when present,
otherwise falls back to the first program POU. No silent
failure when the project has no "main".
- Tests: dropped the assertions that the XML generator MUST
fail without a "main" POU; added two cases for the new
behaviour (zero POUs, function-only, non-"main" program).
Regression test for the instance-cascade behaviour.
2. MAJOR — LD/FBD rungs disappeared after renaming the POU.
The graphical-flow slices (`ladderFlows`, `fbdFlows`) store the
canvas state keyed by POU name in a separate Zustand slice. The
shared `renameElement` helper rekeyed the editor model, file
slice, and tab slice — but never touched the flow slices. A
renamed LD POU's editor immediately looked up
`ladderFlows.find(f => f.name === newName)` → undefined → blank
canvas. Continuing to edit on the blank canvas and saving
would have overwritten the on-disk rungs (real data loss).
The graphical body itself also embeds a `name` field inside
`pou.body.value`, used by the project-load path as the
ladderFlows seed key. The rename only updated `pou.name`,
leaving `body.value.name` pointing at the old name — projects
saved in that state would render an empty canvas under the new
POU name on every reopen.
Fixes:
- `renameLadderFlow(oldName, newName)` + `renameFBDFlow(...)`:
rekey the flow entry in place; defensively drop any empty
placeholder that may have been cold-seeded under the new
name so the original rungs win, not the placeholder.
- `renameElement`: calls both unconditionally (no-op for
non-graphical POUs).
- `updatePouName`: also rewrites `pou.body.value.name` for
LD/FBD bodies so the on-disk JSON stays consistent.
- `handleOpenProjectResponse`: defends against legacy drift —
when seeding flows from disk, override `body.value.name`
with `pou.name`. Projects saved with the new (consistent)
rename path see no change; projects with the legacy drift
(saved during the window where the bug existed) auto-
recover on first open.
Regression tests:
- `renameLadderFlow` rekeys without losing rungs
- drops a stale empty placeholder under the new name
- no-op on same-name and on missing flow
- `updatePouName` syncs `body.value.name` for LD POUs
854 tests pass; tsc -b clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tion The install handler swallows non-zero exit codes as warnings and always resolves, so the promise constructor's `reject` argument is intentionally unused. CI's `@typescript-eslint/no-unused-vars` flagged it on the PR; underscore prefix matches the rule's allowed-unused pattern `/^_/u`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
thiagoralves
marked this pull request as ready for review
June 4, 2026 21:33
Web's eslint config enforces `simple-import-sort/imports` more strictly than editor's local config, but the file MUST stay byte- identical across the two repos. Applied the sort so both pass — the only change is reordering two adjacent middleware/shared imports (library/ before target-capabilities), no logic change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror of openplc-web commit 9fa25df9a6 — keeps the shared pipeline file byte-identical between repos. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror of openplc-web's format pass — Prettier 3.4.2 reflows in the shared surface files (generate-vpp-config, resolve-module-channels, module-slots layout, shared slice rename, tests). Format-only, no logic change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Started as the VPP compile-pipeline port (
BoardInfoResolver+target.platformOptions); grew into the full VPP feature surface plus several adjacent fixes that all flow through the same hardware/compile path.Companion: openplc-web PR #483 — restores byte-identity on the shared zones.
Compile pipeline
BoardInfoResolverunifies hals.json + installed VPP packages into one canonical lookup. Editor (realPackageManagerModule) and web (no-op stub today) both wire through it.os.cpus().length.backend/shared/compile/steps/resolve-board-selection.ts— replaces the inline-duplicated lookup the two repos used to carry separately.BoardInfoResolver.resolve(boardTarget)now (works for legacy hals entries AND VPP-installed boards).generateModbusDefines); runtime-v3/v4 keep routing viaconf/modbus_slave.json.VPP catalog + signing
minEditorVersionvsAPP_VERSION), install/update/uninstall actions.verifyPackageSignature,trusted-keysregistry). VPP install path throughpackages:install-from-urlIPC.Debugger
DebugSpecresolver — debug-channel selection lives in the manifest (hals.json or VPP), the resolver evaluates preconditions /enabledWhen/$reflookups against runtime state and produces aDebugConnectionConfig. Same flow for runtime-v3 TCP, runtime-v4 WebSocket, simulator, and bespoke Modbus channels.Per-target pin mapping
DevicePinMappingswitched from a flatDevicePin[]to{ pinsByBoard: Record<string, DevicePin[]> }so switching Mega ↔ MKR ↔ back doesn't lose pin configuration.devices/pin-mapping.jsonon-disk schema accepts both the new dict shape and the legacy flat array (loaded as the active board's pins so older projects keep working).%QX0.0no longer shown when the active target is a runtime-v4 board, etc.).Vendor screens
password/ip-address/mac-addressfield types inform-layout.editingStategate); gated on the newcanEditcapability.Devices dropdown / boards
DropdownSearchInputatom extracted for reuse.Start screen
projects.jsonafter failed open so dead entries get evicted.Bug fixes (incidental — same area)
boardInfo.halSourceFileresolves viaBoardInfoResolver,--library precompiledLibDirarg threaded for arduino-cli discovery, undefArduino min/maxmacros before strucpp headers,-I avr-libstdcpporder fix.c_blocksSTRING/WSTRING standardized through strucpp wrappers.package-managererrors surface in a modal (no more silent failures on import/uninstall).Test plan
npm run lint,npm run test,npm run validate:arch— all greennpm run build— clean (Linux, macOS, Windows CI)devicePinMapping.filter is not a function)🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Improvements