The old 06-tests, with paths retargeted and both tools proven to run.
Verified end to end during the move - fake_plc.py served on a spare port
and verify_modbus.py read it:
read : 69/69 points OK
writable : 15/15 RW points OK
PASS : the runtime matches the register map
fake_plc.py --expect was BROKEN by the PS_* removal in the previous
commit: it fell back to the scada_tag column, which no longer exists, and
died with KeyError. It now uses ci_item, so the names it prints are the
CI Server items an operator actually sees. Regenerated
expected_readings.txt; fake_values.json came back byte-identical, which
confirms the seed really is deterministic as the docstring claims.
README corrections:
- default host was dev-ubuntu; now 10.0.0.17, with the container
fallback for when that address is not routable
- said 11 RW points; there are 15 (11 control + 4 simulation)
- the 'reading the output' section described the FIELD build - tripped
pumps, alarm word 16500, seeded %MW defaults - as though it were what
you would see. The deployed PLC runs the SIMULATION build. Both are
now shown side by side, with the simulation column marked as the one
that is live.
- says plainly that none of the 20 acceptance tests are implemented
here: this harness verifies the contract, not the behaviour
- adds a caution against running fake_plc.py on port 502 of the live
host, where the real PLC is serving and CI Server is polling
129 lines
5.7 KiB
Markdown
129 lines
5.7 KiB
Markdown
# 05-tests — Modbus verification harness
|
||
|
||
Two tools. One reads the PLC and checks it against the register map; the other
|
||
*is* a PLC, serving deliberately distinctive values so the SCADA side can be
|
||
tested on its own.
|
||
|
||
```bash
|
||
python verify_modbus.py --host 10.0.0.17 --port 502 --unit 1
|
||
python verify_modbus.py --write-test # also prove the 15 RW points accept writes
|
||
python verify_modbus.py --quiet # failures and summary only
|
||
```
|
||
|
||
Requires `pymodbus` (`pip install pymodbus`). Exit code is 0 only if everything
|
||
passed, so it works as a gate.
|
||
|
||
`verify_modbus.py` reads every row of `../03-plc/register-map.csv` from the live
|
||
runtime, using the function code each object type implies — FC1 coils, FC2
|
||
discrete inputs, FC3 holding registers, FC4 input registers.
|
||
|
||
## If you cannot reach 10.0.0.17
|
||
|
||
That address is only routable from inside the VNet or over the WireGuard VPN. From
|
||
anywhere else, poll from a throwaway container on the host's own Docker network:
|
||
|
||
```bash
|
||
ssh lin001 "docker run --rm --network openplc-net python:3.12-alpine \
|
||
sh -c 'pip install -q pymodbus && python - <<EOF
|
||
from pymodbus.client import ModbusTcpClient
|
||
c = ModbusTcpClient(\"openplc-runtime\", port=502, timeout=5); c.connect()
|
||
print(c.read_holding_registers(0, count=21, slave=1).registers)
|
||
EOF'"
|
||
```
|
||
|
||
The container resolves `openplc-runtime` by service name, so no IP is needed.
|
||
|
||
## What a pass proves, and what it does not
|
||
|
||
**Proves:** the runtime honours the register map — every mapped address exists and
|
||
responds, the `%MW` block really does start at holding register 1024, and the RW
|
||
points accept writes.
|
||
|
||
**Does not prove:** that the control logic is correct. **None of the 20 acceptance
|
||
tests from `01-design/WRPS-CTL-003` (§7 and §8.4) are implemented here.** A green
|
||
run against a completely wrong control program looks identical to a green run
|
||
against a right one.
|
||
|
||
That gap is the largest open item in the project. This harness verifies the
|
||
*contract*, not the *behaviour*.
|
||
|
||
## Reading the output
|
||
|
||
What you see depends on which build is running. **The deployed PLC runs the
|
||
simulation build**, so expect the second column.
|
||
|
||
| | Field build, nothing driving the inputs | **Simulation build (deployed)** |
|
||
|---|---|---|
|
||
| `%IW` / `%IX` | all 0 / False | all 0 / False — the mux takes simulated values instead |
|
||
| Pumps | **tripped** (`%QX1.4-1.6` True) — thermal-healthy reads FALSE | running normally |
|
||
| `%QW17` alarm word | **16500** — thermals, mains and `LSLL` all reading unhealthy | 0 |
|
||
| `%QW5` / `%QW6` | 32767, the `CFG_NO_TIME` sentinel | 32767 while drawing down, a real countdown while filling |
|
||
| `%QW11` | 720 — 120 m² × 6.0 m, the plant geometry | falls as the well fills |
|
||
| `%MW` block | the seeded defaults | **whatever was last written** — operators retune these live |
|
||
|
||
In the field build, tripped pumps and a non-zero alarm word are the program
|
||
responding **correctly** to an all-zero field. It is not broken.
|
||
|
||
`%MW` is worth stressing: `IO_MUX` seeds the defaults once on first scan, and
|
||
nothing overwrites them afterwards, so a live system shows the tuning someone
|
||
last wrote — not the values in `03-plc/src/10_globals.st`. See `03-plc/README.md`.
|
||
|
||
## fake_plc.py — testing the SCADA side without a PLC
|
||
|
||
```bash
|
||
python fake_plc.py --serve --port 502 # serve known values
|
||
python fake_plc.py --expect # print what CI Server should show
|
||
```
|
||
|
||
**Why it exists.** Everything downstream of the register map — CI Server's Modbus
|
||
configuration, the conversions, the item bindings, the display masks — had only
|
||
ever been checked against the simulation, whose values are *plausible*. Plausible
|
||
is exactly what you cannot verify: a level of 70% looks right whether it came from
|
||
the correct register or the one beside it.
|
||
|
||
So it serves **distinctive** values instead. Every register gets a different one;
|
||
none round, none equal to a neighbour, and the pattern is deterministic — the same
|
||
seed gives the same numbers, so `expected_readings.txt` can be printed on a
|
||
machine that cannot reach the server.
|
||
|
||
Stdlib only, deliberately: it writes the MBAP header by hand rather than requiring
|
||
`pymodbus` on a host you may not want to install packages on.
|
||
|
||
Speaks FC01, FC02, FC03, FC04, FC06 and FC16. Writes are accepted and stored, so a
|
||
setpoint written from CI Server reads back.
|
||
|
||
| File | What |
|
||
|---|---|
|
||
| `fake_values.json` | the served values, so they can be regenerated or inspected |
|
||
| `expected_readings.txt` | what CI Server should display, item by item, seed `20260814` |
|
||
|
||
> [!CAUTION]
|
||
> **Do not run `fake_plc.py` on port 502 of the live host.** The real PLC is
|
||
> serving there and CI Server is polling it. Use a spare port, or stop the
|
||
> container first and remember that stopping it stops the demo.
|
||
|
||
## Notes
|
||
|
||
- **`%QW17` is decoded unsigned**; bit 15 does not fit a signed INT. Every other
|
||
register is treated as signed, per the map — including `%QW7`, which genuinely
|
||
goes negative.
|
||
- **`--write-test` writes each RW point back with the value it already holds.** No
|
||
value changes. See the caveat in the script docstring about `%MW1`, the command
|
||
word — a non-zero value there is a live command.
|
||
- **If everything fails to connect**, the cause is usually not this harness. The
|
||
runtime opens its Modbus slave only while a program is **running**, and only if
|
||
the Editor project defines a Modbus **Server**. See `03-plc/DEPLOY.md` §A3. A
|
||
refusal looks like a firewall drop and is not: Docker DNATs to the container,
|
||
which returns RST because nothing is bound inside.
|
||
|
||
## Verified 2026-09-02
|
||
|
||
`fake_plc.py` served on a spare port and `verify_modbus.py` read it end to end:
|
||
|
||
```
|
||
read : 69/69 points OK
|
||
writable : 15/15 RW points OK
|
||
PASS : the runtime matches the register map
|
||
```
|
||
|
||
Both tools work against the current 69-point map.
|