668 lines
34 KiB
Markdown
668 lines
34 KiB
Markdown
# wago-modbustcp-simulator-py
|
||
|
||
**WAGO 750-362 Modbus TCP bus coupler simulator for autonomous PLC FAT testing.**
|
||
|
||
The simulator replaces a physical WAGO I/O node during Factory
|
||
Acceptance Tests: the PLC under test connects via Modbus TCP and talks to
|
||
a virtual node whose I/O behavior is driven by scripted scenarios.
|
||
|
||
## Features
|
||
|
||
- Faithful WAGO 750-362 register map (manual §11.2.4): process image
|
||
words/bits with the exact mapped/unmapped areas and exception behavior,
|
||
read-back mirrors (+`0x0200` / +`0x1000`), configuration registers
|
||
`0x1000–0x2FFF`, constants `0x2000–0x2008` (byte-order check `0x1234`).
|
||
- Function codes FC1, FC2, FC3, FC4, FC5, FC6, FC11, FC15, FC16, FC22,
|
||
FC23 with the device limits (2000/800 bits, 125/100 registers).
|
||
- Modbus watchdog (`0x1000–0x100B`), statistics block (`0x1029`),
|
||
response-delay injection (`0x1037`), module diagnosis (`0x1050`).
|
||
- Virtual node built from a YAML module list (WAGO 750 catalog subset,
|
||
extensible).
|
||
- **Scenario engine**: YAML scenarios with aliases, macros, ramps,
|
||
pulses, waits, and PASS/FAIL assertions with timeouts; state machines
|
||
and Python action plugins for complex plant models.
|
||
- **Two I/O addressing schemes** for aliases: *module-based* —
|
||
`{ type: di, module: 2, channel: 1 }` = `DI2.1` (module **numbered
|
||
0-based within its type** — `DI0`, `DO0`, `AI0`, `AO0` start each
|
||
kind — and channel 0-based; v1.5) — and *pool-based* —
|
||
`{ type: di, channel: 3 }` (1-based index across the node's DI
|
||
pool). Both may be mixed.
|
||
- **Bench mode** *(requested, planned — spec §9)*: run the simulator
|
||
without a scenario (`wago-sim serve --node ...` already works);
|
||
planned additions: `--set DI0.1=ON` startup seeding, an interactive
|
||
console monitor with live I/O table, and runtime set/pulse/watch
|
||
commands.
|
||
- **Web UI** *(requested, planned — spec §10)*: browser front-end to
|
||
overlook and manipulate the virtual node's I/O — live channel tables
|
||
with `DI0.1`-style addresses, toggle/force/pulse/ramp actions,
|
||
watchdog/statistics/diagnostics views with fault injection, and
|
||
multi-client consistency for collaborative FAT benches.
|
||
- **Runtime device configuration** *(requested, planned — spec §11)*:
|
||
WBM/DIP-switch parity — module "plug/unplug" at runtime with
|
||
process-image rebuild (SAVE CONFIG / KBUS `0x102B` semantics),
|
||
address-selection DIP-switch emulation (host ID 1–254, 0 = stored
|
||
config, 255 = DHCP), WBM-equivalent pages (Port/Ethernet, Modbus
|
||
settings, Modbus Mapping, I/O Config), and device-state persistence
|
||
with factory reset.
|
||
- **Host network settings** *(implemented 2026-10-06 — spec §15)*: a
|
||
Docker-gated, opt-in settings page (`WAGO_SIM_HOSTNET=1` **and**
|
||
container detection) that lists the host OS's network interfaces
|
||
and adds/removes/changes their IPv4 addresses through a
|
||
whitelisted host-side agent — so simulated node re-addressing
|
||
(DIP dedicated-IP mode) and network-loss fault cases become real
|
||
on the bench network. Runtime-only changes, auto-snapshot with
|
||
restore, and an operator-tagged audit log; fully absent (404, tab
|
||
hidden) when the flag is off.
|
||
- **Long-duration (continuous) simulation** *(requested, planned — see
|
||
`docs/spec/software-spec.md` §8)*: endurance/soak testing over days or
|
||
weeks of simulated time. Designated inputs are driven from streaming
|
||
CSV datasets (historian exports, demand/operator profiles, seamless
|
||
`repeat`, `time_factor` acceleration), while degradation models age
|
||
plant parameters (e.g., pump capacity) from wear counters — exercising
|
||
the PLC's compensation, maintenance-alarm and service-recovery logic.
|
||
Continuous `invariants` and `rate_limits` produce bounded, episode-based
|
||
reports for unattended runs.
|
||
- ≤ 15 concurrent Modbus TCP connections, idle-timeout handling.
|
||
- **Service / container deployment** — run as infrastructure:
|
||
Dockerfile (non-root, health check, ~197 MB), docker-compose service
|
||
(restart policy, log rotation, read-only config mounts), systemd unit
|
||
example, env-var configuration (`WAGO_SIM_NODE/HOST/PORT`), and
|
||
graceful SIGTERM shutdown (`docker stop` completes in < 1 s).
|
||
|
||
## Install (dev)
|
||
|
||
```bash
|
||
pip install -e .[dev]
|
||
```
|
||
|
||
## Usage
|
||
|
||
Run the Modbus TCP server against a node configuration:
|
||
|
||
```bash
|
||
wago-sim serve --node config/pump_node.yaml --port 502
|
||
```
|
||
|
||
Validate a scenario:
|
||
|
||
```bash
|
||
wago-sim validate --scenario scenarios/pump_station_fat.yaml
|
||
```
|
||
|
||
Run a scenario headless and write reports:
|
||
|
||
```bash
|
||
wago-sim run --node config/pump_node.yaml \
|
||
--scenario scenarios/pump_station_fat.yaml \
|
||
--report-jsonl out/report.jsonl --report-csv out/report.csv
|
||
```
|
||
|
||
## Deployment (service / container)
|
||
|
||
The simulator runs as a long-lived service. Configuration comes from
|
||
environment variables — `WAGO_SIM_NODE`, `WAGO_SIM_HOST`, `WAGO_SIM_PORT` —
|
||
so no CLI arguments are needed in the container command.
|
||
|
||
Docker:
|
||
|
||
```bash
|
||
docker build -t wago-sim .
|
||
docker run -d --name wago-sim -p 5020:502 \
|
||
-v ./config:/app/config:ro -v ./scenarios:/app/scenarios:ro \
|
||
-v ./plugins:/app/plugins:ro -v ./out:/app/out \
|
||
-e WAGO_SIM_NODE=config/pump_node.yaml \
|
||
wago-sim
|
||
```
|
||
|
||
Compose (restart policy, health check, log rotation included):
|
||
|
||
```bash
|
||
docker compose up -d # start
|
||
```
|
||
|
||
```bash
|
||
docker compose logs -f wago-sim
|
||
docker compose down # graceful SIGTERM shutdown
|
||
```
|
||
|
||
Bare metal / VM (systemd; unit file in `deploy/wago-sim.service`):
|
||
|
||
```bash
|
||
sudo cp deploy/wago-sim.service /etc/systemd/system/
|
||
sudo systemctl daemon-reload && sudo systemctl enable --now wago-sim
|
||
journalctl -u wago-sim -f
|
||
```
|
||
|
||
Notes:
|
||
|
||
- Host port 5020 by default; use 502 if your bench expects it and your
|
||
host allows binding privileged ports (the container runs non-root;
|
||
the systemd unit grants `CAP_NET_BIND_SERVICE` to a dedicated user).
|
||
- `docker stop` sends SIGTERM; the server closes all client connections
|
||
and the listening socket cleanly (< 1 s) — useful for testing the
|
||
PLC's fieldbus-failure reaction deliberately.
|
||
- Reports from headless `run` invocations go to the mounted `out/`.
|
||
|
||
## Verbose mode (request tracing)
|
||
|
||
Run with `-v` / `--verbose` (or `WAGO_SIM_VERBOSE=1` for containers) to
|
||
trace every Modbus transaction in plain text — with the WAGO 750-362
|
||
register-map meaning of every address annotated:
|
||
|
||
```
|
||
$ wago-sim serve --node config/pump_node.yaml -v
|
||
|
||
REQ txn=0x0001 unit=1 FC03 Read Holding Registers
|
||
addr=0x2002 count=1 [config/constant regs 0x2002]
|
||
RSP txn=0x0001 unit=1 FC03 -> 1 regs: [0x1234]
|
||
REQ txn=0x0002 unit=1 FC06 Write Single Register
|
||
addr=0x0000 value=0x1234 [output area 1 (%QW0)]
|
||
RSP txn=0x0002 unit=1 FC06 -> echo
|
||
EXC txn=0x0006 unit=1 FC03 -> code=0x02 Illegal Data Address
|
||
RAW txn=0x0007 unit=1 PDU[3]: 07 00 00
|
||
```
|
||
|
||
Traces go to the standard log at DEBUG level, so they appear in the
|
||
terminal, in `docker logs`, and in `journalctl` alike. Read vs write
|
||
mappings are annotated correctly per the manual's Tables 119/120
|
||
(e.g. word address `0x0000` reads as `%IW0` but writes to `%QW0`).
|
||
|
||
## Web UI
|
||
|
||
`serve` starts a browser UI alongside the Modbus server (spec §10):
|
||
|
||
```bash
|
||
wago-sim serve --node config/pump_node.yaml --port 5020
|
||
# Web UI on http://127.0.0.1:8080 — default login: admin / simu
|
||
```
|
||
|
||
- **Overview**: realistic DIN-rail view of the coupler + modules with
|
||
per-channel LEDs for digital I/O and bar/numeric indicators for
|
||
analog values; updates live via WebSocket — including changes made
|
||
by the PLC over Modbus.
|
||
- **Manipulation**: click a DI/DO LED to toggle it (field side), or
|
||
force DO/AO master-side; AI values can be set directly. All writes
|
||
go through the simulator's internal data paths — never via Modbus
|
||
TCP — while keeping read-back mirrors and statistics consistent.
|
||
- **Diagnostics**: watchdog status, statistics with reset, and
|
||
LED/module fault injection for FAT fault cases.
|
||
|
||
Configuration options: `--no-web`, `--web-port <n>` or env
|
||
`WAGO_SIM_WEB_PORT` (default 8080); credentials via
|
||
`WAGO_SIM_WEB_USER` / `WAGO_SIM_WEB_PASSWORD` (defaults
|
||
`admin` / `simu`); `WAGO_SIM_WEB_READONLY=1` for observation-only
|
||
benches (all mutating endpoints answer 403). Plain HTTP — keep the
|
||
port inside the bench network.
|
||
|
||
### Host network settings (spec §15, Docker only)
|
||
|
||
An additional **Host network** tab can list and manipulate the
|
||
**host OS's** IPv4 addresses from the browser — for FAT cases that
|
||
need network-level re-addressing or address-loss faults on the bench
|
||
network. It exists only when both gates pass:
|
||
|
||
1. `WAGO_SIM_HOSTNET=1` on the simulator container (opt-in flag), and
|
||
2. running inside Docker (compose sets `WAGO_SIM_IN_DOCKER=1`).
|
||
|
||
The container itself stays non-root and unprivileged: changes go
|
||
through a whitelisted **agent on the host**:
|
||
|
||
```bash
|
||
# once, on the Linux host:
|
||
sudo cp deploy/wago-sim-hostnet-agent.service /etc/systemd/system/
|
||
sudo cp deploy/hostnet-agent.yaml /etc/wago-sim/hostnet.yaml
|
||
# edit the whitelist to your bench NIC(s), then:
|
||
sudo systemctl enable --now wago-sim-hostnet-agent
|
||
# journalctl -u wago-sim-hostnet-agent -f (audit trail lands here)
|
||
|
||
# compose: uncomment in docker-compose.yml —
|
||
# WAGO_SIM_HOSTNET: "1"
|
||
# - /run/wago-sim:/run/wago-sim
|
||
# and align the container user's group with the socket (0660).
|
||
docker compose up -d
|
||
```
|
||
|
||
Safety rails: only whitelisted interfaces are mutable (refusals are
|
||
shown and audited); every change is preceded by an auto-snapshot with
|
||
a **Restore last change** button; changes are runtime-only — a host
|
||
reboot restores the deployed addressing; every mutation and refusal is
|
||
logged with the logged-in operator's name. If the agent is down the
|
||
tab shows a "host control unavailable" banner and mutations answer
|
||
503 — the simulator itself is unaffected.
|
||
|
||
## Plant environments (continuous simulation)
|
||
|
||
A **plant environment** simulates the physical world around the
|
||
distributed I/O: per deterministic tick it reads what the PLC
|
||
commanded (DO/AO), runs your plant physics, and writes what the field
|
||
now shows (DI/AI) — the sensors the PLC reads back. Environments run
|
||
**until manually stopped** (Ctrl+C / `docker stop`), optionally ending
|
||
sooner via an `is_done()` criterion.
|
||
|
||
Attach one to a live Modbus server (wall-clock-paced 10 ms ticks):
|
||
|
||
```bash
|
||
wago-sim serve --node config/pump_node.yaml --port 5020 \
|
||
--env envs.pump_station_env:PumpStationEnv
|
||
```
|
||
|
||
or run headless (unpaced, for plant-model development):
|
||
|
||
```bash
|
||
wago-sim sim --node config/pump_node.yaml \
|
||
--env envs.pump_station_env:PumpStationEnv
|
||
```
|
||
|
||
**Authoring one** is a small subclass — override `model()`:
|
||
|
||
```python
|
||
# myenvs/tank.py
|
||
from wago_sim.scenario.plantenv import PlantEnv
|
||
|
||
class TankEnv(PlantEnv):
|
||
def model(self, state, dt_s):
|
||
pump_on = bool(state.by_code("DO0.0")) # PLC command
|
||
level = float(state.by_code("AI0.0")) # current field
|
||
return {"AI0.0": level + (-5.0 if pump_on else 1.0) * dt_s}
|
||
|
||
Env = TankEnv # `--env myenvs.tank` picks this up
|
||
```
|
||
|
||
API surface (spec FR-8.2.14–17): `observe()` returns every
|
||
DI/DO/AI/AO channel (I/O codes, names, values, analog display
|
||
ranges) plus aliases and degradation parameters; `apply()` writes DI/AI (the field);
|
||
`force()` writes DO/AO master-side (headless runs, fault setup);
|
||
`is_done()` optional end; `reset()` deterministic replay.
|
||
`state.channel(key)` returns the full channel record by I/O code
|
||
or configured name; `state.channel_range(key)` gives an analog
|
||
channel's (min, max) directly. Reference
|
||
forms: `pkg.mod:ClassName`, `pkg.mod:factory`, or `pkg.mod` with
|
||
`Env`/`make_env`. The shipped example is
|
||
`envs/pump_station_env.py` (tank + pump physics with a low-level
|
||
switch). Scenario-continuous mode (§8 YAML: datasets, degradation,
|
||
invariants) remains available for scripted soak runs on the same
|
||
engine ticks.
|
||
|
||
## Documentation
|
||
|
||
- `docs/spec/software-spec.md` — the software specification
|
||
(requirements, architecture, scenario DSL, acceptance criteria,
|
||
and the long-duration simulation feature request in §8).
|
||
- `docs/m07500362_xxxxxxxx_0en.pdf` — WAGO 750-362 reference manual.
|
||
|
||
## Testing
|
||
|
||
```bash
|
||
pytest
|
||
```
|
||
|
||
## TODO
|
||
|
||
Work remaining, tracked against the software specification
|
||
(`docs/spec/software-spec.md`). Completed items are checked and grouped
|
||
first; open items follow, grouped by spec phase, with the spec section or
|
||
requirement ID for each item in parentheses.
|
||
|
||
### Completed — framework phase (spec §3 + §4 core, 141 tests passing)
|
||
|
||
- [x] MBAP + PDU codecs: function codes FC1, FC2, FC3, FC4, FC5, FC6,
|
||
FC11, FC15, FC16, FC22, FC23 with the 750-362 device limits
|
||
(2000/800 bits, 125/100 registers) and exception codes (§3.2).
|
||
- [x] WAGO register map (§3.3): word/bit process-image areas with the
|
||
exact mapped/unmapped ranges, illegal-address exceptions, output
|
||
read-back mirrors (+`0x0200` / +`0x1000`), digital
|
||
channel-number bit addressing, constants `0x2000–0x2008`
|
||
(incl. byte-order check `0x2002` = `0x1234`).
|
||
- [x] Configuration/diagnostic/firmware registers (§3.5): image sizes
|
||
`0x1022–0x1025`, statistics `0x1029`, connections `0x102A`, idle
|
||
timeout `0x1030`, MAC-ID `0x1031`, response delay `0x1037`,
|
||
module diagnosis `0x1050`, LED error `0x1020/0x1021`.
|
||
- [x] Modbus watchdog (§3.4): registers `0x1000–0x100B`, Standard and
|
||
Alternative modes, trigger/masks/stop sequences, expiry clears
|
||
outputs, address-aware `0x04` lock in Standard mode, optional
|
||
TCP close on timeout.
|
||
- [x] Fault-injection paths (§3.6): response-delay injection,
|
||
connection refusal beyond 15, illegal-address probing responses,
|
||
diagnosis/LED registers scriptable.
|
||
- [x] Virtual node built from a YAML module list (13-module WAGO 750
|
||
catalog subset; complex-first, bit-byte-packed image layout per
|
||
manual §7.3.2.1).
|
||
- [x] Scenario DSL (§4): named aliases with `scale`/`offset`, macros
|
||
with parameters (inline expansion, blocking waits inside macros),
|
||
sequence steps `set` / `reset` / `toggle` / `pulse` / `ramp` /
|
||
`wait` / `assert` / `report`, rising-edge detection on all
|
||
aliases.
|
||
- [x] Two I/O addressing schemes for scenario aliases (FR-4.3.11):
|
||
module-based (`{ type: di, module: 2, channel: 1 }` = `DI2.1`,
|
||
module slot and channel 0-based) and pool-based (`channel:` =
|
||
1-based index within the node's kind pool); kind/module/channel
|
||
validation with clear errors; both verified by tests.
|
||
- [x] Assertions with `within:` timeouts → PASS/FAIL verdicts with
|
||
sim-time stamps (FR-4.3.5).
|
||
- [x] Python action/plant-model plugins: per-tick models and step
|
||
actions (FR-4.3.8), e.g. `plugins/pump_plant.py`.
|
||
- [x] Deterministic tick-driven engine: sim-time decoupled from
|
||
wall-clock (FR-4.3.9 core).
|
||
- [x] Service / container deployment (spec §12): env-var config
|
||
(`WAGO_SIM_NODE/HOST/PORT`, CLI args override), graceful
|
||
SIGINT/SIGTERM shutdown via `server.stop()`, Dockerfile
|
||
(python:3.12-slim, non-root user, TCP health check),
|
||
docker-compose service (restart policy, resource limits, log
|
||
rotation, read-only config mounts), systemd unit example with
|
||
hardening; verified end-to-end (build, Modbus round-trip,
|
||
`docker stop` in 0.34 s); CLI env tests added.
|
||
- [x] CLI: `wago-sim serve / run / validate`.
|
||
- [x] Reports: console verdict summary, JSONL and CSV export
|
||
(`--report-jsonl` / `--report-csv`).
|
||
- [x] asyncio Modbus TCP server: ≤ 15 concurrent connections, idle
|
||
connection reaper, response-delay task.
|
||
- [x] Test suite: 178 tests incl. real-socket server tests, `pymodbus`
|
||
interop, CLI env-config and bad-argument tests, and plain-text
|
||
trace tests; spec §7 acceptance criteria 1–7 covered.
|
||
- [x] Clean CLI error handling: invalid arguments (unknown command/
|
||
options, non-numeric or out-of-range `--port`, bad
|
||
`WAGO_SIM_PORT`), missing/unparsable node and scenario files, and
|
||
unwritable report paths all produce a one-line
|
||
`wago-sim: error: …` on stderr with exit code 2 — never a Python
|
||
traceback; missing report directories are auto-created;
|
||
internal errors keep the full traceback for debugging (NFR-6.4
|
||
operational goal).
|
||
|
||
### Requested — multi-vendor & multi-protocol (spec §13 + §14, not yet scheduled)
|
||
|
||
- [ ] Multi-vendor device profiles (spec §13): extract the per-device
|
||
facts (function codes/limits, connection limit, address maps,
|
||
mirrors, config/watchdog/statistics blocks, trace annotations)
|
||
into a device profile; other WAGO 750 Modbus couplers and other
|
||
brands' Modbus TCP I/O heads become data + a profile module
|
||
(FR-13.2.1–13.2.7); scenario engine, DSL, plugins and reports
|
||
stay device-agnostic; `device:` key in node config with the
|
||
750-362 as default; validate rejects unknown devices and
|
||
cross-brand module mixes cleanly.
|
||
- [ ] Additional fieldbus protocols (spec §14): protocol bindings for
|
||
ABB AC800M-style benches — PROFINET IO device simulation (RT
|
||
cyclic data exchange, DCP, record data; FR-14.2.2) and COMLI
|
||
over TCP as used between AC800M and S800/S900 I/O via
|
||
CI51x/CI522 (FR-14.2.3) — reusing the same process image,
|
||
scenario engine and reporting (FR-14.2.1, FR-14.2.4); per-
|
||
protocol tracing, diagnostics and fault injection (FR-14.2.5–
|
||
14.2.6); scoping questions to answer before planning (§14.3).
|
||
|
||
### Host network settings (spec §15) — IMPLEMENTED 2026-10-06
|
||
|
||
The §15 feature is implemented (v1 core operations): the
|
||
Docker-gated settings page with add/remove/change/restore, the
|
||
host-side agent, the safety rails, and the audit trail. 58 dedicated
|
||
tests (`tests/test_hostnet.py`); full suite 339 passing.
|
||
|
||
- [x] Host OS IP settings page (FR-15.2.1–15.2.3): exposed in the web
|
||
UI only when `WAGO_SIM_HOSTNET=1` **and** running inside Docker
|
||
(compose sets `WAGO_SIM_IN_DOCKER=1`; `/.dockerenv` as a
|
||
supplementary signal); authenticated page listing the host's
|
||
interfaces with link state and current IPv4 addresses, read
|
||
from live host state, with add/remove/change-address operations
|
||
validated client- and agent-side; routes answer 404 and the tab
|
||
stays hidden when disabled — no dormant surface.
|
||
- [x] Host-side agent + safety rails (FR-15.2.4–15.2.8):
|
||
`wago-sim hostnet-agent --config deploy/hostnet-agent.yaml`
|
||
runs on the host (systemd unit
|
||
`deploy/wago-sim-hostnet-agent.service`, root or `CAP_NET_ADMIN`,
|
||
hardened) — Unix socket mounted into the container
|
||
(`/run/wago-sim`), interface whitelist, runtime-only
|
||
(non-persistent) address changes so a host reboot restores
|
||
deployed addressing, auto-snapshot before every mutation with
|
||
restore, single-line audit log (operator-tagged, refusals
|
||
included; journald + optional file); degraded state (agent
|
||
unreachable) shows a banner and answers 503 without affecting
|
||
the rest of the UI; readonly mode keeps status readable and
|
||
403s every mutation.
|
||
- [ ] DIP dedicated-IP integration (FR-15.2.9): applying a DIP-switch
|
||
address change in dedicated-IP mode routes through the agent so
|
||
the derived IP lands on the host NIC — deferred until the §11
|
||
DIP-switch model ships; link up/down (cable pull) also deferred
|
||
(v2, FR-15.2.4).
|
||
|
||
### Web UI (spec §10) — in implementation
|
||
|
||
- [x] Spec §10.3–10.6 updated with binding implementation decisions:
|
||
FastAPI/Uvicorn in-process on the Modbus server's asyncio loop;
|
||
vanilla single-page front-end (no build step) with realistic
|
||
DIN-rail graphics and per-channel LEDs; session-cookie auth with
|
||
default credentials `admin`/`simu` (env-overridable,
|
||
read-only mode); backend API uses **only internal memory
|
||
handlers** (`WagoDatastore`, `VirtualNode`, registers,
|
||
watchdog, statistics) — never Modbus TCP; WebSocket live
|
||
updates; milestones M1–M6.
|
||
- [x] M1: webui package (`src/wago_sim/webui/`: `app.py`, `auth.py`,
|
||
`state.py`, `api.py`, static SPA); session auth with scrypt
|
||
hashing, lockout after 5 failed logins, read-only mode
|
||
(`WAGO_SIM_WEB_READONLY`); `GET /api/node` with `DI0.1`-style
|
||
and Modbus addresses; CLI wiring `--web/--no-web`,
|
||
`--web-port`, env `WAGO_SIM_WEB_USER/PASSWORD/READONLY/
|
||
NO_WEB/WEB_PORT`; login page + SPA shell.
|
||
- [x] M2: node overview page — DIN-rail strip with coupler block
|
||
(MS/NS/BF/SF LEDs), module bezels with article numbers, the
|
||
kind+slot designation (`DI0`, `DO2`, `AI4`) in the module's
|
||
lower left corner below the indicators, per-channel LEDs for
|
||
DI/DO with the channel number beneath, bar+numeric indicators
|
||
for AI/AO with the channel number outside the overflow/underflow
|
||
limit LEDs, live values via WebSocket (`/api/ws`, 10 Hz delta
|
||
push), `GET /api/values` snapshot (FR-10.2.2).
|
||
- [x] M3: manipulation — DI/DO toggle (field side via
|
||
`set_digital_input_channel`, master side via internal
|
||
`write_bits`/`write_words` dispatch so mirrors/statistics stay
|
||
consistent), AI value entry (raw counts), AO force;
|
||
unknown-channel → 404, kind-mismatch → 400, read-only → 403.
|
||
- [x] M4: diagnostics — watchdog status card (0x1006/0x100A),
|
||
statistics table (0x1029 words) with reset, LED-error
|
||
(0x1020/0x1021) and module-diagnosis (0x1050) fault injection
|
||
and clear; SF LED on the coupler reflects injected LED errors.
|
||
- [x] M5: configuration — coupler settings forms (watchdog time/masks/type/close-on-timeout via 0x1000–0x100B, response delay 0x1037, idle timeout 0x1030) with the device's own validation surfaced (writes refused while the watchdog runs → field error); module list editor over the 13-article catalog with SAVE CONFIG → live process-image rebuild (`rebuild_node`: datastore survives, values clear like a KBUS reset, 0x1022–0x1025 and 0x2030+ updated) + persistence to the node-config YAML via a comment-preserving custom emitter. Modules re-orderable by drag-and-drop or ▲/▼ arrows (FR-11.2.6); with per-type I/O codes (FR-4.3.11 v1.5) cross-kind moves don't renumber other kinds' channels.
|
||
- [x] M5a: channel names for every module channel (FR-10.2.11) —
|
||
node-config persisted (`channel_names:` mapping, I/O code →
|
||
name), survive module saves and rebuilds, shown in the overview
|
||
list/tooltips and a per-channel name editor; configuration
|
||
import/export as JSON (FR-10.2.12) — modules, channel names,
|
||
coupler settings in one file, atomic validated import with
|
||
stale-name-code dropping; split overview (FR-10.2.13) — DIN-rail
|
||
graphic on top, live scrollable channel list below with I/O
|
||
code, configured name, and a switch (DI/DO) / numeric field
|
||
(AI/AO) for display + manipulation.
|
||
- [x] M5b: analog display ranges (FR-10.2.14) — bar indicators fill
|
||
from the bottom against the channel's display range, default
|
||
0–4095 counts (100 % at 4095, WAGO 750 12-bit scaling), with
|
||
per-channel min/max configurable in the node config
|
||
(`channel_ranges:`) and editable on the Configuration page; values
|
||
outside the range fill the bar red with a red LED above the bar
|
||
(overflow) or below it (underflow); ranges carried through
|
||
module saves, export/import, and rebuilds; also fixes the bar
|
||
never filling (it set width instead of height on a vertical bar)
|
||
and the invisible numeric label (clipped by overflow: hidden).
|
||
Signed support (2026-10-04): range bounds and simulated values
|
||
may be negative (-65534–65535; stored as 16-bit two's
|
||
complement), for signed plant quantities. DI/DO channels render
|
||
in vertical columns of eight (0–7 left, 8–15 next) on thin,
|
||
tall module bezels mirroring real DIN-rail housings. Dense
|
||
store (2026-10-04): `node.channel_ranges` always carries every
|
||
analog channel of the current layout (unconfigured = the
|
||
materialized default, stale codes pruned on rebuild), so
|
||
environments read min/max without fallbacks; the YAML and the
|
||
config export stay lean (non-default entries only).
|
||
- [ ] M6: scenario run view + alias-ownership rejection (FR-10.2.6,
|
||
FR-10.2.9) — deferred.
|
||
- [x] M7: plant environment control (FR-10.2.15, FR-10.2.17) — the
|
||
web UI loads/unloads environments at runtime (`--env` becomes the
|
||
startup default, not the only option): an environment card shows
|
||
ref, state (running/paused/done/none), sim-time and tick ms with
|
||
start (with hot reload of edited source, `sys.modules`
|
||
invalidation), stop (ticker cancelled, field freezes),
|
||
pause/resume (instance kept — pause is a deliberate FAT fault:
|
||
"plant stuck"), reset (deterministic fresh episode) and restart.
|
||
Exactly one env at a time; `is_done()` ends the ticker (state
|
||
`done`); SAVE CONFIG (module-list rebuild) discards the env like
|
||
a KBUS reset discards values; starting while loaded is refused;
|
||
readonly mode blocks every env mutation. API: `GET /api/env` +
|
||
`POST /api/env/{start,stop,pause,resume,reset,restart}`.
|
||
- [ ] M8: environment file authoring (FR-10.2.16, planned) —
|
||
create/modify/delete environment source files (e.g.
|
||
`envs/*.py`) through the web UI (editor pane + upload/download).
|
||
Security note: UI-written Python is RCE by design — same trust
|
||
boundary as the bench network; disabled in readonly mode; delete
|
||
refused while the env is loaded; the on-disk directory must be
|
||
explicit in configuration.
|
||
- [x] Tests: 46 web-UI tests (`tests/test_webui.py`) via httpx
|
||
`ASGITransport` (in-process, no socket) + live verification:
|
||
browser SAVE CONFIG rebuilds the node and persists YAML; Modbus
|
||
master reads the module description (0x2030) correctly after the
|
||
loader-overlap register fix; channel-name save + list sync;
|
||
export/import round-trip with a Modbus read after the rebuild;
|
||
bar states verified for in-range (97.7 % at 4000), overflow,
|
||
custom-range, and underflow. Full suite: 224 passing.
|
||
|
||
### Framework gaps (spec §4 — Scenario & Macro Engine)
|
||
|
||
- [ ] State machines: `states:` with entry actions, transitions on
|
||
input/output edges or conditions, and guards (FR-4.3.2) —
|
||
currently only the time-ordered `sequence:` is implemented.
|
||
- [ ] Actions `noise`, `sin`, `hold` on simulated inputs (FR-4.3.4) —
|
||
`set`/`reset`/`pulse`/`toggle`/`ramp` are done.
|
||
- [ ] `wait_edge` step is accepted but not yet blocking — implement
|
||
edge-wait semantics in the engine loop.
|
||
- [ ] Assertions on statistics registers, e.g. "watchdog expired",
|
||
"BadAddress counter > 0" (FR-4.3.6).
|
||
- [ ] Determinism guard: seed/record plugin RNG state so identical runs
|
||
produce identical reports (FR-4.3.9, NFR-6.3) — needed once
|
||
`noise`/`sin` or randomizing plugins exist.
|
||
- [ ] Multi-node scenarios — several couplers in one scenario
|
||
(FR-4.3.10).
|
||
|
||
### Phase 2 — Module catalog expansion & bench mode (spec §9)
|
||
|
||
- [ ] Analog module variants, counter, pulse-width, SSI, incremental
|
||
encoder, serial interface, DALI/DSI modules with their
|
||
process-data layouts (spec §12.2 of the WAGO manual).
|
||
- [ ] Mailbox parameterization traffic beyond process data.
|
||
- [ ] Bench mode tooling (§9): `--set DI0.1=ON` startup seeding,
|
||
interactive console monitor (`--monitor`) with live channel table
|
||
and change highlighting (FR-9.2.2–9.2.3).
|
||
- [ ] Console runtime manipulation commands: `set`, `pulse`, `watch`,
|
||
`reset stats` (FR-9.2.4).
|
||
- [ ] `serve --scenario` optional combination + passive-node hint
|
||
(FR-9.2.5). *(Plain `serve` without a scenario already works —
|
||
see Completed above.)*
|
||
|
||
### Phase 3 — Long-duration (continuous) simulation (spec §8) — IMPLEMENTED 2026-10-04
|
||
The soak-testing feature specified in §8 is implemented: continuous
|
||
mode with the fixed tick pipeline, optional streaming CSV datasets,
|
||
degradation models, invariants/rate limits with bounded episode
|
||
reporting, snapshots, and the gym-style `PlantEnv`. 30 new tests
|
||
(`tests/test_continuous.py`); the shipped example
|
||
`scenarios/pump_soak.example.yaml` demonstrates the full DSL.
|
||
Datasets are **optional**: signal logic runs in parallel with CSV
|
||
streaming, or entirely without it.
|
||
|
||
- [x] Plant environment `scenario/plantenv.py` (FR-8.2.14–17, reworked
|
||
2026-10-04 to environment-first): standalone `PlantEnv` —
|
||
`observe()` returns ALL modules (DI/DO/AI/AO channels, per-type
|
||
codes, names, parameters); `apply()` writes the FIELD side
|
||
(DI/AI) by default; `force()` writes DO/AO master-side (mirrors/
|
||
statistics/watchdog stay consistent); `model()`/`is_done()`
|
||
override hooks (subclass authoring, FR-8.2.17); `reset()`
|
||
deterministic. Runners: `serve --env ...` wall-clock-paced
|
||
alongside Modbus, runs until stopped; `sim --env ...` headless
|
||
unpaced until is_done()/Ctrl+C. Example: `envs/pump_station_env.py`.
|
||
Verified live: PLC coil write → env physics → level registers
|
||
drain/refill over Modbus.
|
||
- [x] `scenario/datasets.py` — streaming CSV playback for designated
|
||
inputs (OPTIONAL driver): header-driven time column,
|
||
`interpolation: linear|hold`, `repeat` with seamless seams,
|
||
`time_factor` acceleration, `until` limits, bounded memory, ERROR
|
||
verdict with file/line on malformed data (FR-8.2.1–8.2.11).
|
||
- [x] Parallel logic pipeline — fixed per-tick order: datasets → edge
|
||
detection → plant plugins → degradation → invariants/rate
|
||
limits → scheduled steps → snapshots; plugins may read
|
||
dataset-driven and logic-driven aliases in the same tick
|
||
(FR-8.2.12).
|
||
- [x] Logic-only continuous runs — no `datasets:` needed: demand
|
||
profiles generated inside plugins, top-level `until:` bounds the
|
||
run; degradation/invariants/rate limits/snapshots apply
|
||
identically (FR-8.2.13).
|
||
- [x] Continuous-mode run-end + validation rules: run ends when all
|
||
drivers exhausted; scenario with no datasets, no scheduled steps
|
||
and no `until:` rejected (FR-8.2.1).
|
||
- [x] `scenario/degradation.py` — wear counters (runtime hours, start
|
||
cycles, load-hours) and degradation models (`linear`,
|
||
`exponential`, `staircase`, plugin shapes) computing plant
|
||
parameters such as `pump_capacity` (FR-8.3.1–8.3.2).
|
||
- [x] Degradation hooks in plant plugins: `engine.parameter()` accessor
|
||
used in loop physics, e.g. actual flow = nominal × capacity
|
||
(FR-8.3.3).
|
||
- [x] `maintenance_at` thresholds: diagnosis injection via register
|
||
`0x1050`, maintenance-alarm assertions, `maintenance_reset`
|
||
action restoring capacity and clearing diagnoses (FR-8.3.4–8.3.5,
|
||
FR-8.3.8).
|
||
- [x] `scenario/invariants.py` — per-tick `invariants` with bounded
|
||
episode aggregation and `rate_limits` over sliding one-hour
|
||
windows (FR-8.4.1–8.4.2).
|
||
- [x] `snapshot_every` periodic dumps of aliases, wear counters, and
|
||
degradation parameters (FR-8.4.3).
|
||
- [x] Continuous-mode verdict rules and bounded reporting for unattended
|
||
multi-day runs (FR-8.4.4–8.4.5).
|
||
- [x] Scheduled sequence steps via `at: <duration>` interleaved with
|
||
datasets on the sim-time axis (FR-8.4.6).
|
||
- [x] Acceptance tests per spec §8.8 (streaming 100k-row CSV,
|
||
interpolation checks, repeat determinism, time_factor math,
|
||
alias-ownership validation, degradation/recovery round-trip,
|
||
episode recording, full 28-day soak, logic-only run, mixed
|
||
dataset+logic tick-order run).
|
||
|
||
### Phase 4 — Reporting (spec §11)
|
||
|
||
- [ ] FAT report generator (HTML/PDF) from scenario results and Modbus
|
||
traces (console/JSONL/CSV export is done — see Completed above).
|
||
- [ ] Scenario comparison (run-vs-run diff) and template library per
|
||
project type.
|
||
|
||
### Phase 5 — Multi-node orchestration & protocol variants (spec §11)
|
||
|
||
- [ ] Several couplers in one FAT bench (ties into FR-4.3.10).
|
||
- [ ] Modbus UDP variant.
|
||
- [ ] WBM (web-based management) simulation pages.
|
||
- [ ] **Web UI (§10)**: browser front-end for overlooking and
|
||
manipulating I/O — node/module/channel tables with `DI0.1`-style
|
||
and Modbus addresses (FR-10.2.2), live value updates from all
|
||
sources (FR-10.2.3), toggle/force/pulse/ramp manipulation through
|
||
Modbus-consistent paths (FR-10.2.4), diagnostics views with
|
||
fault injection and statistics reset (FR-10.2.5), scenario/run
|
||
view (FR-10.2.6), multi-client consistency (FR-10.2.7), optional
|
||
read-only/access control (FR-10.2.8), alias-ownership protection
|
||
during scenario runs (FR-10.2.9).
|
||
- [ ] **Runtime device configuration (§11)**: module hot-configuration
|
||
(plug/unplug at slot positions) with process-image rebuild per
|
||
KBUS/SAVE-CONFIG semantics and updated `0x1022–0x1025` /
|
||
`0x2030+` registers (FR-11.2.1–11.2.2); alias revalidation after
|
||
rebuilds with position-stable module addressing (FR-11.2.3);
|
||
rejection during running scenarios (FR-11.2.4);
|
||
address-selection DIP-switch emulation with the manual's exact
|
||
coding (1–254 host ID, 0 = stored config, 255 = DHCP,
|
||
restart-to-apply) (FR-11.3.1–11.3.4); WBM-equivalent pages —
|
||
Port/Ethernet, Modbus settings, Modbus Mapping, I/O Config with
|
||
SAVE CONFIG (FR-11.4.1–11.4.4); device-state persistence and
|
||
factory reset via `0x2043` (FR-11.5.1–11.5.2).
|
||
|
||
### Phase 6 — CI integration (spec §11)
|
||
|
||
- [ ] Headless scenario execution in CI pipelines.
|
||
- [ ] JUnit XML export of verdicts for test dashboards.
|
||
|
||
### Operational hardening (spec §6 — NFRs)
|
||
|
||
- [x] Plain-text request/response tracing in verbose mode (`-v` /
|
||
`WAGO_SIM_VERBOSE`): every transaction logged as readable
|
||
`REQ`/`RSP`/`EXC`/`RAW` lines with WAGO register-map annotations
|
||
(input/output areas, %IW/%QW mapping, read-back mirrors,
|
||
config/constant registers, illegal areas) per Tables 119/120 —
|
||
`src/wago_sim/trace.py` + tests. Covers NFR-6.4's readability goal;
|
||
PDU hex is included in `RAW` lines for malformed frames.
|
||
- [ ] Performance budget validation: ≥ 500 requests/s, ≤ 10 ms added
|
||
latency (NFR-6.1). |