PlcHelper

Connect to, monitor and simulate PLCs. Live variable watch, Structured Text engine and program runner, on Windows, Linux, Raspberry Pi, Android and in the browser.

Current version: 3.2.0 — released

Try it online →

Downloads

Supported protocols

Which industrial protocol runs over which transport, and where each pairing ships. Generated from the hosts' own module registries at build time, so it always describes this release.

Protocol TCP client TCP server UDP Serial WebSocket WebSocket server Web Serial Raw Ethernet SSH No transport
ABx DesktopServerAndroid DesktopServer DesktopServer
ADS DesktopServerAndroid
Barcode DesktopServerAndroid DesktopServer DesktopServer
Capture DesktopServerAndroid DesktopServer DesktopServer DesktopServer DesktopServer DesktopServer Desktop DesktopAndroid
CLX DesktopServerAndroid DesktopServer
ECC self-managed DesktopServerAndroid
Firmata DesktopServerAndroid DesktopServer Browser
History self-managed DesktopServer
IoSerial DesktopServerAndroid DesktopServer DesktopServer DesktopServerBrowser DesktopServer Browser
Modbus DesktopServerAndroid DesktopServer DesktopServer DesktopServer Browser
MQTT DesktopServerAndroid DesktopServer DesktopServerBrowser DesktopServer
OPC DA self-managed DesktopServer
OPC UA DesktopServerAndroid DesktopServerBrowser
Siemens S7 DesktopServerAndroid DesktopServer Desktop
Siemens S7 Plus DesktopServerAndroid
Virtual self-managed DesktopServerAndroidBrowser

Every architecture of a platform carries the same set — x64 and ARM64 desktop builds are identical here. Raw Ethernet is compiled into the desktop build but only appears once Npcap (Windows) or libpcap (Linux) is installed. The server build deliberately leaves out SSH and Raw Ethernet so the Raspberry Pi cross-build stays free of native C libraries.

Update log

3.2.0latest

Changed

  • SSH now works on Android. The SSH transport was previously desktop-only: it was built on libssh2, which drags in a native OpenSSL and cannot be cross-compiled for Android. It now uses russh, which is pure Rust, so the transport is registered on mobile as well as on the desktop.
  • No native crypto libraries are linked any more. S7CommPlus TLS and the SSH transport now share one backend (ring) through a new internal TLS layer, replacing an alpha-stage TLS provider that carried an explicit "not for production" warning. openssl-sys, libssh2-sys and ssh2 are gone from the dependency tree entirely. Nothing about how connections behave changes; what changes is what has to be trusted and what has to be built.

    A side effect worth stating plainly: the Android package grew from about 45 MB to about 53 MB, because the new crypto backend is compiled for all four Android architectures.

  • SSH host keys are now recorded. The previous implementation verified no host key at all. Connections still succeed the same way by default, but the key's SHA-256 fingerprint is written to the log, and the machinery for pinning it is in place.

Added

  • Raspberry Pi builds are produced again. Both the 64-bit (aarch64) and 32-bit (armv7) server bundles are built and published. The task existed but had never completed: one node_modules cannot serve both Windows and Linux, so the frontend build is now done on the Windows side and handed to WSL.

Fixed

  • The ARM Windows web-server bundle was never refreshed after the first build. Its packaging step only ran when the output directory did not yet exist, so every later release shipped whatever binary and web assets the bundle happened to be created with. The x64 bundle was unaffected.

Added

  • The Program tab has an AI assistant. A seventh tab in the side panel, beside Structure and Problems. It reads the whole ST project, writes and refactors units, compiles them, and fixes what it broke — the loop a coding assistant runs, scoped to Structured Text and to the connection the project belongs to.

    Its edits go to a working copy, not to your project. That is the point: it can patch a unit, build, read the errors and patch again without asking permission between each step, which is what a design where every edit waits for a click cannot do. You see the units it changed with +/ counts and a diff, and nothing reaches the connection until you press Keep. Discard restores the last saved version; a single unit can be put back on its own.

    Three modes: Chat reads and explains but cannot change anything, Plan works out an approach and posts a checklist, Agent does the work. It can also drive the simulation — build, start, stop, pause, step, set breakpoints — and read the connection's live variables while it debugs.

    When you ask it to wire a function block to the machine it can now see what is actually joinable — which instances exist, what fields each one exposes, and which connection variables are available — so it wires them in one go instead of guessing. If you point it at something that cannot be bound, such as a plain ST global, it says which alternatives exist and why, rather than reporting a bare failure and abandoning the wiring.

    Writing to the connected machine is a separate thing you turn on. It is off in every new session, has to be re-armed after each Discard, and shows a red badge the whole time it is on. The assistant can still see the tool while it is off, so instead of quietly giving up it tells you which variable it wants to write and why, and asks.

  • The ESP build has the assistant too, under the pin table: four tools over the GPIO — list them, read one, check the connection, and drive an output. Driving an output is armed per session, the same way, for the same reason.
  • Answers can use tables. A mapping — XML tag to PLC connection variable, say — now renders as a real table instead of a run-on line of pipes, and scrolls inside its own box so a wide one never pushes the conversation sideways.
  • Any OpenAI-compatible provider. Presets for MiniMax, OpenAI, OpenRouter, DeepSeek, Ollama and LM Studio; anything else is a base URL and a model. Test connection fills the model picker from the provider itself rather than from a list that goes stale. Reasoning models are handled whichever way they emit their thinking, and it is shown as a collapsed Thought rather than mixed into the answer.

    The request goes straight from the app to the provider, so this costs nothing on any platform — no new Rust, no new dependencies, no change to the binary. Your API key is stored in this browser's local storage, unencrypted, and is sent only to the provider you configured; the settings card says so, and it is stripped from anything the app ever displays.

Changed

  • The signal generator is a timeline you drag, not a form you fill in. It was a read-only preview above a row of up to eight numeric boxes per segment: to move a waveform you typed into "Start", to lengthen it you typed into "Length", to change its size you typed into "Amp".

    Now everything with a position or a size is something you grab — the block body moves it in time, its edges set how long it plays, ◆ sets how big it is, the centre line sets what it sits around, ◇ sets how fast it repeats, and a pulse's marker sets its duty. Wheel to zoom, drag to pan, double-click a lane for a new block, Delete to remove one, Ctrl+Z to undo, F to fit. Snapping goes to the grid and to neighbouring edges, held off with Alt. The exact numbers are still there, in an inspector that shows only the knobs the chosen wave actually uses — a constant has no phase, a sine has no duty.

    Every drag shows what it is producing next to the pointer — "lasts 12.5s", "amplitude 40.0", "every 5.0s (0.2 Hz)" — so you are not matching a datasheet by eye or looking away to the inspector. Arrow keys nudge the selection (Shift resizes, Ctrl for a tenth of a step) when a drag is too coarse.

    All the channels now share one time axis, so you can finally see how two waveforms line up. Choosing a wave is a gallery of shapes rather than a dropdown of ten words, and a custom curve is drawn by dragging its points instead of typed as "0:0, 0.5:1, 1:0".

  • The generator's preview no longer disagrees with the machine. Two things made it lie:

    Its Y axis auto-scaled to the samples, so the curve always filled its box — doubling the amplitude changed nothing on screen, which is precisely the feedback you need while setting one. Each lane now holds a scale that only steps out when the signal would leave it.

    And it re-implemented the waveform math in TypeScript to draw with. Two implementations of one formula drift. The editor now samples through the same Rust engine that plays the program, compiled to WASM.

    Gaps are visible for the first time too: where no segment covers an instant the host writes nothing and the variable silently holds its last value, so the curve stops rather than joining across. Overlaps are visible as well — where a later segment covers an earlier one the hidden part is hatched, so overlapping two blocks by accident no longer silently wins.

  • A channel could be stranded. The only way to select one was through one of its segments, so deleting the last segment left it unreachable — no way to change its variable, re-enable it, or remove it. Clicking a lane's name now selects the channel itself, and channels can be reordered and duplicated.
  • A bool channel's threshold is draggable. It was drawn across the lane as a dashed line and could only be changed by typing a number, which is the one thing this whole editor exists not to do.
  • Signal edits reach the running session. The host snapshotted the program when the session started, so tuning a waveform did nothing until the next reconnect — which makes a draggable editor pointless. The program is now shared with the poll loop and swapped in place, and the toolbar says whether it is driving the machine or only saved. A playhead shows where the program is, with each channel's live value beside it.
  • One ST program, one debugger, one panel. The Code section had three scripting tabs and, underneath them, three separate ST virtual machines: the Editor debugged a buffer against a frozen snapshot of I/O, Scripts ran snippets on a timer, and Program ran the real project in the host. So the thing you could debug was not running, the thing that ran could not be debugged, and neither of them was the project.

    There is now one: Program. Scripts and Editor are gone.

    The VM moved into the browser, which is what lets the debugger inspect the program that is actually driving the machine rather than a private copy compiled from the same text. Full DAP against the running scan: breakpoints in the gutter, pause, continue, step in/over/out, the call stack, locals of the selected frame, watch expressions, and setting any of them.

    Hitting a breakpoint stops the program, not the connection. The scan freezes exactly where it is, I/O keeps being exchanged, and the outputs hold their last value — which is what a PLC stopped on a breakpoint does. Stepping off the end of the scan still writes its outputs, so a program you stepped through drives the machine like any other.

    The layout is an editor and one side panel — Structure, Variables, Problems, Breakpoints, Watch — instead of three fixed columns, so it fits a laptop; below 900px the panel becomes a drawer. F5 continue/pause, F9 breakpoint, F10 step over, F11 / Shift+F11 step into/out.

    Saved scripts are imported, not deleted. Each becomes a program unit named after the script, with a note at the top saying where it came from. The stored scripts are left untouched. One thing genuinely went: a script could run on its own interval, and the project has one entry program called once per scan — so the poll period is now the period, as it is on a PLC.

    An edit also takes effect immediately now. The old runner built its models when the session started, so a change needed a reconnect.

  • The program can be stopped and started. A Stop button in the debug bar halts scanning; Start resumes it. This is a PLC's STOP, not a reset: the machine keeps its state and its scan count, so starting again continues rather than reboots, and the outputs hold whatever the last completed scan left them at. Stopping while paused at a breakpoint abandons that scan rather than leaving it suspended, so the next start begins at the entry point.
  • Locals are their own tab, and it appears when the program stops. A POU's locals live on a call frame, and there is no call frame between scans — so unlike the declared globals in Variables, they exist only while stopped. The tab carries the call stack and the variables of whichever frame is selected, and disappears again when the program resumes. Globals are not repeated there; they are the Variables tree, live, in the shape the source declared.
  • The side panel is resizable. Drag its left edge; the width is remembered between sessions.
  • The Program UI does the things it looked like it did. Renaming a unit rewrites its PROGRAM / FUNCTION_BLOCK header, instead of relabelling the tree and leaving the source disagreeing with it — and the entry point follows the rename. The entry program is pickable: it decides what actually runs and previously had no UI at all, so a project whose only program was not the entry ran nothing and said nothing about it. Units can be reordered and duplicated. Instance names are checked against what the source declares, binding fields are offered from the built program, and errors appear as squiggles on the line that caused them rather than only in a list.
  • One place starts a session. Every route into the Live view (the list's play button, Resume, and both buttons on the connection page) now just navigates, and the Live view starts the session. Previously the client Connect button also started one itself, which made client and server behave differently and could start the same session twice. A start that fails outright now reports itself in the Live view, with a Retry button.

Added

  • An ST program runner: units, instances, and a live variable tree. The Live view's Code → Program tab is a small PLC IDE for the simulation — project structure, editor, a clickable Problems list, and the running program's variables.

    A program is now a project of named units (programs, function blocks, functions, types, global variables) instead of one concatenated blob. Units are linked in dependency order, so it no longer matters which one you write first, and every diagnostic names the unit that owns it rather than a line of a text that exists nowhere.

    Function-block instances are declared in ST — VAR_GLOBAL Motor1 : Motor; — and wired to connection variables per instance under Instances, so two instances of one type drive different points. They are called the ordinary way:

    `` Motor1( Enable := StartButton, Speed := 100 ); ``

    The variable tree shows the whole program state in the shape the source declared it: instances nest, structs and arrays expand, timers show their own fields, each node says which connection variable it is bound to and which direction, and any value can be forced by double-clicking it. It is built only while the panel is open, so it costs nothing at a 20 ms poll.

    GET_x / SET are allowed anywhere, while I/O stays declared at top level. A unit that uses them is badged not PLC-portable in the tree, so losing TwinCAT portability is a visible choice rather than a later discovery. Naming a variable that does not exist is now a build error — GET_INT('typo') returns a default rather than failing, which is a silent wrong answer.

    The runner also runs. Previously stModel was carried through the config and only ever executed by the CLI.

Fixed

  • The desktop app started to a blank window when the repo is reached through a drive mount. This checkout lives on a volume mounted twice — as E: and as the C:\Dev mount point, with C:\Git a junction onto that. Vite resolved every module to its canonical location, rewriting C:\...\src\main.tsx into E:/.../src/main.tsx; the file still existed and was still served, so the failure was quiet — but the id no longer matched the module graph, the transform never ran, and the browser was handed raw TSX. The window came up empty with SyntaxError: Unexpected token '<' on the JSX, after a Pre-transform error: Failed to load url /src/main.tsx (resolved id: E:/...) buried in the dev-server output. Vite now keeps paths as written (resolve.preserveSymlinks), so everything stays on the drive letter the dev server was launched from.
  • The Program tab was effectively read-only. Adding a program, function block, function, type or globals unit did nothing visible; nor did renaming one, editing its source, or changing an instance binding. The connection is loaded once when the view opens, and saving an edit never updated it — so every change was written to disk and then ignored, by the tree, the editor and the runner alike, until the page was reloaded.
  • Typing a new unit name typed into the source code instead. The rename box appeared without focus (autoFocus does not act on an element added to an already-rendered page, and the editor takes focus straight back), so the keystrokes went to the editor, the rename silently did nothing, and the program picked up whatever had been typed into it.
  • Breakpoints could not be set with the mouse. The clickable strip was the glyph margin alone — about eight pixels at this font size. The whole gutter works now: click a line number, or press F9 on the line.
  • Deleting a unit opened a native confirmation dialog, which blocks the entire webview. The × now arms on the first click and deletes on the second.
  • A project that failed to compile reported itself as "Faulted", which is what a program that was running and stopped says. It never started; it now says so, and points at the Problems list.
  • Pause did nothing. Asking a running program to stop is a request to break at the start of the next scan — and the next scan starting is exactly what cleared it, because dropping the in-flight step also dropped the pending pause.
  • Only the innermost stack frame knew where it was. Every other frame in the call stack reported line 0, so clicking one had nowhere to go. A frame is stopped at its call site, which the call instruction does not always carry a line for — a call with named arguments attributes the argument stores instead.
  • **The debugger listed function-block type variables as globals.** The variable tree hides them because they are unreachable slots that look exactly like globals the program owns; the debugger showed them, so the two views of one program disagreed about what existed.
  • Belt1.Count could not be watched. An instance field is its own global named with the instance path, not a member of a struct, so watching one by the name shown everywhere else in the app answered "not found".
  • Writing into a nested member of a function-block instance did nothing. inst.Cfg.Field := v compiled clean, ran clean and wrote nowhere, with no diagnostic at any stage (Docs/ST_PORTABLE_SUBSET.md §3.1 — the belt that resulted reported both sensors permanently made with an item that never moved). Two causes: function blocks were never registered as structured types, so everything below inst.Cfg was untyped; and code generation resolved the instance name as an ordinary variable, which it is not — each instance field is its own slot.
  • Assigning a whole STRUCT or ARRAY stored nothing. b := a between two composites discarded the value everywhere, not only inside instances.
  • A function-block instance declared in VAR_GLOBAL was not an instance. It got one opaque slot instead of its own storage and its own copy of the body, so every global instance of a type shared one set of variables, Belt1.MCON resolved to nothing, and calling it did not reach the specialised code. This is the singleton bug fixed for POU-local instances in 3.1.0, which survived one scope up. Built-in blocks (VAR_GLOBAL T1 : TON;) had it too.
  • **Function-block type variables appeared as globals.** Lowering a type allocates bare-named slots beside each instance's own; they showed in the variable list under names like Count that looked like globals the program owned.
  • Starting a server connection did nothing until you pressed Disconnect first. Only one session runs at a time, and it keeps running when you leave the Live view (that is what the Resume button is for). The Live view decided whether to start a session by asking "is a session running?" — so whenever one was, opening a different connection quietly started nothing and showed you the other connection's status. Server connections had no other way in (their button only navigates, unlike a client's Connect, which called connect itself), so they appeared dead until a Disconnect + Connect round trip cleared the state.

    The connection status now carries the id of the connection it belongs to, so the question becomes "is a session running for this connection" and gets the right answer. The same guard also affected clients started from the connection list's play button.

  • Starting a connection right after editing it used the old settings. The Live view started the session from the app shell's copy of the connection, which is only refreshed on navigation — so changing a server's port and pressing Go Live bound the previous port. It now starts from the stored definition, which the button has just saved.

    Relatedly, pressing Connect / Go Live on the connection page now restarts a session already running for that connection, so settings changed a moment earlier take effect. The connection list's play button and the Resume button are unchanged — they resume the running session without interrupting it.

3.1.0

Added

  • Import straight from the New Connection dialog. The dialog now opens on three ways to start — Blank connection, Import from device or export, and Open existing — so a connection can be created directly from a QCX/ECC server or an exported project without first creating an empty one and importing into it. Import lists every source grouped by QCX/ECC/ADS and shows the PLC type each one produces, so you no longer have to know that "import from the QCX server" means picking Modbus first: the type is derived from the source you choose. A live import also configures the transport as part of creating the connection (the QCX Modicon source points it at the PLC's own Modbus TCP address), which is what commType being fixed at creation always implied.

    The connection name is now asked last, on a shared summary step showing the PLC type, transport and variable count before anything is created. This fixes an ordering problem the dialog always had: the name was demanded first, yet for two of the three paths a good one only exists at the end — a loaded file carries its own, and an import knows the machine designation only after the machine is picked. Names are pre-filled accordingly (machine designation, the file's name, or NewMachine) and remain editable. Loading a file or URL now previews it rather than importing immediately, so backing out of the summary leaves nothing behind, and the URL flow uses a proper input field instead of a browser prompt.

  • Built-in QCX Common block-algorithm library. Importing a .FlsSln + .FlsStd pair no longer silently drops points that use the shared QCX Common devices — PneuCyl, Motor and OnOff. Those point types live in the QCX library database and are never part of an export, so the importer used to find no definition and skip the point without a word; on a real Centaurus HW005 export that was 66 of its 86 points importing as nothing. All six Common device algorithms (v8.0 and v8.1, 566 parameters) plus their parameter descriptions and unit texts are now compiled into the application, and are used automatically whenever the imported files don't define a referenced point type. Definitions that are present in the files always take precedence, so existing imports are unchanged. (Version note: there is no 9.0 Common algorithm — packages versioned 9.0/9.1 reference Common 8.1 directly. The Common blocks are addressed for Modicon only, so this affects Modbus imports; Siemens imports are unaffected.) The library is regenerated from the QCX source tree with task gen-blockalgo-lib; ordinary builds don't need it.
  • Custom data on variables. Every variable now carries a third category of data alongside its common properties and I/O-specific parameters: a free-form set of key/value pairs. The variable editor gains a Custom tab where you can add, edit, and delete your own entries, and the live-view hover tooltip lists them after the I/O parameters. The QCX/ECC loaders populate it automatically — Gt (GroupingText), Sort (SortOrderNo), Unit, and Decimals — from the live QCX server, the Modicon→Modbus import, and the offline XML Modbus/Siemens loaders, matching the older application. (These loader values previously lived in the I/O parameter map or the comment field.) The ECC loaders resolve Unit to its human text (e.g. ms, %, smp/h) and each variable's Description (from the parameter name) via the QCX language register service, exactly as the old app's GetLang.
  • Online Siemens (live QCX) importer. The QCX server's Siemens (Sim) driver config can now be imported the same way Online Modicon (live QCX) imports the Modbus one: pick the QCX server, pick a Siemens PLC, pick points, and get a native Siemens S7 connection pointed at the PLC itself. The PLC's own IP, CPU family (S7-300 vs S7-400) and rack/slot come from the QCX config, so the created connection is ready to poll rather than needing those filled in by hand.

    Each point contributes one variable per set address slot — POINT_INP, POINT_OUT1, POINT_OUT2 — and a point wired to a QCX block algorithm expands its block slot into one POINT.KEY variable per block parameter, with the same unit / grouping / decimals / description enrichment the Modicon importer does. S7 is byte-addressed and so is the QCX config, so a block parameter's offset is simply added to the slot's byte address.

    A previous investigation (Docs/QCX_SIEMENS_H1_DIAGNOSTIC.md) had recorded that QCX exposes "no Siemens address service" and no IP/rack/slot for Siemens PLCs. That was wrong — the services were there all along with no client written for them; the report now carries a correction.

  • plchelper-cli ecc siemens-plcs / siemens-addresses / siemens-import. The terminal equivalents of the new importer, alongside the existing modbus-* commands. ecc point-config gained --io SIM so a Siemens point's block algorithm can be inspected too, and a new ecc raw --path … --ns … --interface … --op … calls any no-argument WCF operation and dumps the response, which is how the Siemens contracts above were reverse-engineered.

Changed

  • QCX server PLC list is sorted by name. The loader wizard's machine picker now lists the PLCs alphabetically for both QCX/ECC online loaders. The server returns them in its own order (PLCNo), which put e.g. 882MPR02_PLC ahead of 882MPR01_PLC; the point list below it was already sorted.
  • Import wizard: fixed cursors and a dead-end step. The Load / From URL tiles showed a "blocked" cursor even when they worked (a hardcoded not-allowed); they now follow the normal button convention. The wizard's machine step gained a Back button — previously the only ways out of it were Cancel or closing the dialog.
  • Raw-Ethernet backends merged into one crate. comm-rawether (the PCAUSA Rawether NDIS fallback) is now a driver module inside comm-ether, behind a shared driver layer used by both the comm transport and the packet-capture recorder. The driver is auto-probed at runtime (Npcap/libpcap preferred, PCAUSA fallback, hidden when neither is installed) — behaviour is unchanged, but a connection's network_device saved under one driver now opens under the other (the adapter GUID is matched across the \Device\NPF_{GUID} / {GUID} spellings), outgoing runt frames are padded to the 60-byte 802.3 minimum on the stream path for both drivers, and the PCAUSA datagram path now filters received frames (by EtherType or local MAC) exactly like the Npcap path instead of delivering every frame on the wire.
  • QCX imports are ordered by address, then bit. Every QCX variable import now presents its list sorted by address then bit — the live Modicon→Modbus loader (verified against a live server) and the offline XML Modbus/Siemens loaders (Siemens sorts by data block, then address, then bit). Previously the live import followed the server's parameter order and the offline Modbus list followed point order.
  • Variable editor is now mobile-first. On phones (≤640px) the wide inline table — which forced horizontal scrolling and had tiny tap targets — is replaced by a compact tap-to-edit list, and the detail panel opens as a full-screen editor overlay (with larger touch targets for close/delete/nav). The toolbar reflows into a full-width button grid. Tablet (≤880px) still stacks the table above the panel, and the desktop table + side panel are unchanged.
  • Variable editor toolbar reworked. The Copy and Paste actions are now text buttons instead of icons, and a Delete all button (destructive, confirms first, disabled when the list is empty) was added to clear every variable at once.

Fixed

  • **QCX XML Siemens imports produced variables that could never be polled.** The offline loader wrote the old app's hyphenated parameter names (Siemens-Db, Siemens-Address, …), but io-siemens reads SiemensDb, SiemensAddress, SiemensBit and SiemensDataType, so every imported variable was rejected as missing its address. Existing connections imported this way need re-importing.

Added

  • Import wizard remembers the last QCX server. The host entered on a live QCX/ECC import is saved app-wide (in the runtime store under a sentinel id, so it is shared across every Modbus and Siemens connection) and pre-fills the server prompt on the next live import, falling back to the connection's hostname when nothing has been saved yet.
  • Import wizard can select multiple files for QCX XML. The XML Modbus and XML Siemens loaders now advertise a supports_multi_file flag; the wizard shows a multi-select file picker for them and merges every chosen file into one import (documents are concatenated by root element, so PLCs/points from all files are combined). A new set_files / set_bytes_multi seam on the VarLoader trait carries this through the Tauri (loader_set_files + pick_import_files), server (loader_set_files), and wasm (loader_set_bytes_multi) hosts; single-file loaders (ADS TPY) keep the single-file picker unchanged.

Fixed

  • Modal dialogs no longer close when a text selection ends on the backdrop. Selecting text inside a dialog and releasing the mouse outside it closed the dialog, because the browser dispatches the click to the common ancestor of the press and release (the overlay), defeating both the target === currentTarget guard and the inner stopPropagation. A shared useBackdropClose hook now tracks where the press began and closes only when the press and release both land on the backdrop. Applied to the import wizard, New Connection, and OPC DA/UA browse dialogs.
  • Online Modicon (live QCX) import now retargets the connection to the PLC. The import talks to the QCX server over WCF, but the imported variables are native Modbus, so the loader now switches the connection to a Modbus TCP client pointed at the PLC's IP on port 502 after import. Previously the PLC hostname wasn't applied at all — the loader emitted commNetHostname (lowercase) but the wizard only read CommNetHostname; the key is fixed and the wizard's onImport now also applies commType and port from loader metadata (network loaders can retarget the connection; the ECC online loader still just sets the hostname).
  • Online Modicon (live QCX) import now expands block-algorithm points. The loader emitted a single Modbus variable for every selected point, even when a point is wired to a block algorithm (which carries many parameters). It now classifies each point via its static config: a direct point still maps to one value, while a block-algorithm point expands into one holding-register variable per block parameter — addressed at the point's block base + each parameter's read offset/bit, typed from the parameter's value type/size (mirrors the offline QCX XML combiner). Falls back to the single value when a point has no resolvable block base. Validated live against a QCX server: point 882MPR02_RW1 now imports its 359 block parameters (each at block base + read offset, bit, and size-derived type) instead of a single value. The classification lives in io_ecc::modicon_import, shared by the wizard loader and the CLI, so plchelper-cli ecc modbus-import expands block points too (and gained a --point filter plus an ecc point-config diagnostic). Two live-server details were needed: block-algo parameters are fetched with the legacy "null array = all algorithms" request (a specific-id array comes back empty) for the Modicon I/O system — so IBlockAlgo. GetBlockAlgoParametersAndText now sends an xsi:nil array on an empty slice — and, since that response omits the value type, the Modbus type is derived from each parameter's bit size. The direct path and the CLI ecc dump also now emit the byte-order-free C# type names (ushort/uint/bool) instead of explicit big-endian ids, fixing the same Modbus double-swap corrected for the XML loader.
  • QCX XML imports now emit C# type names, fixing Modbus endianness. The QCX Modbus/Siemens loaders hardcoded explicit big-endian decoder ids (UInt16 BigEndian, Single BigEndian, …). For Siemens that was equivalent, but for Modbus it double-swapped: the Modbus poller already swaps register words into little-endian flat space, so a UInt16 BigEndian variable decoded wrong. The combiners now emit the byte-order-free C# primitive names the old app used (ushort, uint, float, short, int, bool, byte, long, ulong, double, string), which the poll path resolves to the correct decoder per the protocol's default endian (decoder::resolve_id_endian) — little-endian for Modbus, big-endian for Siemens. type_name_for now mirrors CombinerUtil.ApplyVariable exactly (adds the missing 8/64-bit cases). The variable-type dropdown now appends any legacy/alias value it's holding so an imported ushort shows its real type instead of snapping to the first option.
  • FlsStd/FlsSln imports no longer leak {%…%} template placeholders. FLS QCX .FlsStd/.FlsSln config exports (ZIP archives) embed template placeholders in point/machine text; the old app substituted them with dummy strings ({%Alarm department%}000, {%Equipment sequence no%}01, {%Friendly name%}Machine) when reading ZIP entries. The Rust port had dropped this, so those placeholders surfaced verbatim in imported names. Ported the substitution in xml_query, applied to ZIP entries only (bare .xml inputs stay untouched, matching the old AddXmlStream/AddZipStream split).
  • Import variables dialog is now readable. LoaderWizard.scss referenced a set of --color-* theme tokens that don't exist in this app (it uses --panel / --border / --fg / …). The critical one, --color-surface, resolved to nothing, so the dialog surface was transparent and the dark backdrop showed through. Remapped all tokens to the real theme variables.
  • ECC (FLSmidth QCX) is now available on Android. The I/O system was only registered in registry_desktop; since it manages its own TCP transport (no native serial, no server bind) it works on mobile too, so it is now added to registry_mobile as well. The ECC variable loader — a network loader (WCF over TCP, no filesystem) — is likewise un-gated so bulk variable import works on mobile; the file-based loaders (QCX XML, ADS TPY) stay desktop-only.
  • Import wizard now prompts for the server host on network loaders. A new needs_host loader flag adds a host (computer name) entry step to the loader wizard, pre-filled from the connection and editable, before it lists machines. The QCX/ECC online loaders set it, restoring the old app's "QCX Server" field — previously the wizard silently used the connection's default 127.0.0.1 and failed with connection refused. The chosen host is also written back to the connection so the ECC runtime polls the same server.

Changed

  • Raw Ethernet: Npcap default-mode install works out of the box. A CRT-init constructor (comm-ether/build.rs + crt_init/npcap_dll_dir.c) calls SetDllDirectoryW("%SystemRoot%\System32\Npcap") at process start, so the delay-loaded wpcap.dll resolves even when Npcap is installed without the "WinPcap API-compatible Mode" checkbox. The Capture button (Wireshark-lite) now appears in the main toolbar as soon as Npcap is installed.

Added

  • PCAUSA Rawether fallback for Raw Ethernet. New comm-rawether crate adds a second raw-Ethernet transport that talks to the third-party PCAUSA NDIS protocol driver (pcasp60.sys + W64N55.dll, shipped in Resource/ImportRawEtherAssemblies/ for legacy installations). The desktop registry tries comm-ether first and falls through to comm-rawether when Npcap/libpcap is absent. The Capture (.pcap recording) button now works through whichever backend is present — Npcap prefers per-packet hardware timestamps; the PCAUSA backend stamps each frame with host wall-clock at read time and writes the standard libpcap file/packet header around the raw bytes.
3.0.0

Changed

  • First release of the PlcHelper 3.x generation: a full rewrite of the app on a new stack (Rust core, Tauri desktop/mobile host, Preact frontend), replacing the previous generations — PlcHelper 1.x (WPF, C#, .NET Framework) and PlcHelper 2.x (Blazor, C#, .NET Core).
0.2.0

Added

  • Release management: single-source version bump, in-app version display (the header now shows the running version), a static download website with an update log, and one-command FTP publish.
0.1.0

Added

  • Initial PlcHelper release: ST language engine, live view, code/log/calculated tabs, scripts, embedded MCP server, and a broad set of I/O drivers (Modbus, OPC UA/DA, Siemens, Allen-Bradley, Firmata, MQTT, and more).
  • Windows desktop builds (portable + NSIS/MSI installers, x64 & ARM64), Windows/Linux web server, static WASM bundle, Android, and ESP32 firmware.