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.
235 lines
9.9 KiB
Markdown
235 lines
9.9 KiB
Markdown
# 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.
|
||
|
||
```bash
|
||
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.
|
||
|
||
```bash
|
||
~/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 → 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
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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 0–1023 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/`.
|