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

235 lines
9.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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