wrps-demo-kit/03-plc/DEPLOY.md
Clio Liu 13a05d0135 feat(plc): ST sources, generators and the Modbus contract
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.
2026-09-02 15:48:49 +10:00

9.9 KiB
Raw Permalink Blame History

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 on yau-sls-poc-lin001, and there is no wrps Editor project there — the home directory holds only openplc-compose.yml and 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 editing src/ and regenerating the project produces something with nowhere to go.

To restore the ability to deploy, someone must:

  1. Install OpenPLC Editor v4.2.11 somewhere with a graphical desktop that can reach 10.0.0.17:8443. Options: a desktop VM on the 10.0.0.0/24 LAN, or a workstation on the WireGuard VPN.
  2. Recreate the Editor project — §A below is the complete list of what is not generated and must be configured by hand.
  3. Connect it to the runtime at https://10.0.0.17:8443 (not localhost — the Editor is no longer on the same host as the runtime).
  4. 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.json still says localhost, 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.0 here is correct and is not in conflict with the container binding 502 to 10.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 %IW addresses that collide with the field inputs in src/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.json in 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/ — while project.json reverts, so the build fails with

error: VAR_EXTERNAL 'G_SIM_LEVEL_MM' in program 'SIMULATION'
       has no matching VAR_GLOBAL declaration

for 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.json before touching the code.

B3. Connect to the runtime — required before any upload

WHERE: Editor → Device → ConfigurationConnect 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

  • %MW is not holding register 0. %QW occupies HR 01023 and %MW starts at HR 1024, so %MW3 (level setpoint) is HR 1027. Confirmed against the plugin's own defaults (qw_count: 1024, mw_count: 1024). Use register-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. %QW7 is genuinely signed.
  • Nothing is retained. Restarting the runtime resets everything; IO_MUX re-seeds the %MW setpoint defaults on first scan — which discards any live demo tuning. See README.md.
  • All %IW read 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 commit during the migration and carries the compiled program. Deleting it loses the program. See 02-environment/.