# 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. - **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 ` 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. ## 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). ### 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. - [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. - [ ] M6: scenario run view + alias-ownership rejection (FR-10.2.6, FR-10.2.9) — deferred. - [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) The soak-testing feature requested 2026-10-03 — specified in §8, not yet implemented. Datasets are **optional**: signal logic (plugins, scheduled steps, degradation) runs in parallel with CSV streaming, or entirely without it. - [ ] `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). - [ ] 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). - [ ] 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). - [ ] 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). - [ ] `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). - [ ] Degradation hooks in plant plugins: `engine.parameter()` accessor used in loop physics, e.g. actual flow = nominal × capacity (FR-8.3.3). - [ ] `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). - [ ] `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). - [ ] `snapshot_every` periodic dumps of aliases, wear counters, and degradation parameters (FR-8.4.3). - [ ] Continuous-mode verdict rules and bounded reporting for unattended multi-day runs (FR-8.4.4–8.4.5). - [ ] Scheduled sequence steps via `at: ` interleaved with datasets on the sim-time axis (FR-8.4.6). - [ ] 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).