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