The PLC program from the old repo's 04-plc/, flattened into one folder and
checked against the running system.
Verified during the move:
- build.py regenerates register-map.csv byte-identically (69 points)
- polled the live PLC: the SIMULATION build is what is deployed and
running, %MW21=2 wet weather, values moving, run hours accumulating
- addresses, %MW HR1024 segmentation and %QW17/%QW7 signedness all
match the map
Corrections against the old repo:
- 10_globals.st header cited WRPS-CTL-002 (the FDS); it means CTL-003
- build.py wrote the map to its parent directory; now beside itself
- deploy/README.md was a single-file folder; now DEPLOY.md
- dropped the empty editor-devices/remote/
- README no longer claims the simulation build is uncompiled - it is
the one running
Two open items are now stated plainly rather than buried:
- none of the 20 acceptance tests in CTL-003 have ever been run
- the OpenPLC Editor lived only on the retired dev-ubuntu host, so
there is currently NO route to deploy a new program (DEPLOY.md 0)
Documents the setpoint distinction: IO_MUX seeds %MW defaults once at
first scan, operators retune them live, and that tuning exists only in
the container volume - a restart reverts it.
9.9 KiB
Deploying a program to the OpenPLC runtime
Headless upload is not available: Runtime v4 accepts only STruC++ codegen output, and that compiler ships solely inside the OpenPLC Editor's GUI bundle. The repo prepares the project; a human drives the Editor.
The chain — repo src/ → generated project → STruC++ → runtime compile → running
PLC → Modbus TCP read — was proven end to end on 2026-08-14, on the
now-retired dev-ubuntu host.
0. ⚠️ Read this first — the toolchain is not currently available
Warning
You cannot deploy a new program today. The OpenPLC Editor v4 AppImage lived only on
dev-ubuntu, which has been retired. It is not installed onyau-sls-poc-lin001, and there is nowrpsEditor project there — the home directory holds onlyopenplc-compose.ymland the migration tarballs.The PLC that is running is a committed Docker image (
docker commit) with an already-compiled program baked into its writable layer. It runs fine and will keep running. But editingsrc/and regenerating the project produces something with nowhere to go.
To restore the ability to deploy, someone must:
- Install OpenPLC Editor v4.2.11 somewhere with a graphical desktop that can
reach
10.0.0.17:8443. Options: a desktop VM on the10.0.0.0/24LAN, or a workstation on the WireGuard VPN. - Recreate the Editor project — §A below is the complete list of what is not generated and must be configured by hand.
- Connect it to the runtime at
https://10.0.0.17:8443(notlocalhost— the Editor is no longer on the same host as the runtime). - Prove the chain with a trivial change before relying on it.
Until that is done, treat the PLC program as frozen. Everything below §A is the runbook for once it is restored, and is accurate apart from the host and address changes noted.
A. One-time setup (redo on a fresh project)
These steps are not generated by gen_project.py and are easy to miss. A
reference copy of the resulting files is in editor-devices/.
A1. Create the project
WHERE: OpenPLC Editor, on the machine you install it on.
WHAT: New Project, name wrps.
WHAT FOR: creates the folder skeleton the generator writes into. The base
program language does not matter — the generator replaces pous/ entirely,
including deleting the placeholder main.
A2. Point it at the runtime
WHERE: Editor → Device → Configuration.
WHAT: Device OpenPLC Runtime V4, IP 10.0.0.17, port 8443, runtime
user admin (password in 02-environment/secrets.local.md, git-ignored).
WHAT FOR: identifies the target board and the REST endpoint.
The reference copy in
editor-devices/configuration.jsonstill sayslocalhost, which was correct when the Editor ran on the same host as the runtime. It is not correct now — use the LAN address.
A3. Add the Modbus server — the step easiest to get wrong
WHERE: Editor → Device → Servers → add a Modbus TCP server (Modbus01).
WHAT: Enable Server on; leave networkInterface 0.0.0.0 and port 502.
WHAT FOR: this makes the Editor ship conf/modbus_slave.json in the upload
bundle, which flips the runtime's modbus_slave plugin to enabled. Without it the
plugin stays off and nothing ever binds port 502, no matter how the container
is configured.
0.0.0.0here is correct and is not in conflict with the container binding 502 to10.0.0.17. This setting is inside the container — the slave listens on all of the container's own interfaces. Docker's port publishing is what restricts exposure to the host's LAN address. Do not "fix" one to match the other.
⚠️ Do not use Device → Remote Devices for this. That is the Modbus master: it makes the PLC poll someone else, inverting the architecture invariant, and its IO groups claim
%IWaddresses that collide with the field inputs insrc/10_globals.st. CI Server needs no entry in the PLC project at all — it is a client and simply connects.
B. Every deployment
B0. Generate and install the project
WHERE: wherever this repo is checked out.
python gen_project.py --mode sim --out editor-project
then copy editor-project/project.json and editor-project/pous/ over the
Editor project folder.
WHAT FOR: the Editor has no flat-.st import — a v4 project is a folder
(project.json + one file per POU). This also writes the located addresses
(%IW0, %QX0.0, …) straight into project.json, which the Global Variables grid
will not let you type by hand.
devices/ and build/ are never touched — only project.json and pous/.
This repo is canonical. Never edit the Editor project in place — changes there
are wiped on the next generate. Fixes go into src/.
B1. Launch the Editor
WHERE: a graphical desktop session — not over SSH.
~/Applications/OpenPLC.Editor-4.2.11.AppImage --no-sandbox
WHAT FOR: --no-sandbox is required (Electron) and libfuse2t64 must be
installed, or the AppImage exits immediately.
B2. Open the project
⚠️ The Editor must be CLOSED while files are copied in — not merely showing a different tab. It holds
project.jsonin memory and writes that copy back on its next save, silently discarding whatever was installed.Correct order: quit the Editor → copy and verify the md5 → reopen → build.
This has actually happened (2026-08-14). The symptom is confusing because it is partial: new POU files survive — the Editor discovers those by scanning
pous/— whileproject.jsonreverts, so the build fails witherror: VAR_EXTERNAL 'G_SIM_LEVEL_MM' in program 'SIMULATION' has no matching VAR_GLOBAL declarationfor every new global, while pre-existing globals resolve normally. New program instances vanish from the task list the same way. If you see that pattern, the ST is probably fine — check
project.jsonbefore touching the code.
B3. Connect to the runtime — required before any upload
WHERE: Editor → Device → Configuration → Connect to OpenPLC Runtime.
WHAT: authenticate as admin.
WHAT FOR: Upload and Start PLC are unavailable until the session is connected.
The console confirms with [connection] status -> connected.
B4. Build and upload
WHERE: the left icon rail: Search · Open/Close Toolbox · Build Options · Start PLC · Debugger. There is no separate upload button.
⚠️ Turn compile-only mode off. If the console ends with "Compile only mode — skipping upload to runtime", nothing was uploaded. In that mode the composed bundle is discarded rather than written to
build/, so there is no zip left behind for any automated upload path either.
WHAT FOR: the Editor runs STruC++ (v0.6.2), composes the bundle, POSTs it to
/api/upload-file, and the runtime rebuilds new_libplc.so with Make while the
Editor polls /api/compilation-status.
B5. Start the PLC
The runtime opens its Modbus slave only while a program is running.
B6. Verify
python ../05-tests/verify_modbus.py --host 10.0.0.17 --port 502 --unit 1
10.0.0.17 is only routable from inside the VNet or over the WireGuard VPN. From
outside, poll from a throwaway container on the host instead:
ssh lin001 "docker run --rm --network openplc-net python:3.12-alpine sh -c 'pip install -q pymodbus && python -c \"...\"'"
What "working" looks like in the Editor log:
Final state - modbus_slave: enabled=True
PLC started.
Upload complete.
Gotchas, each one actually hit
The Editor's declaration parser is separate from the body compiler and rejects
three things inside a VAR block:
- comment-only lines, e.g.
(* process image in *) - a
(* … *)comment that wraps onto the next line - more than one declaration on a line, e.g.
A : INT; B : INT;
A trailing single-line comment is fine. gen_project.py handles all three, so
src/ keeps its comments and formatting. Symptom of a regression: "could not be
fully parsed … preserved as raw text", then "No variable defined in POU".
The Editor class enum has no constant (input|output|inOut|external|local| temp|global), so VAR_GLOBAL CONSTANT entries are emitted as ordinary globals
with their initial values. Behaviour is unchanged — nothing writes them — but they
are no longer compiler-enforced read-only. The generator warns each run.
Task interval must be T#100ms. The generator takes it from the configuration
file in src/. Run hours, the PI integral and the inflow filter all hardcode
SCAN_S := 0.1; a mismatch silently skews tuning and accumulation.
EtherCAT gets enabled on the runtime from an ethercat.json the Editor project
emits by default. Harmless so far, but it is a plugin running for no reason.
Notes that will bite otherwise
%MWis not holding register 0.%QWoccupies HR 0–1023 and%MWstarts at HR 1024, so%MW3(level setpoint) is HR 1027. Confirmed against the plugin's own defaults (qw_count: 1024,mw_count: 1024). Useregister-map.csv, which carries the resolved addresses.%QW17(alarm bitmask) must be read as unsigned 16-bit. Bit 15 does not fit a signed INT.%QW7is genuinely signed.- Nothing is retained. Restarting the runtime resets everything;
IO_MUXre-seeds the%MWsetpoint defaults on first scan — which discards any live demo tuning. SeeREADME.md. - All
%IWread 0 in the simulation build. That is correct — the mux takes simulated values instead. In a field build with nothing driving the inputs, the program correctly trips the pumps and raises alarms; that is not a fault either. - The runtime image is local to the host and cannot be re-pulled. It was
produced by
docker commitduring the migration and carries the compiled program. Deleting it loses the program. See02-environment/.