# 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/`.