Windows Desktop Portable
- x64 (.exe) 10.3 MB
- ARM64 (.exe) 9.3 MB
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
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.
russh, which is pure Rust, so the transport is registered on mobile as well as on the desktop.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.
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.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 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.
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".
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.
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.
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.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.
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.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.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".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.b := a between two composites discarded the value everywhere, not only inside instances.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.Count that looked like globals the program owned.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.
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.
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.
.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.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.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.882MPR02_PLC ahead of 882MPR01_PLC; the point list below it was already sorted.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.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.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.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.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.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).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.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.{%…%} 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).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.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.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.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.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.