Skip to content

feat: one device connection over serial or Modbus TCP, with run/stop - #980

Merged
thiagoralves merged 21 commits into
developmentfrom
feat/baremetal-connection
Aug 6, 2026
Merged

feat: one device connection over serial or Modbus TCP, with run/stop#980
thiagoralves merged 21 commits into
developmentfrom
feat/baremetal-connection

Conversation

@marconetsf

@marconetsf marconetsf commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Segregates the connection work that the VPP-licensing branch was carrying as an
implicit dependency, so it can land on development on its own. No licensable
VPP code is in here
, and nothing in it requires a change to openplc-packages:
it works with the VPPs published today.

Why draft

Paired with openplc-web #641,
which mirrors the 56 shared-surface files byte-identical (0 of 56 differ). The two
have to merge together: either one alone leaves the other repo's development
carrying a different surface, which is the drift the sync gate exists to catch.

Marking both ready at the same time.

Shared-surface files mirrored (56)

Everything under src/frontend/, src/middleware/shared/, src/backend/shared/
and src/__architecture__/ in this diff. git diff --name-only development | grep -E '^src/(frontend|middleware/shared|backend/shared|__architecture__)/'

What lands

Always-on debugger. The baremetal firmware keeps the serial debug protocol
compiled in unconditionally, so there is always a way in — even with Modbus
disabled entirely. Previously that configuration reported "Modbus Required" and
the board was unreachable.

Modbus layer split. ModbusSlave becomes twelve per-entity modules
(modbus_config/crc/debug/frame/pdu/registers/serial/tcp/types) with an
ARCHITECTURE.md. Modbus RTU can run on a secondary UART while the debugger
keeps the default port.

One device connection. DeviceLinkManager owns THE connection: it takes the
ordered candidates resolved from the board's debug spec, tries them in order, and
keeps the first that both opens and verifies. Every command — debug reads and
writes, run/stop, md5, the status poll — borrows that one client; none of them can
open a connection.

That is what fixes run/stop over Modbus TCP: the command path used to recognise
only an RTU client as reusable, so a tcp stop opened a transient SECOND socket,
which an Arduino Modbus TCP server (one client at a time) never answers.

Run/stop. FC 0x4b rides the same link. Reads come from the status frame
(FC 0x46), which already carries the run/stop state and the mode-switch
position — so a switch flipped by hand shows up within one poll interval, with no
second timer and no extra traffic. A RUN request is refused, not queued, while the
switch reads STOP.

Session structure. Two channel slots (control + debug), lazily opened, so a
debug session shares the link rather than replacing it. Runtime v3/v4 come into
the same session; the runtime-v4 WebSocket keeps its own client, being a different
protocol to a different target.

Field-tested, and what it took

Validated on an ESP8266 NodeMCU at 9600 baud: flash, connect, run/stop, debug
session. Four defects surfaced doing that, each fixed with a regression test:

Symptom Cause
.text1 will not fit in region iram1_0_seg — would not build The precompile named archive objects foo.o. esp8266's linker sends code to flash by matching *.cpp.o; unmatched code falls into a catch-all mapped into the 32 KB IRAM. Measured 7387 of 32768 bytes of ordinary code running from IRAM. Pre-existing and board-independent; surfaced here because the run/stop glue grew the largest offender
"No Firmware Detected" on a healthy board DEBUG_BAUD was read from the serial screen section alone. A VPP published today has no such section — it configures the baud on the RTU section — so the firmware listened at 115200 while the editor dialled the screen's rate
Debug session died every ~10s The liveness read queued behind the debug poll on the one serial link and timed out; two timeouts entered recovery and tore down a session whose own reads were succeeding. A successful command IS liveness evidence, so the poll now skips its round trip
~8 identical log lines per second The channel-use trace fired on every acquisition. Now logged once per distinct command+channel+endpoint

Also added, since a wrong baud is the one misconfiguration that looks like healthy
silence: Connect tries the configured rate first, then sweeps a short list of
fallbacks. Guesses are appended AFTER everything the project declared, get a short
probe budget, and the patient budget stays with the last configured endpoint —
where a just-flashed board still needs it.

Two things in here that are not features

  • validate:arch resolved SRC_ROOT via URL.pathname, producing C:\C:\… on
    win32 — the check could not scan the tree at all. Pre-existing on development;
    without it the gate does not run on this branch.
  • The object-naming fix above. Pre-existing, and independent of any board.

Compatibility with published VPPs

Verified against the real com.openplc.espressif manifest from
openplc-packages@development (no serialPorts, no defaultSerial, only the
legacy modbus_rtu/modbus_tcp screen sections): the generated defines are
correct for RTU on the default port, RTU on a secondary port, TCP static and
TCP Wi-Fi/DHCP; and candidate resolution works with RTU on, TCP on, both on, and
neither on — the last being the case the always-on debugger exists for.

The editor's manifest schema is untouched, and there are no references to the
split screen files. serialPorts / defaultSerial / optionsRef are all
optional with fallbacks.

Not in here

No license blob, no on-device license storage, no activation, no FC 0x480x4C
licensing set, no isLicensable/licenseStore capabilities, no purchase flow.
The device screen confirms a held link and nothing more.

Two commits about re-homing the Modbus screen under Servers and migrating
project data into new serial/network sections were deliberately left out —
they belong with the split-screen work, and the migration would have stripped
fields that no published VPP renders.

Checks

tsc --noEmit clean · validate:arch 0 violations · eslint 0 errors ·
prettier --check clean · jest 5611 passing.

Six failures remain in two suites (stats-table, board-info-resolver) and are
pre-existing — reproduced identically on origin/development. They are
environmental: a pt-BR locale formatting 1.234 where 1,234 is asserted, and
POSIX path assertions on win32.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added persistent device connections with automatic probing, fallback, recovery, and status reporting.
    • Added PLC Run/Stop controls, runtime status, board identity, and firmware version reporting.
    • Added clearer serial-port labels using address, board, and manufacturer information.
    • Added dynamic form options based on connected board details.
    • Added connection controls for runtime and serial targets, including firmware-flash prompts.
    • Improved debugger polling across supported connection types.
  • Bug Fixes

    • Improved board validation, networking, and serial-interface handling.
    • Fixed precompiled library placement for ESP8266 builds.
    • Added clearer errors for unsupported or unavailable board configurations.

marconetsf and others added 15 commits August 4, 2026 12:48
Expose the debug function codes over serial without enabling full Modbus
RTU/TCP. A new DEBUGGER_ENABLED gate brings up the serial port and the
debug FCs without allocating any operation buffers (coils/holding/input
regs), saving SRAM on small AVR boards. When Modbus is enabled behaviour
is unchanged.

Firmware:
- MB_SERIAL_ACTIVE gate (MBSERIAL || DEBUGGER_ENABLED) guards serial RX/framing
- debug-only setup path (Serial/115200/slave 1 defaults, overridable via
  DEBUG_IFACE/DEBUG_BAUD/DEBUG_SLAVE); no init_mbregs()/mapEmptyBuffers()
- process_mbpacket() gates operation FCs under MODBUS_ENABLED -> operation
  requests return ILLEGAL_FUNCTION in debug-only builds
- new FCs 0x46 status, 0x47 version, 0x48 board-id (ArduinoUniqueID, with a
  compilable fallback); RTU framing + CRC-bypass wired for all three
- OPENPLC_RUNTIME_VERSION in openplc_version.h

Editor/shared:
- ArduinoUniqueID added to GLOBAL_LIBRARIES
- mirrored FC enums (editor + simulator)
- modbus-pdu build/parse helpers for the 3 new FCs (100% covered)
- ModbusRtuClient getStatus/getVersion/getBoardId (100% covered)
- generate-defines emits the //Debugger block for baremetal targets when
  full Modbus is off
- simulator debug E2E cases for 0x46/0x47/0x48 (gated by CHRIS_DEMO_HEX)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2 (serial-network-modbus split): let a VPP screen `select` field source
its options from per-board context via `optionsRef` (e.g. "board.serialPorts"),
so a shared screen adapts to each board — the Modbus RTU serial-port picker now
lists only the UARTs the board actually exposes instead of a static Serial/
Serial1/2/3 list.

- BoardInfo + PackageManifest device gain serialPorts/defaultSerial; the hardware
  module forwards them from the manifest onto the board info.
- utils/vpp/field-options: resolveFieldOptions helper (optionsRef wins when it
  resolves to a non-empty array, else falls back to static options). 100% covered.
- form-layout: select uses resolveFieldOptions with the current board as context.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2: emit an always-on serial debugger and read Modbus config from the new
sections.

- generate-defines: the Debugger block is now unconditional for baremetal
  Arduino targets, emitting DEBUG_IFACE (default serial) and DEBUG_BAUD (from the
  Serial section). The old "only when Modbus is off" gate is gone.
- modbus-defines: RTU reads serial_port (legacy rtu_interface fallback) and takes
  its baud from the Serial section on the default port or its own baud on a
  secondary port; emits MBSERIAL_SHARES_DEBUG_SERIAL when it runs on the default
  port so the firmware begins the port once. TCP reads network config from the
  Network section (legacy modbus_tcp.* fallback). New optional defaultSerial arg.
- compiler-module: feed the serial/network sections into vppModbusState.

Backward-tolerant: pre-migration projects (legacy modbus_rtu/modbus_tcp shape)
still generate correct defines. 55 tests pass, 100% stmts/lines/functions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2: with the always-on debugger now emitted unconditionally (DEBUGGER_ENABLED
+ DEBUG_IFACE/DEBUG_BAUD), a Modbus-TCP-only build (MODBUS_ENABLED without
MBSERIAL) previously left the default serial uninitialised, so the debugger had
no port. setup() now brings up DEBUG_IFACE @ DEBUG_BAUD on mb_serialport in that
case.

Single-serial model note: the debugger and Modbus RTU share one mb_serialport;
when MBSERIAL_SHARES_DEBUG_SERIAL is set the RTU port IS the debugger's default
serial (single begin). Running the debugger on the default serial while RTU uses
a different UART simultaneously needs a second serial handler — documented follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2: the always-on debugger keeps serial debug compiled into every baremetal
firmware even with Modbus disabled, so resolveDebugConnection now falls back to
the serial (rtu) channel instead of surfacing "Modbus Required" when no channel's
enabledWhen matches. A TCP-only Modbus build leaves the tcp channel eligible, so
it never hits the fallback and correctly debugs over TCP. Errors only when there
is no serial channel at all.

The serial channel's baud is sourced from the Serial section (screens.serial.
baud_rate) via the VPP debug spec (NodeMCU pilot). 31 tests pass, 100%
stmts/lines/functions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er keeps the default

Phase 2 dual-serial: support the debugger on the default (USB) serial AND Modbus
RTU on a distinct UART simultaneously.

- modbus-defines: emit MBSERIAL_ON_SECONDARY when the RTU serial_port differs
  from the board's default serial (and MBSERIAL_SHARES_DEBUG_SERIAL when it's
  the same port). Tested, 100% stmts/lines/functions.
- ModbusSlave.cpp: factor the serial servicing into handle_serial_port(port,
  txpin, slaveid, buf, len, last) with a parametrised mb_rtu_drop_front. Under
  MBSERIAL_ON_SECONDARY, two contexts each own an RX buffer (debug + rtu) and
  mb_frame is transient process/TX scratch; otherwise the single-port path is
  unchanged (buf IS mb_frame, no copy) — zero RAM cost on single-UART boards.
- Baremetal.ino: bring up both serials in setup() under the dual-serial macro.
- ModbusSlave.h: DEBUG_* defaults now apply whenever DEBUGGER_ENABLED (the dual
  path needs DEBUG_SLAVE even with MBSERIAL defined).

RAM cost (dual builds only): 2*MAX_MB_FRAME + 12 bytes (268 B on 32U4, 524 B on
256-frame boards). Firmware verified via arduino-cli build (multi-UART board).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Break the monolithic ModbusSlave.{cpp,h} into 10 cohesive modbus_*
translation units, each owning one concern and its own build gate.
Behavior-preserving; validated by arduino-cli builds (RTU single-serial,
debug-only/TCP, dual-serial).

- modbus_config.h   build gates (defines.h + MB_SERIAL_ACTIVE/DEBUG_* derived)
- modbus_types.h    enums, MBinfo, frame-size/status constants
- modbus_frame.*    shared seam (mb_frame/mb_frame_len/modbus, exceptionResponse)
- modbus_crc.*      CRC-16 + tables (defined once; fixes latent per-TU flash dup)
- modbus_registers.* register store + operation FCs (#ifdef MODBUS_ENABLED)
- modbus_debug.*    debugger FCs 0x41-0x48 (home for future licensing FCs)
- modbus_pdu.*      process_mbpacket + mb_pdu_request_len/mb_pdu_skips_crc
- modbus_serial.*   RTU/debugger serial transport (single + dual-serial)
- modbus_tcp.*      Modbus TCP transport (Ethernet/WiFi/ETH)
- ModbusSlave.*     umbrella header + mbtask() facade (Baremetal.ino unchanged)

Key decoupling: the serial transport no longer knows the function-code set.
It asks modbus_pdu for per-FC frame shape (mb_pdu_request_len) and CRC policy
(mb_pdu_skips_crc), so adding a function code touches only modbus_pdu +
its handler, never the transports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018esifhUpuyPmJB29BneUqr
Reference for the modularized ModbusSlave layer: layer diagram, per-module
responsibility table (with build gates and dependencies), request lifecycle
(RTU/TCP/dual-serial), the two invariants (transports don't know the FC set;
mb_frame is the one seam), a "how to add a function code" guide, and the
known single-serial + TCP shared-buffer constraint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018esifhUpuyPmJB29BneUqr
…un/stop

Brings the connection work that the VPP licensing branch was carrying as an
implicit dependency onto its own footing, with no licensing in it.

DeviceLinkManager owns THE connection: it takes the ordered candidates resolved
from the board's debug spec, tries them in order, and keeps the first that both
opens and verifies. Modbus TCP is preferred when the project enables it and
serial is always a candidate, since the always-on debugger keeps the serial
protocol compiled into every baremetal firmware. Verification per candidate is
what makes preferring TCP safe: a socket that opens but answers nothing falls
through to the cable instead of stranding the user.

Every command -- debug reads and writes, run/stop, md5, the status poll -- now
borrows that one client; none of them can open a connection. That is what fixes
run/stop over Modbus TCP: the command path used to recognise only an RTU client
as reusable, so a tcp stop opened a transient SECOND socket, which an Arduino
Modbus TCP server (one client at a time) never answers.

Run/stop (FC 0x4b PLC_SET_STATE) rides the same link. Reads come from the status
frame (FC 0x46), which already carries the run/stop state and the mode-switch
position, so a switch flipped by hand shows up within one poll interval with no
second timer and no extra traffic. A RUN request is refused -- not queued --
while the switch reads STOP, and `refusedBySwitch` says so.

Session structure: two channel slots (control + debug), lazily opened, so a
debug session shares the link rather than replacing it. Runtime v3/v4 come into
the same session; the runtime-v4 WebSocket keeps its own client, being a
different protocol to a different target.

Also here: serial-port descriptors carried as data rather than a display string,
a connection-lost warning that names the endpoint and the right advice per
transport, and an upload that releases the link only when it is the serial one
holding that port -- so a Modbus TCP link, and the debug session on it, survives
an upload.

Two fixes that are prerequisites rather than features: validate:arch now
resolves SRC_ROOT via fileURLToPath (URL.pathname produced C:\C:\... on win32
and the check could not scan the tree at all), and it learned to read multi-line
imports, which it never could -- so it had been reporting success while missing
real violations of its own rules.

No licensing: no license blob, no on-device license storage, no activation, no
FC 0x48-0x4C licensing set, no isLicensable/licenseStore capabilities. The
device screen confirms a held link and nothing more. `classifyDeviceLink`
(backend/editor/hardware/device-probe.ts) keeps only the board-id read that says
whether an OpenPLC firmware answered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Formatting only, no behaviour change. Also drops three imports left unused by
the licensing removal (Popover/Copy in board.tsx, DebugBoardIdResult in the
WebSocket transport) and normalises two files to LF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n a miss

Two defects, one symptom: "No Firmware Detected" on a board that is running
perfectly well.

FIRST, a real misalignment. `DEBUG_BAUD` was read from the `serial` screen
section alone, falling back to 115200. A VPP published today has no `serial`
section -- it configures the baud on the RTU section -- so every project built
against a published package compiled a firmware listening at 115200 while the
editor dialled the RTU's rate. The port opened, nothing decoded, and the editor
concluded there was no firmware.

`resolveDebugBaud` states the rule the firmware actually follows: an explicit
`serial` section wins; otherwise the RTU's baud, but ONLY when the RTU is on the
default port, because there the firmware brings that one port up at
MBSERIAL_BAUD and the debugger shares it (MBSERIAL_SHARES_DEBUG_SERIAL);
otherwise the firmware default. With the RTU on a SECOND UART the debugger keeps
the default port to itself and nothing in the project states its speed -- which
is what the second half of this commit is for.

SECOND, defence in depth. A wrong baud is the one misconfiguration that presents
as healthy silence, and the advice it produced -- reflash the device -- is
exactly the wrong move on a device in the field. Connect now tries the
configured rate first and then sweeps FALLBACK_BAUD_RATES, so a board whose rate
nobody remembers is reachable instead of looking dead.

The sweep is deliberately not free-form:

  - Guesses are appended AFTER every endpoint the project declared. A configured
    Modbus TCP address is a better next try than a rate nobody asked for.
  - Guesses carry `speculative`, and verification gives them
    SPECULATIVE_BOARD_ID_PROBE (2 attempts) instead of the patient budget. Two
    rather than one because opening the port asserts DTR and resets AVR/ESP8266
    boards, so the first read can land mid-boot and reject a correct rate.
  - The patient budget stays with the last DECLARED endpoint (`patient`), not
    the last candidate overall. Without that the sweep would quietly take ~32s
    of patience away from the configured endpoint -- the one case that needs it,
    a board still booting right after a flash.
  - Each guess names its rate in the trace, so the console says which rate is
    being tried rather than listing the same port five times.
  - The debug channel of an already-established session opts out
    (`probeBaudRates: false`): there the rate is settled, and reopening the port
    at other rates would be wrong, not merely wasteful.

Cost, stated plainly: a full sweep is four extra port opens, each resetting the
board. It runs only after everything configured has already failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…the screen's rate when Modbus is off

Two defects the first field test exposed, both mine.

THE SWEEP NEVER RAN. `descriptor` is an IDENTIFIER: it is matched against the OS
serial-port list and against the port an upload asks to borrow. Writing
"COM5 @ 115200 baud" into it meant every swept candidate matched no port and was
dismissed as absent in 1ms:

    COM5 @ 115200 baud: serial port is not enumerated, skipping
    open: rtu COM5 @ 115200 baud rejected in 2ms — not available

The rate now travels beside the endpoint as `baudRate`, and
`describeLinkCandidate` composes the caption where a caption is wanted (trace,
attempt list). The same slip would have broken the upload handoff, which
compares the held descriptor against the port arduino-cli wants.

The trace also named the rate on guesses only, so the log showed four rates being
tried and never said which one the CONFIGURED attempt used — the reader could not
tell that the 32s patient probe had already spent itself on 9600. Every serial
candidate now reports its rate.

DEBUG_BAUD IGNORED A DISABLED RTU. The editor dials
`screens.modbus_rtu.rtu_baud_rate` whether or not the RTU is enabled: a debug
spec's `params` are read independently of its `enabledWhen`. `resolveDebugBaud`
required `enabled === true`, so a project with Modbus off and 9600 on the screen
compiled a firmware at 115200 while the editor dialled 9600 — the exact shape of
the reported failure. The rate is now taken whenever it exists, except when an
ENABLED RTU owns a second UART: only there does it genuinely describe a
different port.

Regression tests, both of which the previous commit passed:
  - two candidates on one port, differing only in baud, are both opened rather
    than skipped as missing ports;
  - a failed open reports each attempt's rate;
  - DEBUG_BAUD follows the screen rate with the RTU disabled, and only falls back
    to 115200 for an enabled RTU on a second UART.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nks them into flash

The link failure was not a size problem in the usual sense — it was 7387 bytes
of ordinary code running from the wrong memory.

esp8266's linker script decides flash-vs-IRAM by matching the OBJECT NAME:

    .irom0.text : { *.c.o(.literal* .text*)
                    *.cpp.o(EXCLUDE_FILE (umm_malloc.cpp.o) .literal* … .text*)
                    *.cc.o(.literal* .text*)
                    … explicit list of SDK archives … }

Anything unmatched falls through to `.text1`, a catch-all mapped into
`iram1_0_seg` — 32 KB shared with the WiFi/SDK core. libOpenPLCUserLib.a is not
in the archive list, so its members had to match `*.cpp.o` to reach flash. The
precompile stripped the extension (`basename.replace(/\.cpp$/, '.o')`), naming
them `arduino_runtime_glue.o` / `configuration.o` / `pou_MAIN.o` — none of which
match anything, so every translation unit of the user library was placed in IRAM.

Measured on the failing project (objdump -h over the archive):

    arduino_runtime_glue.o   3781 bytes -> IRAM
    configuration.o          3149 bytes -> IRAM
    pou_MAIN.o                457 bytes -> IRAM
    ------------------------------------------
                             7387 bytes of 32768

Keeping the `.cpp` (`arduino_runtime_glue.cpp.o`) is the same convention
arduino-cli uses for sketch objects — which is exactly why every `modbus_*.cpp.o`
in the sketch was already in flash while the archive was not.

Pre-existing, and independent of any board: the naming has always been this way.
It surfaced now because the run/stop state machine grew arduino_runtime_glue.cpp,
making the largest IRAM squatter larger still and pushing an ESP8266 NodeMCU over
the segment. The reported error named neither the archive nor the cause:

    section `.text1' will not fit in region `iram1_0_seg'

Covered by a test, because the symptom is this remote from the cause: the
compile command must carry `arduino_runtime_glue.cpp.o` and must not carry the
bare `.o` form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…, and quiet its trace

Two problems from the same source: the debug poll is by far the busiest thing on
the link, and both the liveness check and the trace treated each of its requests
as an event worth acting on.

THE DISCONNECT. Every command queues on the ONE serial link (the RTU client
serialises requests, correctly — two frames in flight on a half-duplex wire would
corrupt each other). A debug session reads variables continuously, so on a slow
wire the status read (FC 0x46) waits behind that queue and times out. Two
timeouts enter recovery, recovery drops the client, and the renderer ends the
debug session — over a link that was working the whole time. The reported trace
says exactly this: `unresponsive -> continue` twice, then `enter-recovery`, with
successful variable reads interleaved throughout. Roughly every ten seconds on a
9600-baud ESP8266.

The fix is to stop asking a question already answered: a command that succeeded
IS liveness evidence, so `noteTraffic()` records it and the poll skips its round
trip when something spoke within the interval. Cheaper than the old behaviour and
strictly better evidence — it happened, rather than being asked for.

Deliberately kept:
  - the serial-port presence check runs FIRST, before the shortcut. It reads the
    OS port list, costs nothing, and a yanked cable must still be caught on the
    next tick rather than hidden by a stale timestamp;
  - `dropClient()` clears the stamp, so traffic over a client that is gone cannot
    vouch for its replacement and recovery still probes for real;
  - a fresh connection does NOT seed the stamp: nothing else is on the link yet,
    so the first tick does its own read and the poll behaves exactly as before.

The trade: while a debug session is running, run/stop state refreshes only when
the poll actually reads (the status frame is where it comes from). A Start/Stop
button that lags a hand-flipped switch during a debug session is a far better
outcome than a debug session that dies every ten seconds.

THE LOG FLOOD. `traceChannelUse` fired on every channel acquisition, which for
the debug poll is several times a second: ~8 identical
`read variables: using the debug channel (rtu COM5)` lines per second, burying
every other message — including the ones explaining the disconnect. The question
it answers ("did run/stop really ride the same connection as the debugger?") is
answered by the FIRST occurrence, so it is now logged once per distinct
command+channel+endpoint, and the set is cleared on connect and disconnect so a
new session says it again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found reviewing the branch, not in the field — but it is the same false negative
the last three commits were chasing, and it would have hit a whole class of boards.

`classifyDeviceLink` required the FC 0x48 reply to carry a non-empty board id.
`success: true` already means the frame came back with the right function code and
a SUCCESS status, which only an OpenPLC firmware sends; the id is separate. Cores
without ArduinoUniqueID support, and boards that opt out with
`OPENPLC_NO_UNIQUE_ID`, deliberately answer `id_len = 0` rather than fail to
compile — see `debugGetBoardId` in modbus_debug.cpp, which documents exactly that.

So every such board was classified `no-firmware`: "No Firmware Detected", with an
offer to reflash a device that was running and answering. It also burned the full
patient budget (6 reads, ~32s) before saying so, and then swept every fallback
baud rate, because none of them could satisfy a condition the firmware can never
meet.

The requirement came from `probeAndRecover`, where it was correct — licensing
needs the anchor bytes, so an empty id really is a dead end there. Carrying it
into connect classification was the mistake.

Also: `setVariable` now notes traffic like `getVariablesList` does. Forcing values
queues on the same link and proves the same liveness, so a force held while the
poll is due no longer lets the status read wait behind it and time out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR adds managed device sessions with probing, recovery, persistent connection state, Modbus status and PLC control, unified IPC contracts, frontend connection flows, dynamic VPP options, and compiler updates.

Changes

Compiler, protocol, and configuration

Layer / File(s) Summary
Compiler validation and VPP defines
src/__architecture__/validate.ts, src/backend/editor/compiler/..., src/backend/shared/compile/...
Architecture validation now handles multiline imports and Windows paths. Precompiled objects retain .cpp.o names. Compiler defines resolve debugger serial settings and migrated VPP network settings.
Modbus status and PLC control
src/backend/shared/debug/..., src/backend/editor/modbus/..., src/backend/shared/simulator/...
Shared PDUs and transport contracts support board identity, runtime status, firmware version, and RUN/STOP control across RTU, TCP, and simulator transports.

Device lifecycle and connection resolution

Layer / File(s) Summary
Device probing and session recovery
src/backend/editor/hardware/...
The session manager opens ordered candidates, verifies links, shares channels, polls liveness, performs bounded recovery, and reports connection status.
Candidate resolution and transport contracts
src/backend/shared/hardware/..., src/middleware/shared/ports/..., src/frontend/services/...
Candidate resolution supports transport filtering, RTU fallback, deferred prompts, runtime debug channels, and persistent device-session contracts.

Application integration

Layer / File(s) Summary
IPC and middleware lifecycle
src/main/modules/ipc/..., src/middleware/adapters/...
IPC and adapters now use managed device sessions for connection, runtime sessions, debug channels, PLC control, serial handoff, and status subscriptions.
Frontend state and controls
src/frontend/hooks/..., src/frontend/store/..., src/frontend/components/..., src/frontend/utils/...
Frontend state tracks device connections, PLC state, switch position, and debug media. Connection controls, serial labels, dynamic VPP options, flash events, and unified PLC dialogs were added.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant WorkspaceUI
  participant DeviceAdapter
  participant MainIPC
  participant DeviceSessionManager
  participant ModbusTransport
  WorkspaceUI->>DeviceAdapter: connect candidates
  DeviceAdapter->>MainIPC: deviceConnect(candidates)
  MainIPC->>DeviceSessionManager: open candidates
  DeviceSessionManager->>ModbusTransport: connect and probe
  ModbusTransport-->>DeviceSessionManager: connection result
  DeviceSessionManager-->>MainIPC: connection status
  MainIPC-->>DeviceAdapter: result and events
  WorkspaceUI->>DeviceAdapter: set PLC state
  DeviceAdapter->>MainIPC: debuggerPlcControl(run or stop)
  MainIPC->>DeviceSessionManager: use active client
  DeviceSessionManager->>ModbusTransport: send PLC control PDU
  ModbusTransport-->>WorkspaceUI: PLC control result
Loading

Possibly related PRs

Suggested labels: feature

Suggested reviewers: thiagoralves, dcoutinho1328

Poem

A rabbit watches links connect,
While PLC states reflect.
Ports keep descriptors clear,
Recovery paths persevere.
RUN and STOP now share one flow.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.79% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: one shared device connection over serial or Modbus TCP with run/stop support.
Description check ✅ Passed The description thoroughly explains the scope, architecture, compatibility, validation results, known failures, and licensing exclusions.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/baremetal-connection

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

marconetsf and others added 2 commits August 4, 2026 18:09
The silent post-upload reconnect called `window.bridge.deviceConnect`
directly. That global only exists in the Electron preload, so a shared
component reaching for it does not compile in openplc-web — the byte-identical
mirror gate has no way to satisfy both repos.

`device.connect(...)` is the same call: the editor's device adapter delegates
straight to `window.bridge.deviceConnect`. The component already holds the port
(`const device = useDevice()`) and already uses it a few lines up for
`releaseSerialPort`, so this closes the one place the layer was bypassed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A successful connect wrote nine lines. Eight of them said the same thing the
ninth already said, and the console it shares with the compiler and the debugger
scrolled past whatever the user was actually reading. What is left is two lines:
what was requested, and what happened.

Silenced, all of it happy-path only:

* `status -> connecting` — a transient already visible in the button, emitted once
  per candidate. On a baud sweep that is five identical lines around the one
  outcome worth reading.
* `transport opened in Xms` — opening a port is a step towards the answer, not the
  answer. Failing to open still reports.
* `answered the debug protocol in Xms` / `ACCEPTED in Xms` — both restate the
  `connected` status that immediately follows, with the same descriptor.
* `classified as "connected-with-firmware"` — the interesting classifications are
  the other ones.
* `open: N candidate(s) in order` — now deferred to the moment a fallback is
  actually in play (the second candidate). On the ordinary connection the first
  one answers, and listing four baud rates nobody will dial buries the rest.

Every line that reports a PROBLEM is untouched: port not enumerated, transport
would not open, candidate rejected with its elapsed time, verification threw. The
negative classification is now more useful than before, because the probe budget
moved onto it:

    COM5: "no-firmware" after up to 2 id read(s) (baud guess)
    COM5: "no-firmware" after up to 6 id read(s) (last configured endpoint, was patient)

"No firmware after two reads at a rate I guessed" is a different problem from "no
firmware after six on the port the project configured", and the old line announced
that budget BEFORE the outcome it explains — on every success too.

These traces are what surfaced the four field defects on this branch, so the aim
is to quiet the successful case, not to lose the evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@marconetsf
marconetsf marked this pull request as ready for review August 4, 2026 19:56

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/middleware/shared/ports/debugger-port.ts (1)

62-67: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the stale @param config from the doc comment.

Line 67 no longer accepts a connection config. The @param config line at Line 65 documents a parameter that does not exist, and editors will show it in the tooltip for every implementer.

🐛 Proposed fix
   /**
    * Verify that the running program matches the expected MD5 hash.
    * Used to detect program mismatch before starting a debug session.
-   * `@param` config — Connection target used for the verification request.
+   * `@param` expectedMd5 — Digest the compiled program is expected to have.
    */
   verifyMd5(expectedMd5: string): Promise<Md5VerifyResult>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/middleware/shared/ports/debugger-port.ts` around lines 62 - 67, Remove
the stale `@param` config documentation from the verifyMd5 method comment, leaving
documentation only for the existing expectedMd5 parameter and the method’s
purpose.
🟡 Minor comments (12)
src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts-22-36 (1)

22-36: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Both uniqueness tests always pass and cannot detect a collision.

others is built by filtering out every numeric value equal to the target. A duplicate member sharing that value is filtered out too. expect(others).not.toContain(...) is therefore true by construction. The same defect applies to the REFUSED_BY_SWITCH test at lines 30-36.

These tests guard a wire-protocol contract, so the guard should actually fail on a collision. Count how many members hold the value instead.

🐛 Proposed fix that fails on a real collision
   it('claims 0x4b, clear of every other debug function code', () => {
     expect(ModbusFunctionCode.PLC_SET_STATE).toBe(0x4b)
-    const others = Object.values(ModbusFunctionCode).filter(
-      (value): value is number => typeof value === 'number' && value !== ModbusFunctionCode.PLC_SET_STATE,
-    )
-    expect(others).not.toContain(ModbusFunctionCode.PLC_SET_STATE)
+    const codes = Object.values(ModbusFunctionCode).filter((value): value is number => typeof value === 'number')
+    expect(codes.filter((value) => value === ModbusFunctionCode.PLC_SET_STATE)).toHaveLength(1)
+    // Nothing else in the enum shares a value either.
+    expect(new Set(codes).size).toBe(codes.length)
   })
 
   it('claims 0x86 for REFUSED_BY_SWITCH, clear of every other status code', () => {
     expect(ModbusDebugResponse.REFUSED_BY_SWITCH).toBe(0x86)
-    const others = Object.values(ModbusDebugResponse).filter(
-      (value): value is number => typeof value === 'number' && value !== ModbusDebugResponse.REFUSED_BY_SWITCH,
-    )
-    expect(others).not.toContain(ModbusDebugResponse.REFUSED_BY_SWITCH)
+    const codes = Object.values(ModbusDebugResponse).filter((value): value is number => typeof value === 'number')
+    expect(codes.filter((value) => value === ModbusDebugResponse.REFUSED_BY_SWITCH)).toHaveLength(1)
+    expect(new Set(codes).size).toBe(codes.length)
   })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts` around
lines 22 - 36, Fix both uniqueness tests in the PLC_SET_STATE and
REFUSED_BY_SWITCH cases so they count all enum members whose numeric value
equals the target, without filtering the target value out first. Assert that
each count is exactly one, preserving the existing wire-value assertions.
src/main/modules/ipc/main.ts-1901-1918 (1)

1901-1918: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve the Runtime v4 debug transport when the session connects.

handleOpenRuntimeSession stores params.debug.connectionType, but handleDebuggerConnect later replaces it with getLink()?.transport ?? 'tcp'. A REST-controlled Runtime v4 session has no control link (getLink() returns null) and should keep the debug channel type, such as websocket, instead of defaulting to tcp.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/modules/ipc/main.ts` around lines 1901 - 1918, Update
handleDebuggerConnect so it preserves the debug connection type established by
handleOpenRuntimeSession when the session has no control link, rather than
defaulting to tcp. Reuse the stored params.debug.connectionType for Runtime v4
sessions while retaining the existing getLink transport behavior where a link is
available.
src/middleware/shared/utils/target-capabilities/types.ts-96-101 (1)

96-101: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the shipped Modbus run/stop function code in the doc comment.

arduino-cli targets use FC 0x4b for PLC_SET_STATE; update this comment so the capability contract matches src/backend/shared/debug/modbus-pdu.ts and src/backend/shared/simulator/types.ts. Status state is read with FC 0x46.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/middleware/shared/utils/target-capabilities/types.ts` around lines 96 -
101, Update the doc comment for plcStateControl to identify the shipped
arduino-cli PLC_SET_STATE Modbus function code as FC 0x4b, and mention FC 0x46
for reading status state. Leave the capability behavior and surrounding target
descriptions unchanged.
src/middleware/shared/ports/device-port.ts-161-172 (1)

161-172: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Reattach the connection-status doc block to onConnectionStatus.

Lines 161-165 document onConnectionStatus, but Lines 166-170 insert a second doc block and the onLinkLog? declaration between them. Only the nearest preceding block attaches, so onLinkLog receives the link-log text and onConnectionStatus at Line 172 receives no documentation in tooltips. Move the first block down to onConnectionStatus.

🐛 Proposed fix
-  /**
-   * Subscribe to live serial-link status pushed by the main process (liveness
-   * failure, upload/debug handoff). Returns an unsubscribe function. Editor:
-   * `device:connection-status` IPC event. Web: no-op.
-   */
   /**
    * Subscribe to the device connection's diagnostic trace. Returns an unsubscribe
    * function. Editor: `device:link-log`. Web: no-op.
    */
   onLinkLog?(callback: (message: string) => void): () => void
 
+  /**
+   * Subscribe to live device-link status pushed by the main process (liveness
+   * failure, upload/debug handoff). Returns an unsubscribe function. Editor:
+   * `device:connection-status` IPC event. Web: no-op.
+   */
   onConnectionStatus(callback: (payload: DeviceConnectionStatusPayload) => void): () => void
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/middleware/shared/ports/device-port.ts` around lines 161 - 172, Move the
connection-status documentation block so it immediately precedes
onConnectionStatus, leaving the link-log documentation directly above
onLinkLog?. Preserve both declarations and ensure each tooltip describes the
correct subscription.
src/middleware/adapters/editor/device-adapter.ts-42-64 (1)

42-64: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle IPC failures before returning bridge result shapes.

deviceConnect() and deviceDisconnect() currently resolve only when window.bridge.* resolves, but an IPC failure still rejects. Most call sites wrap the call and report failure as { status: 'error' } or { success: false }, but useDeviceConnect.disconnect() and runtime-session opening also call these methods and do not catch rejections. Return the adapter’s declared result shapes on bridge failure, or ensure every caller catches before surface UX.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/middleware/adapters/editor/device-adapter.ts` around lines 42 - 64,
Update the device adapter methods connect, openRuntimeSession, and disconnect to
catch rejected window.bridge calls and return their declared failure result
shapes instead of propagating IPC rejections. Preserve successful bridge
results, and map failures to the appropriate DeviceConnectResult or { success:
false } response so callers remain safe without requiring additional handling.
src/__architecture__/validate.ts-201-206 (1)

201-206: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Calculate the line from the import/export statement boundary.

The pattern matches before import or export, including leading newlines and blank lines. Use the offset of the import or export keyword before counting lines so imports after blank lines report the correct source line. Add a regression test with leading blank lines.

Proposed fix
   while ((match = pattern.exec(source)) !== null) {
     const path = match[1] ?? match[2]
     if (!path) continue
-    results.push({ path, line: source.slice(0, match.index).split('\n').length })
+    const statementOffset = match.index + match[0].search(/\b(?:import|export)\b/)
+    results.push({ path, line: source.slice(0, statementOffset).split('\n').length })
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/__architecture__/validate.ts` around lines 201 - 206, Update the line
calculation in the import/export scanning loop using the match offset of the
actual import or export keyword, rather than match.index, so leading newlines
and blank lines are excluded. Preserve path extraction and result collection,
and add a regression test covering an import preceded by blank lines.
src/frontend/services/device-link-resolution.ts-269-304 (1)

269-304: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the stale doc comment.

The doc block states that this function "Uses the SINGLE-channel resolver, not the candidate one", and then describes pointing the candidate resolver at a websocket channel as a failure. The implementation calls resolveDeviceLinkCandidates (Line 300), and the inline comment on Line 296 states the opposite. Keep the inline explanation and remove the contradicting paragraph, so a later reader does not change the call to match the doc.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/services/device-link-resolution.ts` around lines 269 - 304,
Update the doc block for resolveRuntimeDebugChannel by removing the stale
paragraph claiming it uses a single-channel resolver and describing candidate
resolution as a failure. Keep the implementation calling
resolveDeviceLinkCandidates and preserve the accurate inline explanation below
it.
src/frontend/utils/vpp/field-options.ts-31-33 (1)

31-33: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate the option shape more strictly.

isFieldOption accepts any object that has a value key. A manifest entry such as { value: 1 } or { value: 'x' } without label passes, and the select then renders an item with an empty label and a non-string value. Require a string value and fall back to value when label is absent.

🛡️ Proposed change
 function isFieldOption(value: unknown): value is FieldOption {
-  return typeof value === 'string' || (typeof value === 'object' && value !== null && 'value' in value)
+  if (typeof value === 'string') return true
+  if (typeof value !== 'object' || value === null || !('value' in value)) return false
+  const candidate = value as { value: unknown; label?: unknown }
+  return typeof candidate.value === 'string' && (candidate.label === undefined || typeof candidate.label === 'string')
 }

If label stays optional, make it optional on FieldOption and let the caller display label ?? value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/utils/vpp/field-options.ts` around lines 31 - 33, Update
isFieldOption to accept object options only when value is a string, while
preserving string options. Ensure FieldOption treats label as optional and
update the select rendering path to display label ?? value when label is absent,
retaining the string value for option values.
src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx-30-37 (1)

30-37: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Remove the duplicate connected indicators.

DeviceConnectButton already renders ● Connected. Both callers also render DeviceConnectedIndicator, so the UI shows two connected confirmations.

Remove the local indicator and its two children.

Also applies to: 637-643, 721-723

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/frontend/components/_features/`[workspace]/editor/device/configuration/board.tsx
around lines 30 - 37, Remove the DeviceConnectedIndicator function and both of
its usages from the two parent render paths in the board configuration
component. Keep DeviceConnectButton’s existing connected display unchanged so
each connection state has only one confirmation.
src/frontend/hooks/use-device-connect.ts-65-89 (1)

65-89: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

A cancelled address prompt shows a "No Response" dialog for an attempt that never happened.

result starts as { status: 'no-response' }. If the first pass produced no candidates (only awaitingInput) and the user then cancels the address prompt, prompted is null, result stays no-response, and tried is empty. The hook then shows "Could not reach the device on this device." This contradicts the stated rule at Line 51 that a cancel is the user's answer and must not stack another dialog.

Track whether any candidate was actually attempted and return when none was.

🐛 Proposed fix
     if (result.status !== 'connected-with-firmware' && silent.awaitingInput.length > 0) {
       const prompted = await resolveDeviceLinkWithUx(deviceBoard, boardInfo, {
         onlyChannels: silent.awaitingInput,
       })
       if (prompted && prompted.candidates.length > 0) {
         tried.push(...prompted.candidates.map((candidate) => describeDebugEndpoint(candidate.config)))
         result = await device.connect(prompted.candidates.map((candidate) => candidate.config))
       }
     }
 
+    // Nothing was attempted: the only channel needed input and the user declined.
+    // The prompt was their answer, so do not report a failed attempt on top of it.
+    if (tried.length === 0) return
+
     const endpoints = tried.join(' or ') || 'this device'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/hooks/use-device-connect.ts` around lines 65 - 89, Track whether
any connection candidate was attempted in the device-connect flow, including
both silent and prompted passes. Before the no-response dialog block, return
without opening another dialog when no candidate was attempted, covering
cancellation of the address prompt; preserve the existing error dialog for
attempts that actually ran.
src/frontend/hooks/use-device-connection-monitor.ts-45-56 (1)

45-56: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

A late ipAddress leaves the runtime without a session.

The effect re-runs only for device, connectionStatus, and jwtToken. It reads runtimeConnection.ipAddress through getState(), so the value is not reactive. If the store records connectionStatus: 'connected' before the address, the effect logs the warning at Line 65 and never opens a session. Every later command then reports "not connected", which is the failure mode the comment at Line 58 describes. Select ipAddress and add it to the dependency array.

🔧 Proposed fix
   const connectionStatus = useOpenPLCStore((state) => state.runtimeConnection.connectionStatus)
   const jwtToken = useOpenPLCStore((state) => state.runtimeConnection.jwtToken)
+  const ipAddress = useOpenPLCStore((state) => state.runtimeConnection.ipAddress)
-  }, [device, connectionStatus, jwtToken])
+  }, [device, connectionStatus, jwtToken, ipAddress])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/hooks/use-device-connection-monitor.ts` around lines 45 - 56,
Update the effect around the runtime connection setup to select
`runtimeConnection.ipAddress` reactively from `useOpenPLCStore` and include that
selected value in the `useEffect` dependency array. Use the reactive `ipAddress`
in the existing session-opening flow so a late address reruns the effect and
establishes the session.
src/frontend/hooks/use-device-connection-monitor.ts-79-88 (1)

79-88: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle rejection on the session IPC calls.

void device.openRuntimeSession(...).then(...) handles only a resolved result. If the IPC call rejects, the promise becomes an unhandled rejection and the user sees nothing. Line 49 has the same gap. The coding guidelines require every promise to be awaited or to handle rejection explicitly.

🔧 Proposed fix
-    void device.openRuntimeSession({ address, debug: debugChannel }).then((result) => {
-      if (!result.success) {
-        store.consoleActions.addLog({
-          id: crypto.randomUUID(),
-          level: 'error',
-          message: `[connection] could not open the runtime session: ${result.error ?? 'unknown error'}`,
-        })
-      }
-    })
+    void device
+      .openRuntimeSession({ address, debug: debugChannel })
+      .then((result) => {
+        if (!result.success) {
+          store.consoleActions.addLog({
+            id: crypto.randomUUID(),
+            level: 'error',
+            message: `[connection] could not open the runtime session: ${result.error ?? 'unknown error'}`,
+          })
+        }
+      })
+      .catch((error: unknown) => {
+        store.consoleActions.addLog({
+          id: crypto.randomUUID(),
+          level: 'error',
+          message: `[connection] could not open the runtime session: ${error instanceof Error ? error.message : String(error)}`,
+        })
+      })

Apply the same .catch to device.closeRuntimeSession?.() at Line 49.

As per coding guidelines: "Do not allow floating promises; await them or handle rejection explicitly."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/hooks/use-device-connection-monitor.ts` around lines 79 - 88,
Handle rejected promises in both runtime session IPC calls: add explicit
rejection handling to the openRuntimeSession flow near the existing result check
and to closeRuntimeSession?.() at the earlier cleanup path. Log each failure
through store.consoleActions.addLog with error-level context, ensuring no
promise remains unhandled while preserving existing success and result-error
behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backend/editor/compiler/compiler-module.ts`:
- Around line 2666-2667: Validate the complete Modbus screen subsection before
constructing the state passed to the compile pipeline, replacing the unchecked
casts in the surrounding compiler flow. Use the existing Zod schema or type
guard for VppModbusScreenState to validate serial, network, modbus_rtu, and
modbus_tcp together, and handle validation failures by stopping or returning
through the established compiler error path instead of passing malformed data
onward.

In `@src/backend/editor/hardware/__tests__/device-session-manager.test.ts`:
- Line 44: Remove all forbidden as unknown as casts in the device-session
manager test harness, including asTransport and the casts near probe/verify.
Prefer making FakeClient implement DeviceModbusTransport with stub members so
transport arguments can be used directly, or maintain a
Map<DeviceModbusTransport, FakeClient> and resolve fakes by identity; preserve
existing test behavior without introducing other type assertions.

In `@src/backend/editor/hardware/device-session-manager.ts`:
- Around line 362-436: Add a monotonically increasing session-generation guard
shared by open(), tick(), and recovery operations so each async operation
captures its generation before awaiting. In open(), invalidate and disconnect
any client being replaced, then discard stale tryCandidate results and never
assign this.client, startPolling(), or emit connected for a superseded
generation; close any stale client returned by the probe. Apply the same
generation checks to tick()/recovery, clearing the old client before probing so
stale operations cannot restore or overwrite session state.
- Around line 660-669: In the recovered branch of the reopen-result handling,
replace both non-null assertions on reopened with an explicit local binding
after confirming it is non-null, then use that narrowed value for client,
candidate, transport, and descriptor access.

In `@src/backend/editor/hardware/device-transport-factory.ts`:
- Around line 80-90: The TCP branch of buildDeviceModbusTransport currently
ignores numeric-string ports and falls back to MODBUS_TCP_PORT. Accept string
port values by parsing them as integers, validate the resulting port against the
supported TCP port range, and use the validated value when constructing
ModbusTcpClient while preserving the default for absent ports.

In `@src/backend/shared/compile/steps/modbus-defines.ts`:
- Around line 200-202: Update the default-port branch of the baud selection
expression in the compile step to check rtu.baud_rate before rtu.rtu_baud_rate,
matching resolveDebugBaud’s precedence while retaining the existing serial and
RTU defaults.

In `@src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts`:
- Around line 321-329: Replace the malformed `as unknown as DebugSpec` fixture
in the `reports unsupported when the board declares nothing reachable` test with
a directly type-checked `DebugSpec` literal whose `channels` value is empty,
preserving coverage of the `!channels?.length` branch and the existing
unsupported assertions.

In `@src/backend/shared/simulator/modbus-rtu-client.ts`:
- Around line 579-592: Add unit tests for ModbusRtuClient.setPlcState in the
shared simulator test suite, without relying on the skipped firmware-backed
plc-control-e2e suite. Cover successful parsing, supported refusal results such
as REFUSED_BY_SWITCH, responses shorter than 8 bytes, Error rejections, and
non-Error rejections, mocking sendRequest and validating the returned
PlcControlResult for each case.

In
`@src/frontend/components/_features/`[workspace]/editor/device/configuration/board.tsx:
- Around line 381-386: Wrap runtime.clearCredentials() in a try/finally block so
device.closeRuntimeSession?.() always executes, including when credential
cleanup rejects. Move the disconnected renderer state updates until cleanup
completes, while preserving the existing token reset and session-close behavior.
- Around line 82-90: Update the modbusTcpConfigured selector to validate
vendorScreenData at runtime before accessing modbus_tcp.enabled, replacing the
unchecked Record type assertion with an existing Zod schema or type guard. Only
treat the value as configured when the validated nested data explicitly contains
enabled === true.

In `@src/frontend/components/_molecules/device-connect-button/index.tsx`:
- Around line 9-12: Update the onConnect and onDisconnect props in the
device-connect button to accept synchronous or asynchronous callbacks using void
| Promise<void>. In the onClick flow, invoke the selected callback through an
async-aware handler and catch or report any rejected promise so connection and
teardown failures are not unhandled.

In `@src/frontend/components/_organisms/workspace-activity-bar/default.tsx`:
- Around line 359-384: Move the reconnect logic currently guarded by
serialWasReleased && result.success into shared completion handling that
executes for both successful and failed uploads, including exceptions from
compiler.compileProgram. Preserve the serialWasReleased guard, silent candidate
resolution, and best-effort device.connect behavior, and remove the success-only
block from its current location.

In `@src/frontend/services/__tests__/device-link-resolution.test.ts`:
- Around line 20-33: Remove both as unknown as casts in the test fixtures: type
mockUseOpenPLCStore with an explicit callable type that includes getState, and
update boardWith to accept DebuggerTransport[] while returning a fully populated
BoardInfo literal containing compiler, core, preview, specs, debug, and
capabilities fields. Import the required DebuggerTransport type and preserve the
existing fixture behavior without type assertions.

In `@src/frontend/store/slices/device/slice.ts`:
- Line 53: Update the clearDeviceDefinitions and clearRuntimeConnection reset
actions to set runtimeConnection.switchPosition to null, ensuring every
connection reset clears the previous target’s switch position.

In `@src/frontend/utils/vpp/field-options.ts`:
- Around line 35-40: Update resolveFieldOptions in
src/frontend/utils/vpp/field-options.ts#L35-L40 to accept context with an opaque
object board value, change lookupPath’s context parameter to unknown, and remove
the Record<string, unknown> assertion while preserving its existing object
narrowing. In
src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx#L174-L176,
pass currentBoardInfo directly in { board: currentBoardInfo } without a type
assertion.

In `@src/main/modules/ipc/main.ts`:
- Around line 1699-1700: Update the return value around getMd5Hash in the
channel probe flow to explicitly populate targetMd5 from probe.md5 before or
alongside spreading probe. Preserve the existing success and match fields and
ensure verifyResult.targetMd5 contains the probed hash.
- Around line 690-698: Add the snake_case switch_position field to the parsed
/api/status response type alongside switchPosition, then map or normalize it
into the IPC switchPosition value before the gating logic uses it. Preserve
support for runtimes that provide the existing camelCase field and treat both
absent fields as undefined.

In `@src/middleware/shared/ports/debugger-port.ts`:
- Line 31: Move the PlcControlResult contract out of backend/shared/debug/types
into the shared ports layer, declaring it in debugger-port.ts or a nearby
./types module. Update debugger-port.ts, debugger-adapter.ts, and the preload
bridge to use the shared definition, and make backend/shared/debug/types
re-export it so existing backend consumers remain compatible without the port
layer importing backend code.

---

Outside diff comments:
In `@src/middleware/shared/ports/debugger-port.ts`:
- Around line 62-67: Remove the stale `@param` config documentation from the
verifyMd5 method comment, leaving documentation only for the existing
expectedMd5 parameter and the method’s purpose.

---

Minor comments:
In `@src/__architecture__/validate.ts`:
- Around line 201-206: Update the line calculation in the import/export scanning
loop using the match offset of the actual import or export keyword, rather than
match.index, so leading newlines and blank lines are excluded. Preserve path
extraction and result collection, and add a regression test covering an import
preceded by blank lines.

In `@src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts`:
- Around line 22-36: Fix both uniqueness tests in the PLC_SET_STATE and
REFUSED_BY_SWITCH cases so they count all enum members whose numeric value
equals the target, without filtering the target value out first. Assert that
each count is exactly one, preserving the existing wire-value assertions.

In
`@src/frontend/components/_features/`[workspace]/editor/device/configuration/board.tsx:
- Around line 30-37: Remove the DeviceConnectedIndicator function and both of
its usages from the two parent render paths in the board configuration
component. Keep DeviceConnectButton’s existing connected display unchanged so
each connection state has only one confirmation.

In `@src/frontend/hooks/use-device-connect.ts`:
- Around line 65-89: Track whether any connection candidate was attempted in the
device-connect flow, including both silent and prompted passes. Before the
no-response dialog block, return without opening another dialog when no
candidate was attempted, covering cancellation of the address prompt; preserve
the existing error dialog for attempts that actually ran.

In `@src/frontend/hooks/use-device-connection-monitor.ts`:
- Around line 45-56: Update the effect around the runtime connection setup to
select `runtimeConnection.ipAddress` reactively from `useOpenPLCStore` and
include that selected value in the `useEffect` dependency array. Use the
reactive `ipAddress` in the existing session-opening flow so a late address
reruns the effect and establishes the session.
- Around line 79-88: Handle rejected promises in both runtime session IPC calls:
add explicit rejection handling to the openRuntimeSession flow near the existing
result check and to closeRuntimeSession?.() at the earlier cleanup path. Log
each failure through store.consoleActions.addLog with error-level context,
ensuring no promise remains unhandled while preserving existing success and
result-error behavior.

In `@src/frontend/services/device-link-resolution.ts`:
- Around line 269-304: Update the doc block for resolveRuntimeDebugChannel by
removing the stale paragraph claiming it uses a single-channel resolver and
describing candidate resolution as a failure. Keep the implementation calling
resolveDeviceLinkCandidates and preserve the accurate inline explanation below
it.

In `@src/frontend/utils/vpp/field-options.ts`:
- Around line 31-33: Update isFieldOption to accept object options only when
value is a string, while preserving string options. Ensure FieldOption treats
label as optional and update the select rendering path to display label ?? value
when label is absent, retaining the string value for option values.

In `@src/main/modules/ipc/main.ts`:
- Around line 1901-1918: Update handleDebuggerConnect so it preserves the debug
connection type established by handleOpenRuntimeSession when the session has no
control link, rather than defaulting to tcp. Reuse the stored
params.debug.connectionType for Runtime v4 sessions while retaining the existing
getLink transport behavior where a link is available.

In `@src/middleware/adapters/editor/device-adapter.ts`:
- Around line 42-64: Update the device adapter methods connect,
openRuntimeSession, and disconnect to catch rejected window.bridge calls and
return their declared failure result shapes instead of propagating IPC
rejections. Preserve successful bridge results, and map failures to the
appropriate DeviceConnectResult or { success: false } response so callers remain
safe without requiring additional handling.

In `@src/middleware/shared/ports/device-port.ts`:
- Around line 161-172: Move the connection-status documentation block so it
immediately precedes onConnectionStatus, leaving the link-log documentation
directly above onLinkLog?. Preserve both declarations and ensure each tooltip
describes the correct subscription.

In `@src/middleware/shared/utils/target-capabilities/types.ts`:
- Around line 96-101: Update the doc comment for plcStateControl to identify the
shipped arduino-cli PLC_SET_STATE Modbus function code as FC 0x4b, and mention
FC 0x46 for reading status state. Leave the capability behavior and surrounding
target descriptions unchanged.

---

Nitpick comments:
In `@src/backend/editor/hardware/__tests__/device-session-manager.test.ts`:
- Around line 236-241: Move the jest.restoreAllMocks() call from the test body
into the existing afterEach setup so Date.now is restored even when assertions
fail. Remove the in-test restore call after h.manager.tick(), while preserving
the existing manager cleanup.
- Around line 655-676: Rename the second acquireDebugChannel holder in the
“closes its own channel only when the last holder lets go” test from “license
check” to “status poll”, and update the corresponding releaseDebugChannel call
to use the same holder id.
- Around line 126-154: Update the test using one harness instead of separate h
and h2 instances: create the harness with the verify override, clear and
populate that harness’s ports set with COM5, and use the same harness manager,
clients, and port-presence state throughout the test. Remove the unused h
instance and serialPortPresent indirection while preserving the existing
baud-attempt assertions.

In `@src/backend/editor/hardware/device-transport-factory.ts`:
- Around line 37-42: Replace the any-derived type of
DeviceTransportOptions.virtualSerialPort with an explicit serial-port interface.
Define and reuse a shared SerialPortLike type between
ModbusRtuClientOptions.serialPort and the simulator transport so
options.virtualSerialPort remains strongly typed throughout the device transport
factory.

In `@src/backend/editor/modbus/modbus-client.ts`:
- Around line 373-436: Update getBoardId to use the existing buildTcpFrame(pdu)
helper for request construction and transaction ID generation, replacing its
inline transaction, MBAP, and unit-ID setup. Preserve the subsequent
sendTcpRequest call and transaction-ID validation using the helper’s returned
request and transactionId.
- Around line 27-41: Replace the local ModbusFunctionCode and
ModbusDebugResponse enum definitions in the modbus client with imports from the
shared simulator types module. Re-export the imported enums if existing
consumers rely on this module’s exports, while preserving the PLC_SET_STATE and
REFUSED_BY_SWITCH values through the shared definitions.

In `@src/backend/shared/debug/__tests__/modbus-pdu.test.ts`:
- Around line 206-225: Add a test case alongside “parses running / tick / uptime
on success” that builds a 12-byte successful status frame by appending a
switch-position byte, invokes parseGetStatusResponse, and asserts the returned
switchPosition value while preserving the existing parsed fields. This must
exercise the data.length >= 12 branch and maintain full coverage for the parser.

In `@src/backend/shared/hardware/debug-spec.ts`:
- Around line 121-126: Update the CHANNEL_TRANSPORT declaration to use a partial
record so unmapped channel kinds are typed as possibly undefined, preserving the
existing filter behavior that excludes channels without a configured transport.
- Around line 412-422: Deduplicate the transport values used by the outer loop
in the eligibility calculation so repeated entries in options.transports cannot
push the same channel index more than once. Update the loop around eligible and
preserve the existing included, CHANNEL_TRANSPORT, and enabledWhen checks.

In `@src/backend/shared/simulator/__tests__/debug-e2e.test.ts`:
- Line 100: Remove the non-null assertions from the debug result tests: in
src/backend/shared/simulator/__tests__/debug-e2e.test.ts lines 100-100, assert
and narrow both tick values before comparing them; at lines 115-115, narrow
result.boardId before reading its length. In
src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts lines 700-700
and 712-712, bind each result.boardId to a local and explicitly narrow it before
using Array.from or asserting the empty-id case.

In `@src/backend/shared/simulator/__tests__/plc-control-e2e.test.ts`:
- Around line 196-230: The tests 'boots STOPPED when the switch reads STOP, and
reports the position' and 'refuses a RUN request while the switch reads STOP'
have a timing race: the switch HAL reads STOP until 3s uptime, but connect()
resolves at 2.5s, leaving only 500ms margin. Emulator overhead can push
execution past that window and find the switch already transitioned to RUN.
Update the test that calls setPlcState to first verify the actual observed
switchPosition before asserting about the refusal behavior, or make the refusal
assertions conditional on confirming switchPosition is still STOP. This ensures
the test does not fail when timing causes the switch to transition between test
setup and assertion.

In
`@src/frontend/components/_molecules/device-connect-button/__tests__/device-connect-button.test.tsx`:
- Around line 45-67: Remove the HTMLButtonElement type assertions in the tests
for “is inert while connecting,” “explains itself when something blocks
connecting,” and “stays live when nothing blocks it.” Use the generic form of
screen.getByRole<HTMLButtonElement>(...) or the appropriate DOM matcher such as
toBeDisabled(), while preserving the existing assertions and behavior.

In `@src/frontend/components/_organisms/modals/runtime-connection-lost-modal.tsx`:
- Line 13: Replace the type assertion on modalData with a type guard that
validates the payload as an object containing optional string label and body
fields, then use the guard’s narrowed result while preserving undefined for
invalid or missing data. Define the guard near the modal data access and apply
it to modals['runtime-connection-lost']?.data.

In `@src/frontend/components/_organisms/workspace-activity-bar/default.tsx`:
- Around line 391-407: Update the dependency array for handleBuild to include
the debuggerPort and device values read in its body, and remove runtime because
it is no longer referenced there. Keep all other existing dependencies
unchanged.
- Around line 529-530: Update handlePlcControl to remove both non-const type
assertions: add or reuse a board type exposing optional
stateControl.modeSwitch.label for typed access to switchLabel, and define an
explicit return type for the PLC status mapping so the mapped literal is
inferred without casting to NonNullable<RuntimeConnection['plcStatus']>.
Preserve the existing behavior and values.

In `@src/frontend/hooks/__tests__/use-device-connect.test.ts`:
- Around line 20-22: Extract a shared typed mock helper for the callable
useOpenPLCStore shape with its getState property, then use it in both
use-device-connect.test.ts:20-22 and
use-device-connection-monitor.test.ts:24-25. Remove both local as unknown as
casts while preserving selector and state behavior; the helper should be the
only implementation change needed at these sites.

In `@src/frontend/hooks/__tests__/use-device-plc-state.test.ts`:
- Line 47: Replace the non-null assertions on `pushed` in the affected tests
with a small helper that validates the hook subscribed, throws a clear error
when it did not, and returns the callable push function. Update each test to
invoke the helper as `push(...)` while preserving the existing payloads and
assertions.

In `@src/frontend/hooks/__tests__/use-runtime-polling.test.ts`:
- Line 8: Add assertions in the runtime polling hook tests using
mockSetPlcSwitchPosition to verify successful polls pass
statusResult.switchPosition ?? null and clearConnectionState passes null,
including the default-to-null case.

In `@src/frontend/services/device-link-resolution.ts`:
- Around line 96-98: Replace the type assertion in the screens initialization
within the device-link resolution flow with boundary validation for
cfg.vendorScreenData. Add or reuse a type guard or Zod schema that verifies the
nested record shape, use the validated value when valid, and fall back to an
empty record when absent or invalid.

In `@src/frontend/utils/device-connect-events.ts`:
- Around line 11-23: Add a focused unit test for requestDeviceFlash and
onDeviceFlashRequest that registers a handler, dispatches the flash request
event, verifies the handler is called, and invokes the returned unsubscribe
function. Ensure the test exercises the real module implementations rather than
mocks and cleans up the subscription.

In `@src/main/modules/ipc/renderer.ts`:
- Around line 413-424: Update debuggerPlcControl in the renderer IPC API to
return the shared PlcControlResult type instead of its duplicated inline object
shape. Add the type-only import from the existing debug types module and
preserve the current action parameter and IPC invocation.
- Around line 352-357: Import the existing CommunicationPort type alongside the
current type imports in renderer.ts, then replace the duplicated inline port
object types in getAvailableCommunicationPorts and refreshCommunicationPorts
with CommunicationPort[].

In `@src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts`:
- Around line 215-229: Update both setPlcState tests in the setPlcState describe
block to be async and await adapter.setPlcState('STOPPED') and
adapter.setPlcState('RUNNING') instead of discarding the promises with void.
Keep the existing debuggerPlcControl assertions unchanged.
- Around line 78-93: Remove the three redundant connection-type tests around
adapter.connect—“supports simulator connection type,” “supports websocket
connection type with JWT,” and “supports RTU connection type with serial
params”—from the debugger adapter test suite. Keep the existing equivalent
connect test and do not add replacement coverage, since connect() accepts no
configuration to distinguish these types.
- Around line 235-254: In the verifyMd5 tests, rename “uses different configs
per call” to describe verifying different MD5 values per call, and remove the
unused simConfig binding. Also remove the DebugConnectionConfig import if no
other tests in the file reference it.

In `@src/middleware/adapters/editor/__tests__/device-adapter.test.ts`:
- Around line 76-100: Extend the device-adapter test suite with cases for
openRuntimeSession, closeRuntimeSession, releaseSerialPort, onLinkLog, and
onPlcState, verifying each delegates to the corresponding window.bridge method
and returns or exposes its result correctly. For releaseSerialPort, explicitly
assert the adapter returns result.released, and for the subscription methods
verify the callback delegation and returned unsubscribe function to achieve full
coverage.

In `@src/middleware/shared/ports/device-port.ts`:
- Around line 174-183: Define and export a shared DevicePlcStatePayload in
src/middleware/shared/ports/device-port.ts#L174-183 with descriptor instead of
port, optionally narrowing plcState to 0 | 1 | 2 and switchPosition to 0 | 1,
and use it in onPlcState. In
src/middleware/adapters/editor/device-adapter.ts#L74-76, import and use this
type; in src/main/modules/ipc/renderer.ts#L500-507, import it and use it for
both the callback parameter and listener payload.
- Around line 113-159: Update the DevicePort interface signatures for connect
and releaseSerialPort to align with their documented web no-op behavior, either
by making them optional or by documenting concrete web return values such as
connect returning failure; preserve the required contract only if web
implementations provide those returns. Also revise disconnect’s comment to
describe closing the held device connection rather than only a serial link.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4bf7f039-2878-4afe-944a-7d168815f987

📥 Commits

Reviewing files that changed from the base of the PR and between bfa9f16 and 1c8ee64.

⛔ Files ignored due to path filters (24)
  • resources/sources/Baremetal/ARCHITECTURE.md is excluded by !resources/**
  • resources/sources/Baremetal/Baremetal.ino is excluded by !resources/**
  • resources/sources/Baremetal/ModbusSlave.cpp is excluded by !resources/**
  • resources/sources/Baremetal/ModbusSlave.h is excluded by !resources/**
  • resources/sources/Baremetal/modbus_config.h is excluded by !resources/**
  • resources/sources/Baremetal/modbus_crc.cpp is excluded by !resources/**
  • resources/sources/Baremetal/modbus_crc.h is excluded by !resources/**
  • resources/sources/Baremetal/modbus_debug.cpp is excluded by !resources/**
  • resources/sources/Baremetal/modbus_debug.h is excluded by !resources/**
  • resources/sources/Baremetal/modbus_frame.cpp is excluded by !resources/**
  • resources/sources/Baremetal/modbus_frame.h is excluded by !resources/**
  • resources/sources/Baremetal/modbus_pdu.cpp is excluded by !resources/**
  • resources/sources/Baremetal/modbus_pdu.h is excluded by !resources/**
  • resources/sources/Baremetal/modbus_registers.cpp is excluded by !resources/**
  • resources/sources/Baremetal/modbus_registers.h is excluded by !resources/**
  • resources/sources/Baremetal/modbus_serial.cpp is excluded by !resources/**
  • resources/sources/Baremetal/modbus_serial.h is excluded by !resources/**
  • resources/sources/Baremetal/modbus_tcp.cpp is excluded by !resources/**
  • resources/sources/Baremetal/modbus_tcp.h is excluded by !resources/**
  • resources/sources/Baremetal/modbus_types.h is excluded by !resources/**
  • resources/sources/Baremetal/openplc_version.h is excluded by !resources/**
  • resources/sources/arduino/arduino_runtime_glue.cpp is excluded by !resources/**
  • resources/sources/arduino/arduino_runtime_glue.h is excluded by !resources/**
  • resources/sources/arduino/openplc.h is excluded by !resources/**
📒 Files selected for processing (77)
  • src/__architecture__/validate.ts
  • src/backend/editor/compiler/__tests__/handle-precompile-user-lib.test.ts
  • src/backend/editor/compiler/compiler-module.ts
  • src/backend/editor/hardware/__tests__/device-link-policy.test.ts
  • src/backend/editor/hardware/__tests__/device-probe.test.ts
  • src/backend/editor/hardware/__tests__/device-session-manager.test.ts
  • src/backend/editor/hardware/__tests__/serial-port-list.test.ts
  • src/backend/editor/hardware/device-link-policy.ts
  • src/backend/editor/hardware/device-probe.ts
  • src/backend/editor/hardware/device-session-manager.ts
  • src/backend/editor/hardware/device-transport-factory.ts
  • src/backend/editor/hardware/hardware-module.ts
  • src/backend/editor/hardware/serial-port-list.ts
  • src/backend/editor/hardware/types.ts
  • src/backend/editor/modbus/modbus-client.ts
  • src/backend/editor/modbus/modbus-rtu-client.ts
  • src/backend/shared/compile/__tests__/generate-defines.test.ts
  • src/backend/shared/compile/__tests__/modbus-defines.test.ts
  • src/backend/shared/compile/steps/generate-defines.ts
  • src/backend/shared/compile/steps/modbus-defines.ts
  • src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts
  • src/backend/shared/debug/__tests__/modbus-pdu.test.ts
  • src/backend/shared/debug/modbus-pdu.ts
  • src/backend/shared/debug/types.ts
  • src/backend/shared/debug/websocket-debug-transport.ts
  • src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts
  • src/backend/shared/hardware/__tests__/debug-spec.test.ts
  • src/backend/shared/hardware/debug-spec.ts
  • src/backend/shared/simulator/__tests__/debug-e2e.test.ts
  • src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts
  • src/backend/shared/simulator/__tests__/plc-control-e2e.test.ts
  • src/backend/shared/simulator/modbus-rtu-client.ts
  • src/backend/shared/simulator/types.ts
  • src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx
  • src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx
  • src/frontend/components/_molecules/device-connect-button/__tests__/device-connect-button.test.tsx
  • src/frontend/components/_molecules/device-connect-button/index.tsx
  • src/frontend/components/_organisms/modals/confirm-device-switch-modal.tsx
  • src/frontend/components/_organisms/modals/runtime-connection-lost-modal.tsx
  • src/frontend/components/_organisms/workspace-activity-bar/default.tsx
  • src/frontend/components/_templates/app-layout.tsx
  • src/frontend/hooks/__tests__/use-device-connect.test.ts
  • src/frontend/hooks/__tests__/use-device-connection-monitor.test.ts
  • src/frontend/hooks/__tests__/use-device-plc-state.test.ts
  • src/frontend/hooks/__tests__/use-runtime-polling.test.ts
  • src/frontend/hooks/use-device-connect.ts
  • src/frontend/hooks/use-device-connection-monitor.ts
  • src/frontend/hooks/use-device-plc-state.ts
  • src/frontend/hooks/use-runtime-polling.ts
  • src/frontend/hooks/useDebugSession.ts
  • src/frontend/screens/workspace-screen.tsx
  • src/frontend/services/__tests__/device-link-resolution.test.ts
  • src/frontend/services/device-link-resolution.ts
  • src/frontend/store/__tests__/device-slice.test.ts
  • src/frontend/store/__tests__/device-types.test.ts
  • src/frontend/store/slices/device/index.ts
  • src/frontend/store/slices/device/slice.ts
  • src/frontend/store/slices/device/types.ts
  • src/frontend/utils/__tests__/serial-port-label.test.ts
  • src/frontend/utils/device-connect-events.ts
  • src/frontend/utils/serial-port-label.ts
  • src/frontend/utils/vpp/__tests__/field-options.test.ts
  • src/frontend/utils/vpp/field-options.ts
  • src/main/modules/ipc/main.ts
  • src/main/modules/ipc/renderer.ts
  • src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts
  • src/middleware/adapters/editor/__tests__/device-adapter.test.ts
  • src/middleware/adapters/editor/debugger-adapter.ts
  • src/middleware/adapters/editor/device-adapter.ts
  • src/middleware/shared/ports/debugger-port.ts
  • src/middleware/shared/ports/device-port.ts
  • src/middleware/shared/ports/runtime-port.ts
  • src/middleware/shared/ports/types.ts
  • src/middleware/shared/utils/debug-endpoint.ts
  • src/middleware/shared/utils/target-capabilities/presets.ts
  • src/middleware/shared/utils/target-capabilities/resolve.ts
  • src/middleware/shared/utils/target-capabilities/types.ts

Comment thread src/backend/editor/compiler/compiler-module.ts
Comment thread src/backend/editor/hardware/device-session-manager.ts
Comment thread src/backend/editor/hardware/device-session-manager.ts
Comment thread src/backend/editor/hardware/device-transport-factory.ts
Comment thread src/frontend/store/slices/device/slice.ts
Comment thread src/frontend/utils/vpp/field-options.ts
Comment thread src/main/modules/ipc/main.ts
Comment thread src/main/modules/ipc/main.ts Outdated
Comment thread src/middleware/shared/ports/debugger-port.ts

@JulioSergioFS JulioSergioFS left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — openplc-editor #980

Verdict

Strong work, and close to ready on its own merits.

DeviceSessionManager / DeviceLinkPolicy is a clean single-ownership design: one held
link, two channel slots that collapse to the same client when one medium serves both roles,
and the counting for down/back/lost isolated in a class that can be tested without a cable.
That structure is what actually fixes run/stop over Modbus TCP — the transient second socket
an Arduino TCP server never answers.

Verified rather than taken on trust:

Check Result
src/backend/editor/hardware/__tests__ + src/backend/shared/hardware/__tests__ ✅ 8 suites, 159 tests passing
FC 0x46 status frame offsets, TS ↔ firmware ✅ agree byte-for-byte (see below)
FC 0x4b run/stop response layout [FC][status][plc_state][switch], matches plcSetState
DeviceLinkPolicy reopen accounting onReopenResult(true) always returns recovered, so no client leaks on the success path

The status-frame check in full, because a mismatch here is exactly the class of field bug
this PR exists to fix. Firmware writes mb_frame[3] = state, [4..7] = tick BE,
[8..11] = uptime BE, [12] = switch, mb_frame_len = 13 — so 12 PDU bytes once the slave
byte is stripped. parseGetStatusResponse reads running at 2, readU32BE(data, 3),
readU32BE(data, 7), and switch at 11, guarding < 11 and gating the switch on >= 12.
Correct, including the "11 bytes on older firmware" back-compat case.

The two non-feature fixes are correct and well justified: fileURLToPath for the C:\C:\…
win32 SRC_ROOT, and .cpp.o object naming for the esp8266 linker script (the IRAM
overflow diagnosis — 7387 bytes of ordinary code in a 32 KB segment — is the kind of root
cause that saves the next person a day). Two smaller additions in compiler-module.ts are
also good calls: adding ArduinoUniqueID to the always-installed libs (FC 0x48 needs it,
and the include isn't behind a gate), and turning a missing HAL into a hard error naming the
board and path instead of an undefined-reference wall several hundred log lines later.


1. Blocking for the pair, not for this diff

Web #641 cannot ship as written: the shared UI this PR introduces gates every debug
session and Run/Stop on deviceConnection.status === 'connected', and nothing on web ever
publishes that status. Full detail in PR-641-web-review.md.

Relevant here only for where the fix lands. The recommended fix — the web DevicePort
publishing a real session status — is entirely inside src/middleware/adapters/web/ and
needs no change to this PR, which is why it is the better of the two routes.

The alternative route would touch this PR, because two of the implicated files are
byte-identical shared surface:

  • src/frontend/components/_organisms/workspace-activity-bar/default.tsx — the gate itself,
    if it is made capability-aware instead of status-aware.
  • src/frontend/hooks/useDebugSession.ts:189 — the ?? 'simulator' fallback (see §3).

Per the sync gate, either change has to originate here and be mirrored. Worth agreeing on
the route before either PR moves.


2. Should fix — resolveDebugBaud and generateModbusDefines disagree

Both live in src/backend/shared/compile/steps/modbus-defines.ts and are documented to
mirror each other, but they read modbus_rtu.baud_rate differently when RTU is on the
default port. Executed against this branch:

legacy: RTU on default, rtu_baud_rate 9600
   MBSERIAL_BAUD=9600    DEBUG_BAUD=9600   shared=true
phase2 field on default port: baud_rate 9600
   MBSERIAL_BAUD=115200  DEBUG_BAUD=9600   shared=true   <-- MISMATCH
phase2 both: baud_rate 9600 + rtu_baud_rate 19200
   MBSERIAL_BAUD=19200   DEBUG_BAUD=9600   shared=true   <-- MISMATCH
serial section wins
   MBSERIAL_BAUD=57600   DEBUG_BAUD=57600  shared=true

Two macros describing the same physical port, emitted into the same defines.h, disagreeing.

generateModbusDefines looks right per the field's own documented meaning — "baud for RTU
on a secondary port. On the default port the Serial section's baud is used"
— so
resolveDebugBaud is the one that should not consult rtu.baud_rate when
iface === defaultSerial.

Latent today: no published VPP carries the Phase-2 serial/network sections, and the
migration commits were deliberately left out of this PR. But it lands the moment the
split-screen work does, and it is the same "No Firmware Detected on a healthy board" class
this PR just spent a commit fixing.

3. Should fix — ?? 'simulator' is the wrong default for an unknown medium

src/frontend/hooks/useDebugSession.ts:189:

wsActions.setDebugConnectionType(
  useOpenPLCStore.getState().deviceConnection.debugTransport ?? "simulator",
);

Harmless in the editor, where the manager always publishes debugTransport. But 'simulator'
is a meaningful value, not a neutral one: in useDebugPolling it selects RTU framing —
19-variable batches and a 50 ms cadence — and short-circuits the usesHttpFallback branch.
Defaulting an unknown medium to the tightest cadence in the system is the wrong direction to
fail in.

The sibling call in default.tsx:708 already guards correctly (if (activeTransport)), so
this is also an inconsistency between two call sites doing the same job.

This is what breaks web today (§1), but it is worth fixing on its own merits regardless of
which route the pair takes.


Minor

  • MBSERIAL_SHARES_DEBUG_SERIAL is emitted but never read. No #ifdef tests it anywhere
    in the firmware — only a comment in Baremetal.ino:171 mentions it. The generator's own
    comment ("tell the firmware to begin the port once") implies it does something. Either
    gate on it or drop it; a define that looks load-bearing and isn't will mislead the next
    reader.

  • debug-spec.ts — the TCP-only justification is factually wrong. The comment on the new
    rtu fallback claims it is skipped for TCP-only builds "matching the firmware which does NOT
    bring up the serial debugger in that configuration."
    Baremetal.ino:176-181 does exactly
    that: #elif defined(DEBUGGER_ENABLED)DEBUG_IFACE.begin(DEBUG_BAUD) +
    modbus.slaveid = DEBUG_SLAVE. The behaviour is fine — resolveDeviceLinkCandidates
    exempts rtu unconditionally, which is right precisely because the firmware does bring it up
    — but the stated reason is backwards, and it is the kind of comment that justifies the wrong
    change later.

  • device-link-resolution.tsresolveRuntimeDebugChannel docblock contradicts itself.
    It opens "Uses the SINGLE-channel resolver, not the candidate one", then the code calls
    resolveDeviceLinkCandidates and the next paragraph says "The SAME resolver Connect uses."
    The first paragraph is stale.

  • modbus_debug.cppdebugGetStatus header comment stale. Still reads
    [FC, STATUS, running:u8, tick:u32 BE, uptime_ms:u32 BE] (no switch byte) and "running
    is always 1 on baremetal (the PLC scan is unconditional)"
    — both contradicted by the
    function body eight lines down, which writes runtime_get_plc_state() and appends
    mb_frame[12].

  • extractImports line attribution. The new whole-source regex uses an unbounded lazy
    [\s\S]*? between import|export and from. A non-import export above the import block
    matches forward to a later import's path and reports it at the export's line. No import is
    missed (the lazy quantifier stops at the first from '…'), so this is cosmetic — violation
    reports can point at the wrong line. The multi-line fix itself is a clear improvement and
    worth having; it found a real pre-existing violation in the activity bar.

  • DeviceSessionManager.acquireDebugChannel — concurrent-open leak. Two callers arriving
    while debugClientHeld === null both create() + await connect(); the second assignment
    overwrites the first, leaking a connected channel. Wants an in-flight promise. Narrow window,
    and I did not find a caller that hits it today.

  • open() has no re-entrancy guard. A second open() while the first is awaiting
    tryCandidate calls close({ silent: true }), and the first then assigns this.client
    afterwards. Needs a double-click on Connect to reach; noting it since the module's whole
    premise is single ownership.


Notes, not objections

  • declareLost calls close({ silent: true }), which clears candidates — so a serial device
    unplugged and replugged needs a manual reconnect rather than being picked up. Consistent with
    the documented gonefail-now design (nothing to reset, nothing to wait for); flagging
    only so it reads as a conscious choice.
  • noteTraffic is correctly called on success only, in both call sites (getVariablesList and
    setVariable). That invariant is what the whole liveness shortcut rests on, so it is worth
    stating that it holds.
  • The run/stop command path and the status poll do not call noteTraffic. Only a missed
    optimisation, not a defect.

Eight defects found reviewing this branch, plus the tests that pin each.

Connection lifecycle
- Connect could wedge the UI permanently. The renderer sets 'connecting'
  optimistically and only the main process clears it, but two paths return
  before `deviceSession.open()` and so publish nothing: a cancelled DHCP
  address prompt, and a candidate list that built no usable transport. The
  button is disabled while 'connecting' and Disconnect only fires when
  'connected', so the user had to reopen the project. Both early returns now
  emit 'disconnected', and the hook settles any non-success outcome in a
  `finally` (not on success — the status push and the invoke reply travel
  separate IPC channels, so settling there would risk a flicker).
- A Runtime v3/v4 debug channel never closed. `requireDebug` registered every
  per-command caller as a lifetime holder, and `read variables` runs on every
  poll tick, so `releaseDebugChannel('debug session')` always found the set
  non-empty. Stopping the debugger left an authenticated channel open to the
  PLC until logout. Per-command callers now go through `withDebugChannel`,
  which releases in a `finally`; `debug session` stays the only lifetime
  holder. Safe for baremetal by construction: `releaseDebugChannel` returns
  early on a shared channel before touching any client.

Firmware / editor agreement
- `DEBUG_SLAVE` was never emitted, so the firmware fell back to
  modbus_config.h's `1` while the editor addressed the RTU screen's slave id
  (spec `params` are read regardless of `enabledWhen`). Every frame was
  dropped at the id check and reported as "No Firmware Detected" on a healthy
  board — the same defect class as the DEBUG_BAUD fix, but with no baud sweep
  to recover it. Added `resolveDebugSlave` and emitted `#define DEBUG_SLAVE`.

Run/stop
- Runtime v3 lost Start/Stop silently. v3 exposes the same JWT-authenticated
  `/api/start-plc` and `/api/stop-plc` as v4 (webserver/restapi.py routes
  under `url_prefix='/api'`); only the debug channel differs. The main process
  already routes the command over REST for both, so `plcStateControl: false`
  was the only thing stopping it. Also folded the capability into
  `plcControlBlocked`, so a target that genuinely lacks run/stop shows a
  reason instead of an enabled button that does nothing.
- `verifyMd5` dropped `targetMd5`: `Md5ProbeResult` names the hash `md5`, so
  `...probe` left the declared field undefined and a spread skips excess
  property checks. The mismatch report read "Target: undefined".
- Corrected the run/stop function code in the capability doc (0x49 -> 0x4b).

Tests
- compiler-module.spec.ts asserted the old `foo.o` archive names and failed on
  this branch; updated to `foo.cpp.o`.
- Restored coverage on every file this branch touches under a 100% threshold:
  device-adapter (100 -> 62.5%), debugger-adapter, the new
  device-connect-events, plus previously uncovered `setPlcSwitchPosition`,
  `setDeviceConnectionStatus`'s transport arguments, the simulator client's
  `setPlcState`, and two resolver/parser error paths. Measured against the
  branch's merge base, all four threshold directories are now at or above
  baseline and no file lost coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/modules/ipc/main.ts (1)

1891-1908: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Do not let a status read reject a verified candidate.

verifyDeviceCandidate awaits pushPlcState() after classifyDeviceLink returns connected-with-firmware, but DeviceSessionManager.tryCandidate treats any verify rejection as { ok: false }. A rejected status read can close an otherwise usable candidate. Wrap pushPlcState so failed reads only skip the initial push.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/modules/ipc/main.ts` around lines 1891 - 1908, The pushPlcState call
used by verifyDeviceCandidate must not propagate status-read failures and reject
an otherwise verified connected-with-firmware candidate. Catch failures around
the initial push after classifyDeviceLink succeeds, allowing verification to
continue while only skipping the PLC state update; keep normal successful push
behavior unchanged.
🧹 Nitpick comments (5)
src/frontend/components/_organisms/workspace-activity-bar/default.tsx (2)

537-538: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace the stateControl cast with a type guard.

Line 537 casts boardInfo to an inline shape to read stateControl.modeSwitch.label. That value comes from a VPP manifest, which is external data. The coding guidelines forbid type assertions and require a Zod schema or a type guard at the boundary. Declare stateControl on the board-info type, or narrow it with a guard before reading the label.

♻️ Proposed change
-    const switchLabel = (boardInfo as { stateControl?: { modeSwitch?: { label?: string } } } | undefined)?.stateControl
-      ?.modeSwitch?.label
+    const switchLabel = readModeSwitchLabel(boardInfo)

Add the guard outside this callback:

function readModeSwitchLabel(boardInfo: unknown): string | undefined {
  if (typeof boardInfo !== 'object' || boardInfo === null) return undefined
  const { stateControl } = boardInfo as Record<string, unknown>
  if (typeof stateControl !== 'object' || stateControl === null) return undefined
  const { modeSwitch } = stateControl as Record<string, unknown>
  if (typeof modeSwitch !== 'object' || modeSwitch === null) return undefined
  const { label } = modeSwitch as Record<string, unknown>
  return typeof label === 'string' ? label : undefined
}

A Zod schema for the manifest stateControl block is the cleaner option if one already exists for the surrounding board type.

As per coding guidelines: "Do not use type assertions, except as const" and "Validate external data at boundaries, including IPC payloads, project files, and downloaded-binary metadata, using Zod schemas or type guards instead of casts."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/components/_organisms/workspace-activity-bar/default.tsx` around
lines 537 - 538, Replace the inline cast used to read the label in the
switch-label logic with boundary validation. Prefer an existing Zod schema for
the board manifest; otherwise add a type guard or helper such as
readModeSwitchLabel that safely narrows unknown stateControl, modeSwitch, and
label values, then use it at the callback call site without type assertions.

Source: Coding guidelines


591-599: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace the state-code assertion with named PlcRuntimeState values.

The ternary already produces 'RUNNING' | 'ERROR' | 'STOPPED'; add PlcRuntimeState to the imports/branch mapping and pass the status without as NonNullable<RuntimeConnection['plcStatus']> so this path avoids a type assertion and aligns 1/2 with PlcRuntimeState.RUNNING/PlcRuntimeState.ERROR.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/components/_organisms/workspace-activity-bar/default.tsx` around
lines 591 - 599, Update the result.state mapping in the workspace activity bar
to use the imported PlcRuntimeState.RUNNING and PlcRuntimeState.ERROR named
values, with STOPPED for other states. Remove the as
NonNullable<RuntimeConnection['plcStatus']> assertion and pass the resulting
PlcRuntimeState-compatible status directly to setPlcRuntimeStatus.

Source: Coding guidelines

src/frontend/utils/__tests__/device-connect-events.test.ts (1)

34-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unsubscribe in a cleanup hook so one failure does not cascade.

Each test unsubscribes on its success path only. The assertions at Lines 42-43 run before unsubFirst() and unsubSecond() at Lines 44-45. If Line 42 fails, both listeners survive into the following tests and break their call-count assertions, which hides the original failure. Collect the unsubscribe functions and release them in an afterEach.

♻️ Proposed change
 describe('device flash-request bridge', () => {
+  const cleanups: Array<() => void> = []
+  const subscribe = (handler: () => void): void => {
+    cleanups.push(onDeviceFlashRequest(handler))
+  }
+
+  afterEach(() => {
+    while (cleanups.length > 0) cleanups.pop()?.()
+  })

Then replace the per-test onDeviceFlashRequest(...) calls with subscribe(...) and drop the manual unsubscribe() calls, except in the tests that assert unsubscribe behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/utils/__tests__/device-connect-events.test.ts` around lines 34 -
46, Update the device-connect event tests to register cleanup callbacks in an
afterEach hook, using a shared subscription collection and subscribe helper for
onDeviceFlashRequest listeners. Replace per-test manual unsubscribe calls with
this cleanup, while retaining explicit unsubscribe calls only in tests that
verify unsubscribe behavior.
src/frontend/hooks/use-device-connect.ts (1)

57-61: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse the device-port connect result type.

Import DeviceConnectResult and type result with it instead of duplicating the connect-status union. Replace the current branches with an exhaustive switch (result.status) and add a default: never case so future device.connect status values fail type checking instead of falling through.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/hooks/use-device-connect.ts` around lines 57 - 61, In
use-device-connect, import and reuse the existing DeviceConnectResult type for
result instead of duplicating its status union. Replace the result-status
branching with an exhaustive switch on result.status, including a default never
case so newly added device.connect statuses fail type checking rather than
falling through.

Source: Coding guidelines

src/frontend/hooks/__tests__/use-device-connect.test.ts (1)

12-17: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Type the mocked store state instead of casting it.

mockState.deviceConnection is declared as unknown, so currentStatus uses a forbidden type assertion. Give deviceConnection an explicit type, such as DeviceConnection, and read .status directly.

currentStatus appears only once, so there is no duplicate declaration issue.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/hooks/__tests__/use-device-connect.test.ts` around lines 12 -
17, Type mockState.deviceConnection explicitly with the appropriate
DeviceConnection type in the mocked store state, then update currentStatus to
access .status directly without a type assertion. Keep
mockSetDeviceConnectionStatus and the existing status-reading behavior
unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/backend/editor/hardware/__tests__/device-session-channel-lifetime.test.ts`:
- Line 65: Replace the unsafe mock casts with contract-preserving typed test
doubles: in
src/backend/editor/hardware/__tests__/device-session-channel-lifetime.test.ts at
lines 65, 96, and 116, build a typed DeviceDebugChannel fixture, and at line 140
build a typed DeviceModbusTransport fixture. In
src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts at lines 233
and 249, use jest.mocked(...) for bridge methods instead of as jest.Mock. In
src/middleware/adapters/editor/__tests__/device-adapter.test.ts at line 137, use
a typed Jest mock for window.bridge.deviceReleaseSerialPort.

---

Outside diff comments:
In `@src/main/modules/ipc/main.ts`:
- Around line 1891-1908: The pushPlcState call used by verifyDeviceCandidate
must not propagate status-read failures and reject an otherwise verified
connected-with-firmware candidate. Catch failures around the initial push after
classifyDeviceLink succeeds, allowing verification to continue while only
skipping the PLC state update; keep normal successful push behavior unchanged.

---

Nitpick comments:
In `@src/frontend/components/_organisms/workspace-activity-bar/default.tsx`:
- Around line 537-538: Replace the inline cast used to read the label in the
switch-label logic with boundary validation. Prefer an existing Zod schema for
the board manifest; otherwise add a type guard or helper such as
readModeSwitchLabel that safely narrows unknown stateControl, modeSwitch, and
label values, then use it at the callback call site without type assertions.
- Around line 591-599: Update the result.state mapping in the workspace activity
bar to use the imported PlcRuntimeState.RUNNING and PlcRuntimeState.ERROR named
values, with STOPPED for other states. Remove the as
NonNullable<RuntimeConnection['plcStatus']> assertion and pass the resulting
PlcRuntimeState-compatible status directly to setPlcRuntimeStatus.

In `@src/frontend/hooks/__tests__/use-device-connect.test.ts`:
- Around line 12-17: Type mockState.deviceConnection explicitly with the
appropriate DeviceConnection type in the mocked store state, then update
currentStatus to access .status directly without a type assertion. Keep
mockSetDeviceConnectionStatus and the existing status-reading behavior
unchanged.

In `@src/frontend/hooks/use-device-connect.ts`:
- Around line 57-61: In use-device-connect, import and reuse the existing
DeviceConnectResult type for result instead of duplicating its status union.
Replace the result-status branching with an exhaustive switch on result.status,
including a default never case so newly added device.connect statuses fail type
checking rather than falling through.

In `@src/frontend/utils/__tests__/device-connect-events.test.ts`:
- Around line 34-46: Update the device-connect event tests to register cleanup
callbacks in an afterEach hook, using a shared subscription collection and
subscribe helper for onDeviceFlashRequest listeners. Replace per-test manual
unsubscribe calls with this cleanup, while retaining explicit unsubscribe calls
only in tests that verify unsubscribe behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 43aa918d-cd1f-4d01-9143-ce81bd2423e4

📥 Commits

Reviewing files that changed from the base of the PR and between 1c8ee64 and 87001fd.

⛔ Files ignored due to path filters (1)
  • resources/sources/Baremetal/modbus_config.h is excluded by !resources/**
📒 Files selected for processing (20)
  • src/backend/editor/compiler/compiler-module.spec.ts
  • src/backend/editor/hardware/__tests__/device-session-channel-lifetime.test.ts
  • src/backend/editor/hardware/device-session-manager.ts
  • src/backend/shared/compile/__tests__/generate-defines.test.ts
  • src/backend/shared/compile/__tests__/modbus-defines.test.ts
  • src/backend/shared/compile/steps/generate-defines.ts
  • src/backend/shared/compile/steps/modbus-defines.ts
  • src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts
  • src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts
  • src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts
  • src/frontend/components/_organisms/workspace-activity-bar/default.tsx
  • src/frontend/hooks/__tests__/use-device-connect.test.ts
  • src/frontend/hooks/use-device-connect.ts
  • src/frontend/store/__tests__/device-slice.test.ts
  • src/frontend/utils/__tests__/device-connect-events.test.ts
  • src/main/modules/ipc/main.ts
  • src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts
  • src/middleware/adapters/editor/__tests__/device-adapter.test.ts
  • src/middleware/shared/utils/target-capabilities/presets.ts
  • src/middleware/shared/utils/target-capabilities/types.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/middleware/shared/utils/target-capabilities/types.ts
  • src/backend/shared/compile/steps/generate-defines.ts
  • src/backend/shared/debug/tests/modbus-pdu-plc-control.test.ts
  • src/backend/shared/compile/tests/generate-defines.test.ts
  • src/backend/editor/hardware/device-session-manager.ts

…latforms

The poller had three sources of truth for the same fact, and the browser's two
ways of reaching a runtime had no name at all.

What was wrong
- `workspace.debugConnectionType` — a copy of the spec's channel kind, set at
  session start — drove batch size.
- `session.debugTransport` ('http' | 'webrtc') in the WebRTC slice drove the
  cadence, behind a `!capabilities.isNativeApplication` platform check.
- `backend/shared/debug/types.ts` declared a THIRD `DebugConnectionType`
  ('webrtc' | 'http' | 'simulator'), same name as the ports one but different
  members, stored by the debug bridge and read by nothing.
- `deviceConnection.debugTransport` then added a fourth.
They could and did disagree: a session whose medium was not yet known fell
through to `'simulator'`, which is both the smallest batch (19 vs 500) and the
fastest cadence (50ms) — and, because the RTU branch short-circuits first, it
also defeated the deliberate 1000ms relay throttle.

What replaces it
`DebugMedium` names every medium a live session can ride, including the two the
browser distinguishes and no spec does: `webrtc` (data channel to the
orchestrator agent) and `http-relay` (browser -> Autonomy Edge -> agent
websocket -> runtime). `DEBUG_MEDIUM_PROFILE` maps each to a batch size and a
cadence, because those are two independent physical limits: batch is the frame
budget at the far end (identical for websocket / webrtc / http-relay — all three
terminate at the same debug socket on the runtime), cadence is link latency
(200ms direct or peer-to-peer, 1000ms through two relays).

The poller now reads one field, `deviceConnection.debugTransport`, published by
the connection manager — the main process on the editor, the WebRTC lifecycle
manager in the browser. It reads it LIVE, so a data channel that drops to the
relay re-paces mid-session. The `isNativeApplication` branch is gone; nothing in
the poller asks which platform it is on any more.

Removed, not deprecated: `workspace.debugConnectionType` + its action,
`session.debugTransport` (now `debugChannelOpen`, a boolean about the WebRTC
channel rather than a second medium vocabulary), the vestigial
`DebugConnectionType` in backend/shared/debug, and the debug bridge's unread
transport label. `capabilities.debugHttpFallbackPollIntervalMs` is now
`debugRelayPollIntervalMs`; its env key keeps the old spelling, being a
deployment contract in other people's .env files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/frontend/hooks/useDebugPolling.ts`:
- Around line 396-418: Update the polling setup in the useEffect and its
interval callback to share one guarded poll starter that checks isPollingRef
before invoking getVariablesList. Use this same starter for the immediate poll
after debugMedium changes and subsequent interval ticks, preserving serialized
updates to batchOffsetRef and batchSizeRef.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 20a98658-0138-48ce-8d85-d60db18caa43

📥 Commits

Reviewing files that changed from the base of the PR and between 87001fd and 58bdcda.

📒 Files selected for processing (18)
  • src/backend/editor/hardware/device-session-manager.ts
  • src/backend/shared/debug/index.ts
  • src/backend/shared/debug/types.ts
  • src/frontend/components/_organisms/workspace-activity-bar/default.tsx
  • src/frontend/hooks/__tests__/debug-medium-profile.test.ts
  • src/frontend/hooks/useDebugPolling.ts
  • src/frontend/hooks/useDebugSession.ts
  • src/frontend/store/__tests__/webrtc-slice.test.ts
  • src/frontend/store/__tests__/workspace-slice.test.ts
  • src/frontend/store/slices/device/types.ts
  • src/frontend/store/slices/webrtc/index.ts
  • src/frontend/store/slices/webrtc/slice.ts
  • src/frontend/store/slices/webrtc/types.ts
  • src/frontend/store/slices/workspace/slice.ts
  • src/frontend/store/slices/workspace/types.ts
  • src/middleware/shared/ports/device-port.ts
  • src/middleware/shared/ports/platform-capabilities.ts
  • src/middleware/shared/ports/types.ts
💤 Files with no reviewable changes (4)
  • src/frontend/store/tests/workspace-slice.test.ts
  • src/frontend/store/slices/workspace/slice.ts
  • src/frontend/store/slices/workspace/types.ts
  • src/frontend/components/_organisms/workspace-activity-bar/default.tsx
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/backend/editor/hardware/device-session-manager.ts
  • src/backend/shared/debug/types.ts
  • src/middleware/shared/ports/device-port.ts
  • src/frontend/store/slices/device/types.ts
  • src/frontend/hooks/useDebugSession.ts

Comment thread src/frontend/hooks/useDebugPolling.ts

@thiagoralves thiagoralves left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not all issues reported were valid. Fixed all valid issues and other minor issues as consequence of the fixes.

Brings the branch up to date with `development` (24 commits), which branch
protection requires before merging, and which also unblocks the paired
openplc-web#641.

The cross-repo sync gate compares each repo's MERGE ref against the other
repo's PR HEAD. So a paired set can only be green on both sides when both
branches have their base merged in: web#641 already picked up DOPE-530 (the
data-type serializer/parser) from its development, while this branch still
predated it, leaving web's job reporting 13 differences against this head.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (8)
src/main/modules/ipc/main.ts (8)

2703-2724: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Stop the simulator when session opening throws.

loadAndRun starts the emulator before deviceSession.open. The !opened.ok branch stops it, but the catch branch only returns an error. If session opening rejects, the emulator remains running without a managed session.

Close the session and stop the simulator in the exception path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/modules/ipc/main.ts` around lines 2703 - 2724, Update
handleSimulatorLoadFirmware’s catch path to close the device session and stop
the simulator when deviceSession.open or subsequent setup throws. Preserve the
existing error response from getErrorMessage(error), and ensure cleanup occurs
before returning the failure result.

1746-1760: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject invalid PLC actions at the IPC boundary.

The 'run' | 'stop' annotation is erased at runtime. Any value other than 'run' selects the STOPPED branch, so malformed IPC data can stop the PLC. Validate action before REST routing and before calculating target.

Proposed fix
 handleDebuggerPlcControl = async (_event: IpcMainInvokeEvent, action: 'run' | 'stop'): Promise<PlcControlResult> => {
+  if (action !== 'run' && action !== 'stop') {
+    return { success: false, error: 'Invalid PLC action' }
+  }

As per coding guidelines: “Validate external data at boundaries, including IPC payloads, project files, and downloaded-binary metadata, using Zod schemas or type guards instead of casts.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/modules/ipc/main.ts` around lines 1746 - 1760, Validate the runtime
value of action at the start of handleDebuggerPlcControl, before trace logging,
REST routing, or target calculation, using the project’s established Zod schema
or type-guard approach. Reject any value other than "run" or "stop" with the
existing PlcControlResult error shape, while preserving the current valid-action
behavior.

Source: Coding guidelines


2365-2379: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate candidates before opening a device link.

handleDeviceConnect trusts the renderer argument as DebugConnectionConfig[]. Type annotations do not validate IPC data. A non-array or malformed object can make toDeviceLinkCandidates throw or pass invalid transport parameters to the device factory.

Parse the payload with a Zod schema or an explicit type guard before iterating it.

As per coding guidelines: “Validate external data at boundaries, including IPC payloads, project files, and downloaded-binary metadata, using Zod schemas or type guards instead of casts.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/modules/ipc/main.ts` around lines 2365 - 2379, Validate the
renderer-supplied payload at the start of handleDeviceConnect before accessing
candidates.length, mapping it, or calling toDeviceLinkCandidates. Use the
project’s existing Zod schema or an explicit type guard to confirm the value is
an array of well-formed DebugConnectionConfig objects, and return the
established invalid-input outcome for malformed data instead of opening a device
link.

Source: Coding guidelines


2074-2087: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Bind the runtime session to the authenticated runtime.

handleOpenRuntimeSession stores params.address from IPC without checking it against runtimeIp. Later REST control calls use that stored address with the current bearer token. A malformed or compromised renderer can route the token to another host.

Reject a session address that differs from the login address, or authenticate separately for the target. Validate the complete params payload before creating the session.

As per coding guidelines: “Validate external data at boundaries, including IPC payloads, project files, and downloaded-binary metadata, using Zod schemas or type guards instead of casts.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/modules/ipc/main.ts` around lines 2074 - 2087, Update
handleOpenRuntimeSession to validate the complete IPC params payload with the
established Zod schema or type-guard approach before use, including debug
connection fields and address. Reject any address that does not match the
authenticated runtimeIp, and only call deviceSession.openRestSession and update
debuggerConnectionType after validation succeeds.

Source: Coding guidelines


2203-2210: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Reuse the status frame during liveness probing.

Line 2206 reads status, then Line 2209 calls pushPlcState with an interval of 0. That helper performs another getStatus call. This doubles FC 0x46 traffic and can queue a second request on a single-client serial or TCP link.

Pass the first status result to the push helper, or emit it directly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/modules/ipc/main.ts` around lines 2203 - 2210, Update
probeDeviceLink to reuse the status result returned by client.getStatus when
invoking pushPlcState, avoiding a second getStatus request. Extend or use the
helper’s existing status-frame input as needed while preserving the current
failure check, plcStatePushedAt reset, and successful return behavior.

2403-2408: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reset debugger state when the device session closes.

handleDebuggerGetVariablesList treats a non-null debuggerConnectionType as an active debugger. handleDeviceDisconnect closes deviceSession but does not clear this field. A disconnect during debugging can therefore keep the poll loop active and return channel errors instead of the intentional disconnected state.

   this.deviceSession.close()
+  this.debuggerConnectionType = null
   this.deviceLinkProbe = null
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/modules/ipc/main.ts` around lines 2403 - 2408, Update
handleDeviceDisconnect to clear debuggerConnectionType when closing the device
session, alongside resetting deviceLinkProbe and tracedChannelUses, so debugger
polling observes the disconnected state.

2103-2122: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Keep certificate verification enabled for runtime WebSocket sessions.

rejectUnauthorized: false accepts any certificate for the WebSocket that carries the JWT. A network attacker can impersonate the runtime and capture the token.

Use a trusted CA or explicit per-device certificate or fingerprint approval. Do not disable verification globally.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/modules/ipc/main.ts` around lines 2103 - 2122, Update
toDebugCandidate’s websocket transport creation to keep TLS certificate
verification enabled instead of setting rejectUnauthorized to false. Use the
existing trusted CA or explicit per-device certificate/fingerprint approval
mechanism, without disabling verification globally, while preserving the current
host, port, and token handling.

709-731: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Validate runtime responses before using the declared shapes.

JSON.parse(data) as ... and the later raw as ... checks only silence TypeScript. They do not validate status, task fields, plugin_stats, or switchPosition. Malformed /api/status data can reach the renderer as a valid result. The same pattern in restStartPlc can turn a non-string status into an exception path before .trim().

Parse into unknown, then validate with a Zod schema or an explicit type guard.

As per coding guidelines: “Validate external data at boundaries, including IPC payloads, project files, and downloaded-binary metadata, using Zod schemas or type guards instead of casts.” Also, “Do not use type assertions, except as const.”

Also applies to: 1796-1802

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/modules/ipc/main.ts` around lines 709 - 731, Replace the asserted
JSON parsing in the /api/status response callback with parsing into unknown
followed by a Zod schema or explicit type guard that validates status,
timing_stats task fields and plugin_stats, and switchPosition before
constructing the renderer result. Apply the same boundary validation in
restStartPlc so status is confirmed to be a string before trim() is called.
Remove the raw as-casts and only pass validated data onward.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/main/modules/ipc/main.ts`:
- Around line 2703-2724: Update handleSimulatorLoadFirmware’s catch path to
close the device session and stop the simulator when deviceSession.open or
subsequent setup throws. Preserve the existing error response from
getErrorMessage(error), and ensure cleanup occurs before returning the failure
result.
- Around line 1746-1760: Validate the runtime value of action at the start of
handleDebuggerPlcControl, before trace logging, REST routing, or target
calculation, using the project’s established Zod schema or type-guard approach.
Reject any value other than "run" or "stop" with the existing PlcControlResult
error shape, while preserving the current valid-action behavior.
- Around line 2365-2379: Validate the renderer-supplied payload at the start of
handleDeviceConnect before accessing candidates.length, mapping it, or calling
toDeviceLinkCandidates. Use the project’s existing Zod schema or an explicit
type guard to confirm the value is an array of well-formed DebugConnectionConfig
objects, and return the established invalid-input outcome for malformed data
instead of opening a device link.
- Around line 2074-2087: Update handleOpenRuntimeSession to validate the
complete IPC params payload with the established Zod schema or type-guard
approach before use, including debug connection fields and address. Reject any
address that does not match the authenticated runtimeIp, and only call
deviceSession.openRestSession and update debuggerConnectionType after validation
succeeds.
- Around line 2203-2210: Update probeDeviceLink to reuse the status result
returned by client.getStatus when invoking pushPlcState, avoiding a second
getStatus request. Extend or use the helper’s existing status-frame input as
needed while preserving the current failure check, plcStatePushedAt reset, and
successful return behavior.
- Around line 2403-2408: Update handleDeviceDisconnect to clear
debuggerConnectionType when closing the device session, alongside resetting
deviceLinkProbe and tracedChannelUses, so debugger polling observes the
disconnected state.
- Around line 2103-2122: Update toDebugCandidate’s websocket transport creation
to keep TLS certificate verification enabled instead of setting
rejectUnauthorized to false. Use the existing trusted CA or explicit per-device
certificate/fingerprint approval mechanism, without disabling verification
globally, while preserving the current host, port, and token handling.
- Around line 709-731: Replace the asserted JSON parsing in the /api/status
response callback with parsing into unknown followed by a Zod schema or explicit
type guard that validates status, timing_stats task fields and plugin_stats, and
switchPosition before constructing the renderer result. Apply the same boundary
validation in restStartPlc so status is confirmed to be a string before trim()
is called. Remove the raw as-casts and only pass validated data onward.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 83ac08e2-2588-4eb2-8c2f-bd1572e679b9

📥 Commits

Reviewing files that changed from the base of the PR and between 58bdcda and 7fd4fd4.

📒 Files selected for processing (1)
  • src/main/modules/ipc/main.ts

@thiagoralves
thiagoralves merged commit 05dc8d7 into development Aug 6, 2026
14 checks passed
@thiagoralves
thiagoralves deleted the feat/baremetal-connection branch August 6, 2026 15:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants