Files
wago-modbustcp-simulator-py/docs/spec/software-spec.md
T
2026-10-04 10:01:49 +02:00

1448 lines
75 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.
# Software Specification
**WAGO Modbus TCP Simulator — Autonomous FAT Testing Framework**
| | |
|---|---|
| Project | `wago-modbustcp-simulator-py` |
| Version | 1.4 (Draft for review) |
| Date | 2026-10-03 |
| Status | Framework phase; module addressing implemented (FR-4.3.11); service/container deployment implemented (§12); plain-text request tracing implemented (NFR-6.4); clean CLI error handling implemented (NFR-6.5 CLI part); web UI §10 in implementation (M1–M5a done: auth, overview, manipulation, diagnostics, configuration, channel names, import/export, split overview; M6 scenario view open); long-duration simulation (§8), bench mode (§9), runtime device configuration (§11 — coupler/module/channel-name subset now covered by web UI M5/M5a), multi-vendor device profiles (§13) and additional fieldbus protocols PROFINET/COMLI (§14) requested |
| Target device simulated | WAGO 750-362 / 750-362/0000-0001 FC Modbus TCP (G4) bus coupler |
| Reference documents | `docs/m07500362_xxxxxxxx_0en.pdf` (750-362 Modbus TCP manual), Modbus TCP specification (modbus.org) |
---
## 1. Introduction
### 1.1 Purpose
This document specifies a software system that **simulates a WAGO 750-362 Modbus TCP fieldbus coupler** on a standard PC, to be used as a test bench during **Factory Acceptance Tests (FAT)** of PLC programs.
During FAT the PLC under test is normally wired to a real WAGO I/O node. This simulator replaces the physical node entirely: the PLC (or any Modbus TCP master/SCADA) connects over TCP/IP to the simulator, which responds exactly like a 750-362 coupler — same function codes, same register map, same exception behavior, same timing characteristics.
### 1.2 Problem being solved
FAT of PLC programs requires either:
1. Physical I/O hardware (expensive, must be wired per project, error-prone, single-purpose), or
2. A generic Modbus slave (does not implement the WAGO-specific register map, digital-channel addressing, watchdog, statistics, or module-diagnostics behavior; PLC programs validated against it may fail against a real node).
The simulator provides the **exact WAGO behavior** plus **scriptable, autonomous scenario execution**, so a complete FAT round — including fault cases — can run unattended and produce a machine-verifiable result.
### 1.3 Scope
In scope (v1.0 framework):
- Modbus TCP server (slave) with the 750-362 function-code set.
- WAGO 750-362 register map: process image (words + bits), configuration, diagnostic, constant, and firmware-info registers.
- Virtual I/O node: a configurable list of WAGO 750/753 I/O modules forming the process image.
- **Scenario engine**: scripted macros that observe input states and act on outputs (the automation core).
- Deterministic simulation clock and scheduler.
- CLI runner with configuration files.
Out of scope (v1.0): HTTP/WBM web interface, FTP, SNMP, UDP variant of Modbus, DHCP/BootP provisioning, real-time behavior beyond response latency simulation, and module parameterization (mailbox traffic) beyond process data.
### 1.4 Definitions
| Term | Meaning |
|---|---|
| PII | Process Input Image — data the coupler presents to the master (from input modules) |
| PIO | Process Output Image — data the master writes (to output modules) |
| MBAP | Modbus Application Protocol header (7-byte TCP header) |
| FC | Modbus function code |
| FAT | Factory Acceptance Test |
| Scenario | A scripted, timed sequence of state changes and assertions |
| Macro | A reusable scripted action (e.g., "pulse output 3 for 500 ms") |
| Node | One simulated fieldbus coupler + its I/O module configuration |
| LTS | Long-Time Simulation — continuous (“soak”) mode running hours to weeks of simulated time |
| Dataset | A CSV time-series file driving designated input aliases in continuous mode |
| Wear counter | Engine-maintained per-alias statistic: runtime hours, edge counts, load-hours |
| Degradation model | Time/cycle-dependent transformation of a plant parameter (e.g., pump capacity) |
| Invariant | A condition that must hold every tick throughout a continuous run |
| Episode | One aggregated violation period of an invariant (bounded reporting) |
---
## 2. System Overview
```mermaid
flowchart LR
subgraph DUT["Devices under test"]
PLC["PLC 1..n\n(Modbus TCP masters)"]
end
subgraph SIM["Simulator (this system)"]
MB["Modbus TCP Server\n(asyncio, ≤ 15 connections)"]
DS["Datastore\n(WAGO word & bit maps)"]
NODE["Virtual WAGO Node\n(module list → process image)"]
SE["Scenario Engine\n(states → actions)"]
CLK["Deterministic Clock\n+ Scheduler"]
LOG["Event Log / Traces"]
end
PLC -- "MBAP frames (FC1..FC23)" --> MB
MB <--> DS
DS <--> NODE
SE -- "read inputs / write outputs" --> NODE
SE <--> CLK
NODE -- change events --> SE
LOG -.records.- MB
LOG -.records.- SE
```
The system is a pure **slave/server**: it never initiates Modbus traffic. Autonomy comes from the **scenario engine**, which drives the *field side* of the virtual node (as if sensors moved and actuators did something), while the PLC drives the *master side* through normal Modbus writes.
---
## 3. Functional Requirements — Modbus Server
### 3.1 Transport & connections
- FR-3.1.1: The server shall listen on TCP port 502 (configurable) and speak Modbus TCP with the standard MBAP header.
- FR-3.1.2: The server shall support **up to 15 concurrent Modbus TCP connections** (per 750-362 spec, §7.3). Connection number 16 shall be refused.
- FR-3.1.3: Idle connections shall be closed after the timeout configured in register `0x1030` (default 600 × 100 ms). Outputs remain in last state (§11.2.5.5).
- FR-3.1.4: Protocol identifier other than 0 in MBAP shall increment the `BadProtocol` statistic and be ignored.
- FR-3.1.5: Unit identifier shall be ignored (accepted, echoed back), as on the real device.
### 3.2 Function codes
The simulator shall implement exactly the 750-362 function set (§11.2.2) and reject all others with exception `0x01` (Illegal Function):
| FC | Function | Limits (R/W) |
|----|----------|--------------|
| 01 | Read Coils | 2000 / — |
| 02 | Read Discrete Inputs | 2000 / — |
| 03 | Read Holding Registers | 125 / — |
| 04 | Read Input Registers | 125 / — |
| 05 | Write Single Coil | — / 1 |
| 06 | Write Single Register | — / 1 |
| 11 | Get Comm Event Count | — |
| 15 | Write Multiple Coils | — / 800 |
| 16 | Write Multiple Registers | — / 100 |
| 22 | Mask Write Register | — / 1 |
| 23 | Read/Write Multiple Registers | 125 / 100 |
Exceeding limits → exception `0x03` (Illegal Data Value) and increment `TooManyRegisters` / `TooManyBits`.
Exception codes (§11.2.3): `0x01` Illegal Function, `0x02` Illegal Data Address, `0x03` Illegal Data Value, `0x04` Slave Device Failure, `0x05` Acknowledge, `0x06` Server Busy, `0x08` Memory Parity Error, `0x0A`/`0x0B` Gateway errors.
### 3.3 Register map (WAGO 750-362)
#### 3.3.1 Word (register) access — reading (FC3/FC4)
| Modbus address (hex) | Area | Behavior |
|---|---|---|
| `0x0000–0x00FF` | Physical input area (1), %IW0–255 | First 256 words of input process image |
| `0x0100–0x01FF` | — | Exception `0x02` |
| `0x0200–0x02FF` | Physical output area (1) | Read-back mirror of `%QW0–255` (outputs written at `0x0000` are readable here, +`0x0200` offset) |
| `0x0300–0x0FFF` | — | Exception `0x02` |
| `0x1000–0x2FFF` | Configuration registers | See §3.5 |
| `0x3000–0x5FFF` | — | Exception `0x02` |
| `0x6000–0x62FB` | Physical input area (2), %IW256–1020 | Additional 764 words |
| `0x62FC–0x6FFF` | — | Exception `0x02` |
| `0x7000–0x72FB` | Physical output area (2), %QW256–1020 | Read-back mirror, +`0x01000` offset from write area (2) |
| `0x72FC–0xFFFF` | — | Exception `0x02` |
#### 3.3.2 Word access — writing (FC6/FC16)
| Modbus address (hex) | Area |
|---|---|
| `0x0000–0x00FF` | Physical output area (1), %QW0–255 |
| `0x0100–0x01FF` | Exception `0x02` |
| `0x0200–0x02FF` | Physical output area (1) (mirror accepted for writing) |
| `0x0300–0x0FFF` | Exception `0x02` |
| `0x1000–0x2FFF` | Configuration registers |
| `0x3000–0x5FFF` | Exception `0x02` |
| `0x6000–0x62FB` | Physical output area (2), %QW256–1020 |
| `0x62FC–0x6FFF` | Exception `0x02` |
| `0x7000–0x72FB` | Physical output area (2) (mirror) |
| `0x72FC–0xFFFF` | Exception `0x02` |
#### 3.3.3 Bit (coil) access — reading (FC1/FC2) & writing (FC5/FC15)
| Modbus address (hex) | Area |
|---|---|
| `0x0000–0x01FF` | Physical input area (1) — first 512 digital inputs (read) |
| `0x0200–0x03FF` | Physical output area (1) — first 512 digital outputs (read mirror + write) |
| `0x0800–0x85F7` | Physical input area (2) — digital inputs 513–2039 |
| `0x9000–0x95F7` | Physical output area (2) — digital outputs 513–2039 |
| all others | Exception `0x02` |
**Digital channel addressing rule** (§11.2.4): bit addresses are **channel numbers** — digital input no. 47 has Modbus address 46 — independent of the physical module layout. Word-oriented modules are laid out first (in physical order, from word 0), then bit-oriented modules are byte-packed in physical order.
#### 3.3.4 Constant registers (FC3/FC4 read-only)
| Addr | Value | Purpose |
|---|---|---|
| `0x2000` | `0x0000` | Zero |
| `0x2001` | `0xFFFF` | Ones |
| `0x2002` | `0x1234` | Byte-order check (Intel format expected) |
| `0x2003` | `0xAAAAl` | Bit mask 1 |
| `0x2004` | `0x5555` | Bit mask 2 |
| `0x2005` | `0x7FFF` | Max positive |
| `0x2006` | `0x8000` | Max negative |
| `0x2007` | `0x3FFF` | Half positive |
| `0x2008` | `0x4000` | Half negative |
#### 3.3.5 Firmware-info registers (read-only)
`0x2010` revision, `0x2011` series code (`0x02EE` = 750), `0x2012` coupler code (`0x0166` = 362), `0x2013`/`0x2014` major/minor, `0x2020` description (16 words), `0x2021` compile time, `0x2022` compile date, `0x2023` firmware loader info, `0x2030–0x2033` connected-module descriptions, `0x2035` process-image settings, `0x2036` diagnostics, `0x2040` software reset (write `0x55AA`/`0xAA55`), `0x2043` factory settings.
### 3.4 Watchdog (§11.2.5.2/3)
The Modbus watchdog shall be simulated:
- `0x1000` timeout value ×100 ms (default `0x0064`), writable only when WD inactive.
- `0x1001`/`0x1002` coding masks (bit *n−1* of the mask = FC *n*; default `0xFFFF`).
- `0x1003` trigger (write nonzero, alternating values), `0x1005` stop (`0xAAAA` then `0x5555`), `0x1008` simple stop (`0xAA55`/`0x55AA`), `0x1007` restart (write `1`).
- `0x1006` status: `0` inactive / `1` active / `2` expired.
- `0x1009` close-socket-on-timeout, `0x100A` type Standard/Alternative, `0x100B` save WD params (remanent).
- On timeout: **all analog outputs → 0, all digital outputs → OFF**; in Standard mode further process-data access is answered with exception `0x04`; WD registers remain accessible; `0x1009 = 1` additionally closes TCP connections.
- Watchdog events shall be visible to the scenario engine (assertions: "WD triggered at t seconds").
### 3.5 Configuration & diagnostic registers
- `0x1022`–`0x1025`: image sizes (analog in/out bits, digital in/out bits) computed from the configured module list.
- `0x1029` (9 words) Modbus TCP statistics: SlaveDeviceFailure, BadProtocol, BadLength, BadFunction, BadAddress, BadData, TooManyRegisters, TooManyBits, ModTcpMessageCounter. Reset by writing `0xAA55`/`0x55AA`.
- `0x102A` active TCP connections.
- `0x1030` Modbus TCP idle timeout (×100 ms).
- `0x1031` MAC-ID (3 words, configurable to emulate a specific device).
- `0x1037` response delay (ms) — the simulator shall actually delay responses, to emulate link latency.
- `0x1038` TOS/DSCP (stored, echoed).
- `0x1050` module diagnosis (3 words): module, channel, diagnosis bits (underrange … collective failure) — scriptable fault injection.
- `0x1020`/`0x1021` LED error code/argument — scenario-settable.
- `0x102B` KBUS reset (write): re-reads the module configuration; process image rebuilt.
### 3.6 Fault injection
FR-3.6.1: The scenario engine shall be able to simulate fieldbus faults:
- KBUS/local-bus failure → `SlaveDeviceFailure` statistic increments and process-data reads may return exception `0x04`.
- Response-delay injection via `0x1037`.
- Connection refusal beyond 15 concurrent connections.
- Illegal-address probing (read/write the exception areas) → correct `0x02` responses (this validates master error handling).
### 3.7 Process data semantics
- Word mapping of digital channels follows the **Intel format** (§7.3.3): channel 1 = bit 0 of the low byte (`D0`), high byte `D1` on top.
- Digital word mapping: `Bit15 … Bit0` = `Byte D1 D0`, bit 0 = lowest channel number.
- Analog modules contribute 16-bit data words (control/status bytes not accessible via Modbus).
- A module list defines the image; the framework computes addresses and exposes them in the log and via module-description registers (`0x2030+`).
---
## 4. Functional Requirements — Scenario & Macro Engine
### 4.1 Concept
A **scenario** is a scripted, time-ordered description of the *world around the PLC*: sensor inputs change over time, actuators respond, and the script asserts on PLC outputs. This is what makes FAT autonomous — no operator needs to toggle signals.
- **State-based**: rules keyed on input/output conditions (`when input 5 turns ON …`).
- **Time-based**: scheduled events (`after 30 s …`).
- **Output actions**: `set`, `reset`, `pulse`, `toggle`, `ramp`, `hold_value` on simulated field outputs (what the PLC sees as its inputs).
- **Input observations**: the engine reads the process output image (what the PLC wrote) with edge detection (rising/falling).
- **Assertions**: expected PLC behavior (output X ON within 2 s after input Y) → FAT verdicts.
### 4.2 Scenario DSL (YAML)
```yaml
name: pump_station_fat
description: "Full FAT round for pump station PLC program"
node: config/pump_node.yaml # module configuration
timing: { tick: 10ms } # simulation tick
inputs: # named aliases → digital/analog inputs
start_button: { type: di, module: 0, channel: 0 } # DI0.0
level_sensor: { type: ai, module: 4, channel: 0, scale: 0.1 } # AI4.0, mm → counts
em_stop_ok: { type: di, module: 0, channel: 3 } # DI0.3
outputs: # named aliases → PLC outputs (read-only for script)
pump_relay: { type: do, module: 2, channel: 0 } # DO2.0
valve_open: { type: do, module: 2, channel: 1 } # DO2.1
speed_setpoint: { type: ao, module: 5, channel: 0, scale: 0.1 } # AO5.0
macros:
pulse_start: # reusable macro
- set: start_button = ON
- wait: 250ms
- set: start_button = OFF
states: # optional state machine
idle:
on: { em_stop_ok: ON }
goto: running
running:
entry: [ ramp: level_sensor, to: 8500, over: 30s ]
on: { pump_relay: RISING }
assert: { valve_open: ON, within: 2s }
goto: full
...
sequence: # time-ordered script (or fully state-driven)
- macro: pulse_start
- assert: { pump_relay: ON, within: 2s }
- wait: 5s
- set: level_sensor = 4200
- assert: { speed_setpoint: ">= 1000", within: 3s }
- report: PASS
```
### 4.3 Requirements
- FR-4.3.1: Scenarios shall be defined in YAML (no programming required for standard FAT sequences).
- FR-4.3.2: The engine shall support **state machines** (states, entry actions, transitions on input/output edges or conditions, guards).
- FR-4.3.3: The engine shall support **macros** (reusable action groups, parameterizable).
- FR-4.3.4: Actions on simulated inputs: `set`, `reset`, `pulse`, `toggle`, `ramp` (linear over time), `noise` (± band), `sin`, `hold`.
- FR-4.3.5: Assertions on PLC outputs with timeout → **PASS/FAIL/ERROR verdict per check**, with timestamps; final scenario verdict.
- FR-4.3.6: Assertions may compare against expected values, ranges, edges, and statistics registers (e.g., "watchdog expired").
- FR-4.3.7: All scenario events and all Modbus transactions shall be logged (CSV/JSONL) for the FAT report.
- FR-4.3.8: Python **action plugins** shall be supported for actions beyond the built-in set (registered callables), for complex plant-model physics.
- FR-4.3.9: The engine shall run **deterministically**: simulation time decoupled from wall-clock; results reproducible.
- FR-4.3.10: A scenario may load multiple nodes (multi-coupler FAT benches).
- FR-4.3.11: **Module-based channel addressing** — aliases may be addressed
as *module type + module number + channel number*, all components
explicitly configurable: `{ type: di, module: 2, channel: 1 }` selects
digital input channel 1 (0-based) on module slot 2 (0-based position in
the node's module list), i.e. `DI2.1`. Both numbers are 0-based to match
physical slot/channel labeling. The channel kind must match the module
(`DO` on a DI-only module is a validation error). This is an alternative
to pool addressing (`{ type: di, channel: 3 }` = 3rd DI channel across
the whole node); both schemes are supported and may be mixed, but an
alias uses exactly one. **Implemented** in v1.1 (`channel_by_module`,
`Alias.module`, `alias_location`).
### 4.4 Example FAT flow
1. PLC program boots, connects to simulator (Modbus TCP client), reads input image.
2. Scenario `pump_station_fat` starts in `idle` state; `em_stop_ok` turns ON.
3. Scenario pulses `start_button`; PLC sees input, starts pump relay (writes coil).
4. Engine detects rising edge on `pump_relay`, asserts `valve_open` within 2 s.
5. Scenario ramps `level_sensor` to 8500 counts; PLC adjusts `speed_setpoint`; assertions verify.
6. Scenario injects fault (KBUS error / watchdog timeout); PLC must react (e.g., switch to safe state) — asserted.
7. Report generated: verdicts, timing, Modbus trace.
---
## 5. Architecture
### 5.1 Package layout
```
wago-modbustcp-simulator-py/
├── docs/ # WAGO manuals + this spec
│ └── spec/
│ ├── software-spec.md # this document
│ └── register-map.md # extracted WAGO tables
├── src/
│ └── wago_sim/
│ ├── __init__.py
│ ├── mbap.py # MBAP frame codec
│ ├── pdu.py # PDU codec (request/response/exception)
│ ├── server.py # asyncio Modbus TCP server
│ ├── datastore.py # WAGO word/coil datastores
│ │ # (holds process images + register bank)
│ ├── registers.py # config/diagnostic/constant/firmware regs
│ ├── modules.py # virtual WAGO I/O modules (module catalog)
│ ├── node.py # node = modules → process image mapping
│ ├── watchdog.py # Modbus watchdog simulation
│ ├── clock.py # deterministic clock + scheduler
│ ├── scenario/
│ │ ├── __init__.py
│ │ ├── engine.py # interpreter for scenario DSL
│ │ ├── dsl.py # YAML schema + validation
│ │ │ ├── actions.py # set/pulse/ramp/... + plugin registry
│ │ │ ├── assertions.py # condition matcher
│ │ │ ├── statemachine.py
│ │ └── report.py # verdicts, CSV/JSONL log
│ ├── stats.py # Modbus TCP statistics counters
│ ├── logging_setup.py
│ └── cli.py # entry point (argparse)
├── config/
│ └── pump_node.yaml # example node configuration
├── scenarios/
│ └── pump_station_fat.yaml # example scenario
├── tests/
│ ├── test_mbap.py
│ ├── test_pdu.py
│ ├── test_server.py
│ ├── test_datastore.py
│ ├── test_registers.py
│ � ├── test_watchdog.py
│ ├── test_scenario_engine.py
│ └── conftest.py
├── pyproject.toml
└── README.md
```
### 5.2 Design decisions
| Decision | Rationale |
|---|---|
| Pure-Python, asyncio TCP server | No external Modbus library needed; full control over WAGO-specific behavior (register-map exceptions, statistics, watchdog); testable |
| Deterministic clock | Reproducible FAT runs; scenario times independent of host speed |
| YAML DSL + plugin boundary | Test engineers write scenarios, not code; complex physics via Python plugins |
| Datastore split: process image vs. register bank | Mirrors real device: process image vs. internal registers; the readback-mirror rule (+0x0200) is dataflow, not copy-on-read |
| Statistics as first-class objects | FAT assertions can check communication health (`0x1029`) |
### 5.3 Threading & concurrency model
- asyncio event loop owns the Modbus server and the scheduler tick.
- Scenario engine runs in the same loop (coroutines), so image updates and Modbus reads see consistent data without locks.
- Response delay (`0x1037`) implemented as asyncio sleep before response send.
### 5.4 Data model
```mermaid
erDiagram
NodeConfig ||--|{ ModuleConfig : modules
NodeConfig ||--|| ProcessImage : builds
ModuleConfig ||--|{ Channel : channels
ProcessImage ||--|| InputImage : inputs
ProcessImage ||--|| OutputImage : outputs
OutputImage ||--|| InputMirror : "read-back at +0x200/+0x1000"
Scenario ||--|{ State : states
Scenario ||--|{ SequenceStep : sequence
State ||--|{ Action : entry
State ||--|{ Transition : on
SequenceStep ||--|{ Action : actions
Scenario ||--|{ Alias : "inputs/outputs aliases"
Scenario ||--|{ Macro : macros
Macro ||--|{ Action : steps
```
### 5.5 State chart — server session
```mermaid
stateDiagram-v2
[*] --> Listening
Listening --> Connected: TCP accept (≤15)
Connected --> RequestParsing: MBAP complete
RequestParsing --> Dispatching: PDU decoded
Dispatching --> Responding: result
Dispatching --> ExceptionResponse: FC/addr/val error
Responding --> Connected
ExceptionResponse --> Connected
RequestParsing --> Connected: malformed (count BadLength)
Connected --> Closed: idle timeout / WD 0x1009=1 / client close
```
---
## 6. Non-Functional Requirements
- NFR-6.1 Performance: sustain ≥ 500 requests/s with ≤ 10 ms added latency (typical FAT bench load).
- NFR-6.2 Portability: Python 3.11+ on Windows/Linux.
- NFR-6.3 Determinism: two runs of a scenario with identical inputs and timing produce identical verdict logs (except wall-clock stamps).
- NFR-6.4 Observability: every Modbus transaction is observable in
verbose mode (`-v` / `WAGO_SIM_VERBOSE`) as plain text — decoded
`REQ`/`RSP`/`EXC` lines with transaction id, unit, function name,
values, and the WAGO 750-362 register-map meaning of each address
(process areas with %IW/%QW mapping per Tables 119/120, read-back
mirrors, configuration/constant registers, illegal areas); malformed
frames are traced as `RAW` lines with PDU hex. *Implemented*
(`src/wago_sim/trace.py`).
- NFR-6.5 Robustness: malformed frames never crash the server; counted in
statistics. Invalid operator input — unknown commands or options,
non-numeric or out-of-range port numbers (CLI or
`WAGO_SIM_PORT`), missing/unparsable node or scenario files,
unwritable report paths — yields a one-line
`wago-sim: error: …` on stderr and exit code 2, never a Python
traceback; missing report directories are auto-created; unexpected
internal errors keep the traceback (logged) for debugging.
*CLI part implemented* (tested in `tests/test_cli.py`).
- NFR-6.6 Configurability: port, bind address, node config, firmware identity registers, MAC-ID configurable without code changes.
---
## 7. Acceptance Criteria (framework phase)
1. A Modbus TCP master (e.g., `pymodbus` client in tests) can connect, read constants `0x2000–0x2008`, and gets exactly `0x0000, 0xFFFF, 0x1234, 0xAAAA, 0x5555, 0x0166, ...` per spec — byte order verified.
2. Reads/writes in all mapped/unmapped areas produce values/exceptions per §3.3 tables.
3. Digital channel addressing: a configured 16-channel DI module + 8-channel DO module yields channel-number bit addressing (DI 47 → bit address 46).
4. Output readback mirrors at +`0x0200` (word) and +`0x0200`/+`0x1000` (bit/word area 2) work.
5. A YAML scenario can set a digital input, detect a PLC output rising edge, and produce a PASS/FAIL report file.
6. Watchdog: configure `0x1000`, trigger via `0x1003`, let it expire, outputs clear, `0x1006` = 2, process access → exception `0x04`, restart via `0x1007`.
7. Statistics counters increment correctly on bad frames/addresses; reset via `0x1029` write `0xAA55`.
---
## 8. Feature Request — Long-Duration (Continuous) Simulation
> **Status: requested 2026-10-03, planned (roadmap phase 3), not yet implemented.**
> Extends the scenario engine (§4) with a `continuous` mode for endurance
> (“soak”) testing: the facility runs for hours, days or weeks of simulated
> time against evolving inputs and an aging plant.
### 8.1 Motivation
FAT rounds (§4) validate functional behavior over minutes. A facility must
additionally be proven **over long time**:
- **Endurance/soak**: memory leaks, counter rollovers, watchdog stability over days.
- **Trend & historian verification**: SCADA trends, averaging, alarm shelving.
- **Cycle behavior**: daily/weekly demand profiles, shift patterns, weekend idle.
- **Maintenance logic**: wear-based alarms, degradation detection, service recovery.
- **Clock edge cases**: midnight rollovers and (via absolute-timestamp datasets) DST transitions.
Long runs cannot be hand-scripted step by step. Two mechanisms replace the
scripted `sequence`, and both run **in parallel on the same sim-time axis**:
1. **Dataset playback** *(optional)* — designated inputs are driven from
time-series files (e.g., CSV exported from a plant historian, or
synthesized profiles).
2. **Signal logic** — plant-model plugins (FR-4.3.8) compute endogenous
signals and closed-loop physics in Python, scheduled sequence steps
(`at:`) perform discrete actions, and degradation models age plant
parameters (e.g., pump capacity) from wear, exercising the PLC’s
compensation and maintenance logic.
Datasets are **not required**: a continuous run may be driven entirely by
logic (plugin-generated demand profiles, scheduled events, degradation),
entirely by datasets, or by any mix — some aliases fed from CSV while
others are computed by logic, in the same run. The signal logic works
unchanged whether or not CSV streaming is used.
### 8.2 Requirements — continuous mode & CSV dataset playback
- FR-8.2.1: A scenario may declare `mode: continuous`. The `sequence` becomes
optional. The run continues until **all active drivers are exhausted**:
every dataset finished (or looped to its `repeat` limit), all scheduled
steps executed, and — if a top-level `until: <duration>` is given — the
duration elapsed. With no datasets, `until:` is the primary run-length
control. A continuous scenario with neither datasets, nor scheduled
steps, nor `until:` is a validation error (it would run forever).
- FR-8.2.2: Input aliases (di/ai) may be driven from **datasets**, which are
entirely **optional**. Output aliases must not appear in datasets
(validation error). Aliases not owned by a dataset are **logic-driven**:
computed by a plant-model plugin (FR-4.3.8, closed loop), set by
scheduled steps, or left at their current value. An endogenous process
variable (e.g., tank level) is computed by the plant model. **Exactly one
driver** may own an alias (dataset, plugin-declared variable, ramp,
pulse, or step) — conflicts are validation errors.
- FR-8.2.3: CSV format: a header row with the time column (default `time`,
configurable via `time_column`) plus one column per alias. Time is elapsed
seconds (default) or ISO-8601 absolute timestamps (`time_format: iso8601`).
Analog values are numeric; digital values accept `ON/OFF`, `1/0`,
`true/false`. Lines starting with `#` are comments.
- FR-8.2.4: Values pass through the alias `scale`/`offset` (engineering
units → raw counts), identical to `set:` semantics.
- FR-8.2.5: Between samples, analog aliases use per-dataset
`interpolation: linear | hold`; digital aliases always use `hold` (never
interpolated).
- FR-8.2.6: Datasets shall be **streamed with bounded memory** (one-row
lookahead); files covering months of samples must be playable without
loading them into RAM.
- FR-8.2.7: `repeat: n` loops a dataset seamlessly and monotonically (no time
jump at the seam); identical loops produce identical values (deterministic).
`until: <duration>` limits total playback.
- FR-8.2.8: `time_factor: k` (default 1.0) — dataset time advances k× faster
than sim-time, enabling a 28-day soak in a fraction of the wall-clock time.
- FR-8.2.9: Multiple datasets share one sim-time axis; each has an optional
`offset:` start shift. The engine merges them; an alias’s value is that of
its owning dataset.
- FR-8.2.10: **Pacing**: in server mode (real PLC connected) sim-time follows
wall-clock for real-time soak testing; headless runs are unpaced (as fast
as deterministic processing allows).
- FR-8.2.11: Malformed data (unknown column, unparsable time or value,
non-monotonic timestamps) ends the run with verdict **ERROR**, citing file
and line number.
- FR-8.2.12: **Parallel logic execution**: datasets and signal logic run in
the same deterministic tick pipeline, in a fixed order per tick:
(1) datasets apply their sampled/interpolated values to their aliases,
(2) alias edges are detected,
(3) plant-model plugins run — they may read dataset-driven AND
logic-driven aliases and compute endogenous variables in the same tick,
(4) degradation models update,
(5) invariants and rate limits evaluate,
(6) scheduled steps due at this tick execute,
(7) snapshots are taken if due.
This order is part of the specification and shall be covered by tests.
- FR-8.2.13: **Logic-only continuous runs**: with no `datasets:` block at
all, continuous mode shall work unchanged — demand profiles and other
exogenous signals may be generated *inside* plant-model plugins (e.g., a
daily sinusoid or a state-based profile computed in Python), with
top-level `until:` bounding the run. All §8.3–8.4 machinery (degradation,
invariants, rate limits, snapshots) applies identically, whether or not
any CSV streaming is configured.
### 8.3 Requirements — degradation models (plant logic over time)
- FR-8.3.1: The engine maintains **wear counters** per alias, updated every
tick: runtime hours (integral of truth), edge counts (rising/falling), and
load-hours (integral of the analog value). Counters are deterministic state.
- FR-8.3.2: A **degradation model** computes a named plant parameter (e.g.
`pump_capacity`, range 0…1) from wear counters and sim-time. Built-in
shapes: `linear` (per runtime hour, per start cycle), `exponential`,
`staircase` (threshold table); custom shapes via Python plugin.
- FR-8.3.3: Plant-model plugins read degraded parameters via
`engine.parameter("pump_capacity")` and shall use them in the loop physics
(e.g., actual pump flow = nominal flow × capacity), so the PLC must
compensate (e.g., speed creep) — the behavior under test.
- FR-8.3.4: `maintenance_at:` threshold crossing fires configured actions:
fault injection (module diagnosis, register `0x1050`), assertions
(maintenance alarm within a time bound), and logging. A
`maintenance_reset` restores the parameter to 1.0 and clears injected
diagnoses — verifying the PLC’s alarm clearing and performance recovery.
- FR-8.3.5: `floor:`/`ceiling:` clamp the parameter so the simulated plant
stays physically plausible.
- FR-8.3.6: Models must be pure functions of (sim-time, wear counters,
configuration) — no wall-clock — keeping runs reproducible.
- FR-8.3.7: Degradation state and wear counters are included in snapshots
(§8.4) and the final report.
- FR-8.3.8: New DSL actions introduced: `inject_diagnosis` / `clear_diagnosis`
(module-diagnosis fault injection) and `maintenance_reset`.
### 8.4 Requirements — continuous assertions & monitoring
- FR-8.4.1: `invariants:` — conditions evaluated **every tick** (analog
bounds, expected digital states). Violations are recorded as **episodes**
(start, end, extreme value, tick count) so a 30-day run yields a bounded
report, not a per-tick flood.
- FR-8.4.2: `rate_limits:` — maximum rising/falling edges per sliding hour
window per alias (e.g., pump starts ≤ 6/h), verifying anti-cycling logic.
- FR-8.4.3: `report.snapshot_every: <duration>` — periodic dumps of all
alias values, wear counters, and degradation parameters (CSV/JSONL) for
trend/historian verification.
- FR-8.4.4: Continuous-mode verdict: **PASS** iff the dataset schedule
completed, no invariant episode occurred, no rate limit was exceeded, and
no assertion failed; otherwise FAIL/ERROR accordingly.
- FR-8.4.5: Report and log sizes shall be bounded independent of run length
(episode aggregation + ring-buffered recent events).
- FR-8.4.6: A limited `sequence:` may still be used in continuous mode;
steps may be scheduled via `at: <sim duration>` (e.g., a service visit at
day 12). Scheduled steps and datasets interleave on the same sim-time axis.
### 8.5 Example — 28-day pump-station soak
Scenario (YAML):
```yaml
name: pump_station_soak_28d
description: "28-day soak: demand/operator playback, closed-loop plant with pump degradation"
mode: continuous
timing: { tick: 100ms }
inputs:
start_button: { type: di, channel: 1 } # exogenous (operator, CSV)
em_stop_ok: { type: di, channel: 2 } # exogenous (CSV)
inflow_valve: { type: di, channel: 3 } # exogenous (CSV)
inflow_rate: { type: ai, channel: 2, scale: 0.01 } # exogenous (CSV), m³/h → counts
level_sensor: { type: ai, channel: 1, scale: 0.1 } # endogenous (plant model computes)
outputs:
pump_relay: { type: do, channel: 1 }
valve_open: { type: do, channel: 2 }
speed_setpoint: { type: ao, channel: 1, scale: 0.1 }
maintenance_alarm: { type: do, channel: 3 }
datasets:
demand_week: # exogenous demand profile
file: data/demand_week.csv
aliases: [inflow_rate]
interpolation: linear
repeat: 4 # 7-day profile × 4 = 28 sim-days
operator_week: # operator actions
file: data/operator_week.csv
aliases: [start_button, em_stop_ok, inflow_valve]
interpolation: hold
repeat: 4
degradation:
pump_wear:
model: linear
parameter: pump_capacity # consumed by the plant model
per_runtime_hour: 0.0004 # 0.04 %/h → ~10 % after ~10.4 sim-days
per_start_cycle: 0.0002 # 0.02 % per start
floor: 0.80
maintenance_at: 0.90
on_threshold: # fires when capacity crosses 90 %
- inject_diagnosis: { module: 4, channel: 1, bits: [user_overrange] }
- assert: { label: "maintenance alarm raised",
conditions: { maintenance_alarm: ON }, within: 60s }
on_reset: # after maintenance_reset
- clear_diagnosis: { module: 4 }
sequence: # scheduled events (sim-time)
- at: 12d
maintenance_reset: pump_wear # service visit: capacity restored
plugins: [plugins.pump_plant.pump_plant] # loop: d(level)/dt = inflow − pump_flow(speed, capacity)
invariants:
- { alias: level_sensor, min: 200, max: 9000, label: "tank level bounds" }
rate_limits:
- { alias: pump_relay, edge: rising, max_per_hour: 6, label: "pump start cycles" }
report:
snapshot_every: 1h
aggregate: episodes
```
Datasets (CSV):
```csv
# data/demand_week.csv (elapsed seconds, inflow in m³/h)
time,inflow_rate
0,12.0
3600,12.4
7200,13.1
...
604800,12.0 # week boundary — loops seamlessly with repeat: 4
```
```csv
# data/operator_week.csv
time,start_button,em_stop_ok,inflow_valve
0,OFF,ON,OFF
28800,ON,ON,ON # Mon 08:00: shift start, valve opened
28810,OFF,ON,ON
432000,OFF,OFF,ON # Sat 00:00: weekend e-stop drill
440000,OFF,ON,ON
```
**Variant without datasets** (logic-only, same soak): drop the
`datasets:` block entirely, keep everything else, and let the plant
plugin *also* generate the demand signal each tick:
```yaml
name: pump_station_soak_28d_logic
mode: continuous
until: 28d # no datasets: until bounds the run
inputs: ... # same aliases — none dataset-owned
outputs: ...
plugins: [plugins.pump_plant.pump_plant]
# plugin computes, per tick (FR-8.2.12 step 3):
# inflow_rate = 12 + 3*sin(2*pi*t/24h)
# d(level)/dt = inflow - pump_flow(speed, capacity)
degradation: ... # identical to the dataset version
invariants: ...
rate_limits: ...
report: { snapshot_every: 1h }
```
Both variants test the same PLC behavior; the CSV version replays recorded
demand, the logic version synthesizes it. Mixing is equally valid — e.g.
`inflow_rate` from a dataset while `start_button` is toggled by scheduled
`at:` steps, with the plant plugin reading both (FR-8.2.12).
### 8.6 Architecture impact (planned modules)
```
src/wago_sim/scenario/
├── datasets.py # CsvDataset: streaming reader, interpolation, repeat,
│ # time_factor — OPTIONAL driver in continuous mode
├── degradation.py # WearCounters, DegradationModel registry, maintenance actions
├── invariants.py # invariant & rate-limit evaluation, episode aggregation
└── engine.py # mode: continuous, parameter() accessor, snapshot
# scheduler; fixed tick pipeline per FR-8.2.12:
# datasets -> edges -> plugins -> degradation ->
# invariants -> scheduled steps -> snapshots
```
```mermaid
erDiagram
ContinuousScenario ||--o{ Dataset : "datasets (optional)"
Dataset ||--|{ ColumnMapping : "alias columns"
ContinuousScenario ||--|{ PlantPlugin : "signal logic (always runs)"
ContinuousScenario ||--|{ DegradationModel : degradation
DegradationModel ||--|{ WearCounter : counts
WearCounter }o--|| Alias : observes
ContinuousScenario ||--|{ Invariant : invariants
Invariant ||--o{ Episode : violations
ContinuousScenario ||--|{ RateLimit : rate_limits
ContinuousScenario ||--o{ Snapshot : "periodic dumps"
PlantPlugin }o--|| DegradationModel : "reads parameters"
PlantPlugin }o--o{ Alias : "reads dataset-driven + computes logic-driven"
```
### 8.7 Non-functional requirements (long-duration)
- NFR-8.7.1: Memory O(1) in dataset length and run duration (streaming +
bounded caches).
- NFR-8.7.2: A 28-sim-day soak with two datasets completes headless in
≤ 1 hour wall-clock on a standard PC (budget to be confirmed after
implementation).
- NFR-8.7.3: Determinism incl. degradation: identical config + datasets
produce identical reports (except wall-clock stamps).
- NFR-8.7.4: Robustness: host sleep/hibernate or GC pauses never distort
sim-time or dataset position (sim-time accumulates by tick, never from
wall-clock deltas — except when explicitly paced per FR-8.2.10).
- NFR-8.7.5: Report and log artifacts stay bounded (FR-8.4.5 mechanism),
enabling unattended multi-day runs.
### 8.8 Acceptance criteria (long-duration simulation)
1. A ≥ 100,000-row CSV streams with constant memory; alias values at
arbitrary ticks match the expected interpolation exactly.
2. `linear` interpolation for analog and `hold` for digital verified against
hand-computed samples; digital aliases are never interpolated.
3. `repeat: 4` of a 7-day dataset produces four identical per-loop value
sequences (seamless seam, deterministic).
4. `time_factor: 10` consumes a 7-day dataset in 16.8 h of sim-time.
5. An alias-ownership conflict (dataset + ramp on one alias) is rejected at
validation time.
6. Degradation: `pump_capacity` declines per the configured formula; crossing
`maintenance_at` injects the configured diagnosis and the maintenance-alarm
assertion passes; `maintenance_reset` restores capacity to 1.0 and clears
the diagnosis.
7. An invariant breach is recorded as one episode (start, end, peak)
regardless of duration; a rate-limit breach fails the run.
8. A full 28-sim-day soak completes headless with a bounded report and a
correct PASS/FAIL/ERROR verdict.
9. A continuous run with **no datasets** (plugin-generated demand,
top-level `until:`) completes with degradation, invariants, rate limits
and snapshots all active, and a correct verdict (FR-8.2.13).
10. A **mixed run** — one alias driven by a dataset, another computed by a
plugin in the same run — is supported; the plugin observes the dataset
value applied earlier in the same tick (FR-8.2.12 order), verified by a
tick-order test.
11. A continuous scenario with no datasets, no scheduled steps, and no
`until:` is rejected at validation time (FR-8.2.1).
---
## 9. Feature Request — Bench Mode (Run Without a Scenario)
> **Status: requested 2026-10-03, planned (roadmap phase 2), partially
> available today.** `wago-sim serve --node <config>` already runs the
> Modbus TCP server with a static process image and no scenario engine;
> this section formalizes the mode and its supporting tooling.
### 9.1 Motivation
Early commissioning and interactive troubleshooting do not follow a
script: an engineer wants the virtual node up, connected to the PLC/SCADA,
and then *manually* observe and tweak values — the way one would probe a
real node with a screwdriver and a multimeter. Requiring a scenario file
for this is friction; a syntax error in an unrelated YAML file should not
block a bench session.
Bench mode is also the foundation for manual FAT exploration and for the
web UI (§10), which needs a long-lived node whose I/O can be manipulated
at runtime without a scenario driving it.
### 9.2 Requirements
- FR-9.2.1: `wago-sim serve --node <config>` shall start the Modbus TCP
server with **no scenario** attached: the process image starts at
defined defaults (all-zero), the watchdog/statistics behave normally,
and the server runs until terminated. *(Available today.)*
- FR-9.2.2: `--set alias=value` CLI options (repeatable) shall seed input
values at startup, e.g. `--set DI0.1=ON --set AI4.0=1234`, addressing
channels by the module notation of FR-4.3.11. Optional in v1: a small
startup-commands file for larger value sets.
- FR-9.2.3: An interactive **console monitor** (`--monitor`): periodically
prints a compact table of all I/O channels (module, type, address,
value) and highlights changes, so a bench session is observable in a
terminal without any UI.
- FR-9.2.4: Runtime manipulation via the console monitor: simple commands
(`set DI0.1 ON`, `pulse DO2.0 500ms`, `watch 0x1029`, `reset stats`) —
the same operations the web UI (§10) offers, over stdio.
- FR-9.2.5: `serve` shall be combinable with a scenario later (`--scenario`
becomes optional); running with neither scenario nor monitor prints a
hint that the node is passive and values can only be changed by a master
or via the web UI.
- FR-9.2.6: The console monitor and web UI (§10) must never violate device
semantics — manipulations go through the same datastore paths as Modbus
writes, so read-back mirrors, watchdog kicks, and statistics stay
consistent.
### 9.3 Acceptance criteria
1. `wago-sim serve --node config/pump_node.yaml` starts with no scenario
file present and serves a static image; a pymodbus client can read and
write process data.
2. `--set DI0.1=ON` seeds the corresponding input bit; a master reading
bit address 0 sees ON.
3. The console monitor shows the full channel table and flags value
changes within one refresh interval.
4. Console `set`/`pulse`/`watch` commands affect the image immediately and
are visible to a connected master in the same way Modbus writes are.
---
## 10. Feature Request — Web UI (I/O Overview & Manipulation)
> **Status: in implementation (2026-10-03).** The implementation
> decisions below are binding; milestones M1–M4 are implemented and
> verified (M5 configuration and M6 scenario view remain). A browser
> front-end overlooking and manipulating the simulated node's I/O
> values and states at runtime.
### 10.1 Motivation
Terminal monitors (§9) suffice for single engineers, but FAT benches are
collaborative: commissioning engineers, PLC programmers, and test
witnesses need a **shared, visual** view of the virtual I/O — the
equivalent of watching the LEDs on a real WAGO node, plus the ability to
force signals the way WAGO's own WBM does on real devices. A web UI keeps
the simulator usable from any machine on the bench network without
installing tools.
### 10.2 Requirements
- FR-10.2.1: The simulator shall serve a web UI (default port 8080,
configurable) alongside the Modbus TCP server, with no external backend
dependencies beyond Python (FastAPI/Starlette + static files, or an
equivalent embedded approach — implementation choice, to be decided).
- FR-10.2.2: The UI shall present the **node overview**: module list with
article numbers, per-module channel tables (type, module, channel,
addressing `DI0.1` per FR-4.3.11, Modbus address, current value/state),
derived from the same process image the Modbus server serves. On the
node graphic (requested 2026-10-04, refined same day): each module
shows its kind+slot designation (`DI0`, `DO2`, `AI4`, …) in the
module header's upper right corner — same size and vertical
alignment as the article number — and the channel indicators carry
only the 0-based channel number: below each DI/DO LED, and on analog
modules outside the indicator stack, above the overflow and below
the underflow LED (FR-10.2.14) so the limit LEDs stay adjacent to the
bar.
- FR-10.2.3: Values shall update live (WebSocket push or short-polling),
including changes made by the PLC/master over Modbus, by scenarios, by
the console monitor, or by other UI clients — one shared source of truth.
- FR-10.2.4: **Manipulation**: clicking a DI/DO toggles it; an AI/AO gets a
numeric input (engineering units, using alias scale/offset when
defined); `pulse` (with duration) and `ramp` (target + duration) actions
on DI/DO/AI. All writes go through the datastore's Modbus-equivalent
paths (FR-9.2.6) so mirrors, watchdog, and statistics stay consistent.
- FR-10.2.5: **Diagnostics view**: watchdog status (0x1006), statistics
block (0x1029, with reset button), module diagnosis (0x1050), LED error
registers (0x1020/0x1021) — mirroring WBM pages of the real device —
with controls to inject/clear faults for FAT fault cases.
- FR-10.2.6: A scenario/run view: which scenario (if any) is loaded,
current step, verdicts so far, and basic controls (start/abort). Headless
result reports (JSONL/CSV) remain downloadable via the CLI.
- FR-10.2.7: Multi-client consistency: several simultaneous browser
clients see the same state; forced values propagate to all clients and
to the Modbus side within one refresh interval.
- FR-10.2.8: Access control for shared benches: optional read-only mode
and (v2) token-based login, so witnesses can observe without accident
rights.
- FR-10.2.9: The UI must not disturb deterministic scenario runs: when a
scenario owns an alias, UI writes to it are rejected with a clear
message (alias-ownership rules of §8 FR-8.2.2 apply).
- FR-10.2.10: The UI's **configuration** pages (Ethernet/port settings,
Modbus watchdog settings, I/O Config with SAVE CONFIG, Modbus Mapping
overview, address-selection DIP switch) are specified in §11; §10 covers
value/state oversight and manipulation.
- FR-10.2.11: **Channel names** (requested 2026-10-03): every module
channel can carry an application-specific name (e.g. `start_button`,
`tank_level`). Names live in the node configuration — not in the
module catalog (catalog articles are shared per type) — persisted
alongside the module list, and are re-applied after process-image
rebuilds (§11). Unnamed channels fall back to the generic channel
label (e.g. `DI 1`). Names surface everywhere the UI lists a channel:
node overview, the channel list, the configuration editor, and
tooltips.
- FR-10.2.12: **Import/export of module configuration** (requested
2026-10-03): the complete module configuration — module list with
slot order, per-channel names, and coupler settings — can be
downloaded as a single JSON file and imported back. Import validates
the payload (known articles, ≤ 64 modules, name shapes) and applies
it atomically: module rebuild + channel names + coupler settings in
one step, with the same persistence behavior as SAVE CONFIG (§11).
Export/import makes FAT benches shareable and reproducible between
engineers and benches.
- FR-10.2.13: **Split overview** (requested 2026-10-03): the overview
page is divided in two — the realistic node graphic (DIN rail with
LEDs, FR-10.2.2) on top, and below it a scrollable **channel list**:
one row per channel with the I/O code (`DI0.0`), the configured
channel name (if any), and a control for the value — a switch for
DI/DO and a numeric field for AI/AO — which both displays the live
value and manipulates it (same semantics as FR-10.2.4: DI = field
side, DO/AO = master-side force through internal paths). The list
updates live like the rail (FR-10.2.3) and is the primary working
surface for benches with many channels.
- FR-10.2.14: **Analog display ranges** (requested 2026-10-04): the
analog bar indicators (rail + list) scale a channel's raw value
against a display range. The default range is 0–4095 counts
(100 % at 4095, matching the WAGO 750 analog modules' 12-bit
scaling); per-channel `min`/`max` can be configured in the node
configuration — used by both AI (generated) and AO (used) values.
Values within the range fill the bar 0–100 % from the bottom.
Underflow/overflow are indicated: the bar fills in the limit color
(red) and a small red LED above the bar lights for overflow, below
the bar for underflow, clearing when the value returns to range.
### 10.3 Implementation decisions (binding, 2026-10-03)
- **Framework**: FastAPI (Starlette) + Uvicorn, embedded **in-process on
the same asyncio event loop** as the Modbus TCP server — a UI write and
a Modbus request never race on the datastore.
- **Front-end**: single-page vanilla HTML/CSS/JS (no build step) under
`src/wago_sim/webui/static/`; realistic node graphics (DIN-rail strip,
module bezels, glowing per-channel LEDs for DI/DO, bar/numeric
indicators for AI/AO) drawn with inline SVG/CSS.
- **Authentication** (user request 2026-10-03): session-cookie login with
in-memory token store; default credentials `admin` / `simu`,
overridable via env (`WAGO_SIM_WEB_USER` / `WAGO_SIM_WEB_PASSWORD`);
optional read-only mode via `WAGO_SIM_WEB_READONLY`. No third-party
auth/session dependencies (`hashlib.scrypt` hashing; sessions need not
survive restarts on a bench).
- **Backend data access** (user request 2026-10-03): the API **never uses
Modbus TCP**. It reads via the internal memory handler
(`WagoDatastore` getters, `VirtualNode` channel model,
`ConfigRegisterBank`, `ModbusWatchdog`, `ModbusStatistics`) and writes
via internal paths chosen by role: simulated-field writes go through
`set_digital_input_channel()` / `set_input_word()` (as the scenario
engine does); master-side forcing goes through the datastore's
internal `write_bits()` / `write_words()` dispatch (mirrors, watchdog
kicks, and statistics stay consistent, FR-9.2.6 semantics) — but
always as direct in-process calls, never via TCP.
- **Live updates**: WebSocket push (`/api/ws`) — datastore
`add_listener` for output changes plus a 10 Hz snapshot-difference
sweep for input-side changes.
- **Wiring**: `serve` starts the web UI by default (`--no-web` to
disable; `--web-port`, env `WAGO_SIM_WEB_PORT`; default port 8080).
### 10.4 Architecture sketch (as-built)
```
src/wago_sim/
├── webui/
│ ├── __init__.py
│ ├── app.py # build_webui(app, ...) -> FastAPI; API + static
│ ├── auth.py # login/logout, scrypt hash, session dependency
│ ├── state.py # snapshot builder + change detection
│ ├── api.py # REST + WebSocket routes
│ └── static/ # single-page front-end (no build step)
```
### 10.5 API surface (as-built)
| Method/Route | Purpose | FR |
|---|---|---|
| `POST /api/auth/login`, `POST /api/auth/logout` | session cookie (`HttpOnly`) | auth |
| `GET /api/node` | module list, channel tables with `DI0.1`-style and Modbus addresses | 10.2.2 |
| `GET /api/values` | full channel-value snapshot | 10.2.3 |
| `WS /api/ws` | live value/state deltas | 10.2.3, 10.2.7 |
| `POST /api/channels/{kind}/{module}/{channel}` | set/toggle/pulse/ramp a channel (field side) | 10.2.4 |
| `POST /api/force` | master-side force at a Modbus address (internal dispatch) | 10.2.4 |
| `GET /api/diagnostics` | watchdog status, statistics, module diagnosis, LED errors | 10.2.5 |
| `POST /api/diagnostics/reset-stats` | statistics reset (0x1029 semantics) | 10.2.5 |
| `POST /api/diagnostics/inject-fault`, `POST /api/diagnostics/clear-fault` | LED / module-diagnosis injection | 10.2.5 |
| `GET/PUT /api/config/coupler` | watchdog settings, response delay, idle timeout, MAC-ID | §11 subset |
| `GET/PUT /api/config/modules` | module list CRUD + process-image rebuild | §11 FR-11.2.1–11.2.4 |
| `GET /api/config/export` · `POST /api/config/import` | full configuration (modules + channel names + coupler) as JSON | 10.2.12 |
| `GET/PUT /api/config/channel-names` | read/write per-channel names | 10.2.11 |
### 10.6 Milestones
| # | Deliverable |
|---|---|
| M1 | WebUI skeleton, in-process FastAPI, CLI/env wiring, authentication (`admin`/`simu`), `GET /api/node` |
| M2 | Node overview page — DIN-rail graphics, LEDs, analog indicators, live values (WS) |
| M3 | Manipulation — toggle/pulse/ramp, AI engineering units, master-side force |
| M4 | Diagnostics — watchdog/statistics/diagnosis view, reset, fault injection |
| M5 | Configuration — coupler forms; module CRUD with image rebuild and persistence |
| M5a | Channel names (FR-10.2.11) + configuration import/export (FR-10.2.12) + split overview with channel list (FR-10.2.13) |
| M6 | (deferred) Scenario run view (FR-10.2.6) + alias-ownership rejection (FR-10.2.9) |
### 10.7 Acceptance criteria
1. Opening the UI shows the node's module list and channel table exactly
as configured, with `DI0.1`-style addresses and Modbus addresses.
2. Toggling a DO in the browser is immediately visible to a connected
Modbus master (and vice versa: a master write appears in the UI within
one refresh interval).
3. Forcing an AI value uses engineering units and the configured scale.
4. The diagnostics page shows live watchdog status and statistics; the
reset button clears the counters (register 0x1029 semantics).
5. With a running scenario, writes to scenario-owned aliases are rejected
with a visible message; the scenario continues unaffected.
6. Two simultaneous browsers stay consistent with each other and with
the Modbus image.
---
## 11. Feature Request — Runtime Device Configuration (WBM/DIP-Switch Parity)
> **Status: requested 2026-10-03, planned (roadmap phase 5), not yet
> implemented.** Mirrors the real 750-362's configuration mechanisms so
> the FAT bench can commission the node the way a real one is
> commissioned.
### 11.1 Motivation
On the real coupler the *node* is configured through two mechanisms
(manual §4.4.2, §8.3, §9.11–9.18):
1. **Web-Based Management (WBM)** pages: Ethernet/port settings (IP
address, subnet, gateway, DHCP/BootP/EEPROM source, base IP), Modbus
settings (connection idle timeout, Modbus watchdog type/timeout/trigger
masks, keepalive), Modbus Mapping (per-module register/coil address map,
read-only), and I/O Config (connected module list + **SAVE CONFIG**,
which re-reads the terminal bus).
2. **Address-selection DIP switch** (8-pole): binary host ID 1–254 = last
IP octet (base network 192.168.1.0/24 in delivery state); 0 = use the
stored WBM port configuration; 255 = DHCP. Changing it requires a
restart to take effect.
The I/O modules themselves are **not** configured via WBM or DIP switch —
they are physically plugged/unplugged, and the coupler re-reads the
terminal bus (SAVE CONFIG / KBUS reset, register `0x102B`), rebuilding
the process image. The simulator equivalent of "plugging" a module is a
**runtime module-list change with image rebuild**.
Today the simulator freezes all of this at startup: module list from
YAML, watchdog only via Modbus registers, no DIP switch, no WBM pages.
This feature request closes that gap.
### 11.2 Requirements — runtime module configuration ("plug/unplug")
- FR-11.2.1: The node's module list shall be **mutable at runtime** via
the web UI (§10), the console monitor (§9), and a programmatic API:
insert/remove modules at slot positions, replicating physical
plug/unplug on the local bus.
- FR-11.2.2: After every change the process image is **rebuilt exactly
like the real device after a KBUS reset** (`0x102B` or WBM SAVE
CONFIG): word modules first in physical order, then bit modules
byte-packed (§7.3.2.1); image sizes updated in `0x1022–0x1025`;
module descriptions updated in `0x2030+` so a polling master sees the
new configuration.
- FR-11.2.3: Scenario aliases are **revalidated** after a rebuild.
Module-based addresses (FR-4.3.11) are position-stable — `DI2.1` stays
on module 2 when earlier slots change — while pool-based indexes may
shift; aliases orphaned by a removal are reported as configuration
errors. This difference shall be documented and covered by tests.
- FR-11.2.4: While a scenario is running, module reconfiguration is
rejected with a clear message (consistent with FR-10.2.9) — deterministic
runs must not be disturbed. In bench mode (§9) it is always allowed.
- FR-11.2.5: A reconfiguration event is logged (sim-time, change
description, new image sizes) and visible in the web UI's I/O Config
page, mirroring the WBM SAVE CONFIG flow.
### 11.3 Requirements — address-selection DIP switch emulation
- FR-11.3.1: An 8-pole address-selection switch shall be emulated per node
with the manual's exact coding (§4.4.2/§8.3.1): DIP 1 = LSB … DIP 8 =
MSB; **1–254** = host ID appended to the base network (default
192.168.1.0/24, changeable); **0** = use the stored port configuration
(WBM port settings); **255** = DHCP (virtual: an address from the
configured DHCP pool is reported).
- FR-11.3.2: The switch shall be visual and operable in the web UI
(toggle poles) and settable in the console/config; the **derived virtual
IP address** is displayed and introspectable via the API.
- FR-11.3.3: **Apply semantics match the device**: a switch change marks
the node "restart required"; on apply (explicit restart command or
`serve` restart) the simulator rebinds its listen address to the
derived IP when running in dedicated-IP mode, or updates the virtual
IP model in shared-IP mode. Outputs and connections behave like a
device restart.
- FR-11.3.4: The stored port configuration (base network, static IP for
switch position 0, DHCP pool) is part of the device state (FR-11.5.1).
### 11.4 Requirements — WBM-equivalent configuration pages (web UI)
- FR-11.4.1: **Port/Ethernet page**: virtual IP settings — configuration
source (static/EEPROM, DHCP, BootP), IP/subnet/gateway, base network for
DIP addressing — persisted as device state and reported alongside the
DIP-derived address.
- FR-11.4.2: **Modbus settings page**: connection idle timeout
(`0x1030`), Modbus watchdog fields (type Standard/Alternative, timeout,
trigger masks FC1–16/FC17–32, close-socket-on-timeout) — the same
fields the real WBM exposes (manual §9.16), mapped 1:1 onto registers
`0x1000–0x100B`. Writes obey the register rules (e.g., timeout not
changeable while the watchdog is active).
- FR-11.4.3: **Modbus Mapping page**: per-module address map generated
from the node layout — word addresses (input RO, output WO, read-back
RW) and bit addresses (input, output, read-back) in the WBM's
`<SR>[.<SO>]`–`<ER>[.<EO>]` notation (manual §9.17), plus the general
area overview of §3.3. Read-only; auto-updates after FR-11.2.2 rebuilds.
- FR-11.4.4: **I/O Config page**: module list (position, article, type,
channel count) + **SAVE CONFIG** button = re-read/rebuild per
FR-11.2.2, mirroring WBM §9.18.
### 11.5 Requirements — persistence & reset
- FR-11.5.1: Device state (module list changes, DIP switch position,
port configuration, watchdog settings written as remanent per `0x100B`)
persists to a device-state file and is reloaded on start — the
simulator's equivalent of the device's EEPROM.
- FR-11.5.2: **Factory reset** (register `0x2043` write, CLI flag, web UI
button): restores delivery state — module list from the node YAML, DIP
switch to 0, watchdog defaults, cleared statistics — and persists that
as the new device state.
### 11.6 Acceptance criteria
1. Removing a module and issuing SAVE CONFIG (or `0x102B`) rebuilds the
image: `0x1022–0x1025` and `0x2030+` reflect the change and a pymodbus
master reading process data sees the new layout.
2. Inserting a module before existing ones leaves module-addressed
aliases (`DI2.1`) pointing at the same module, while pool-indexed
aliases shift by the inserted channel count — asserted by tests.
3. Aliases orphaned by a module removal are reported as errors, not
silently misaddressed.
4. Reconfiguration during a running scenario is rejected; in bench mode
it succeeds and is logged.
5. DIP switch 00001001 (33) derives virtual IP 192.168.1.33; switch 0
shows the stored port configuration; 255 reports a DHCP-pool address;
changing the switch shows "restart required" until applied.
6. Watchdog fields edited on the web settings page behave identically to
register writes (same validation, same statistics).
7. Device state survives a simulator restart; factory reset restores
delivery defaults.
---
## 12. Deployment — Service / Containerized Operation
> **Status: implemented 2026-10-03 (v1.3).** The simulator can run as a
> long-lived service: docker/compose container, or a systemd unit on bare
> metal/VM. Verified: image builds, container answers Modbus requests,
> and `docker stop` (SIGTERM) shuts down gracefully in < 1 s.
### 12.1 Motivation
A FAT bench simulator is infrastructure: it must start on boot, survive
restarts, log to the platform's log pipeline, and stop cleanly so a
connected PLC sees a controlled fieldbus-node failure rather than a
> hung socket — the exact condition watchdog/FAT fault cases test.
### 12.2 Requirements (all implemented)
- FR-12.2.1: **Environment-variable configuration**: `WAGO_SIM_NODE`,
`WAGO_SIM_HOST`, `WAGO_SIM_PORT` configure `serve` without CLI arguments
(container `CMD ["wago-sim", "serve"]` needs zero arguments).
Explicit CLI arguments override environment values.
- FR-12.2.2: **Graceful shutdown**: on SIGINT/SIGTERM (docker stop,
systemctl stop) the server closes all client connections and the
listening socket via `server.stop()` before exiting — no forced kill,
no lingering sockets. Verified: `docker stop` completes in 0.34 s.
- FR-12.2.3: **Container image** (`Dockerfile`): `python:3.12-slim`,
non-root `wago-sim` user, unbuffered stdout logging, baked-in example
config for out-of-the-box startup, TCP health check, ~197 MB image.
- FR-12.2.4: **Compose service** (`docker-compose.yml`): port mapping
(5020→502 default; 502 needs privileges on some hosts), read-only
mounts for config/scenarios/plugins, writable `out/` for reports,
resource limits, log rotation, `restart: unless-stopped`.
- FR-12.2.5: **systemd unit** (`deploy/wago-sim.service`): dedicated
service user, env-var configuration, `Restart=on-failure`, hardened
(NoNewPrivileges, ProtectSystem, CAP_NET_BIND_SERVICE for port 502
without root).
- FR-12.2.6: Logs to stdout/stderr only (docker json-file / journald
collect them); single-line format, no ANSI colors.
### 12.3 Acceptance criteria (all verified)
1. `docker build -t wago-sim .` succeeds; image ~197 MB.
2. `docker run -p <host>:502 -e WAGO_SIM_NODE=... wago-sim` serves
Modbus (constants `0x2000–0x2008` read correctly from the container).
3. `docker stop` completes in < 1 s with clean shutdown logs.
4. `docker compose up -d` starts a healthy service; `down` tears it down.
5. CLI tests cover env-var configuration and override precedence.
---
## 13. Feature Request — Multi-Vendor Device Profiles
> **Status: requested 2026-10-03, not yet planned into a roadmap phase.**
> Simulate additional Modbus TCP bus couplers — other WAGO 750 couplers
> and other brands' Modbus TCP I/O heads — reusing the existing protocol
> stack. This section captures the requirements; it does not commit to
> a schedule.
### 13.1 Motivation
FAT benches test more than one controller and fieldbus family. Today
the simulator is single-device: the 750-362 register map, limits,
watchdog, and statistics are hard-coded across the protocol stack
(`pdu.py` limits, `datastore.py` address dispatch, `server.py`
connection limit and process-area predicate, `registers.py`,
`watchdog.py`, `stats.py`, `trace.py` annotations). The transport layer
(`mbap.py`), PDU codecs, server skeleton, scenario engine, DSL,
plugins, and reporting are protocol-generic and directly reusable.
A second Modbus TCP device multiplies the value of the bench without a
protocol rewrite: the deltas between Modbus TCP devices are — address
maps, configuration areas, watchdog presence/semantics, statistics
blocks, connection limits, and module catalogs with image-layout
rules. These are exactly the facts the device manual provides.
### 13.2 Requirements
- FR-13.2.1: **Device profile abstraction**: extract the per-device
facts into a profile concept covering: supported function codes and
per-FC limits, max concurrent connections, idle-timeout behavior,
process-image address map (reads and writes), read-back mirror rules,
configuration/diagnostic register areas and semantics, watchdog
(presence, register layout, modes), statistics block layout, and
identity/description registers. The 750-362 becomes the first
profile; its behavior must be unchanged after the refactor (the
existing test suite is the regression gate).
- FR-13.2.2: **Scenario-engine independence**: the scenario engine,
DSL, plugins, and reports must remain device-agnostic — they interact
with a node only through the channel model (`ModuleType` /
`Channel` / `ChannelLocation`) and the datastore's set/read surface.
Scenarios must not require changes when a profile is added.
- FR-13.2.3: **Per-vendor module catalogs**: catalogs keyed by brand;
each entry defines word/bit widths and channels. A node config
selects brand + module list; validation must reject cross-brand
module mixes within one node (e.g., a WAGO 750-430 in an ABB S800
station). New catalogs are data-only additions (no engine changes).
- FR-13.2.4: **Process-image layout rules per brand**: layout becomes
a profile parameter (word-first + byte-packed bit modules is the
WAGO §7.3.2.1 rule; other brands may pack differently — e.g.,
byte-per-channel images). The channel model and alias addressing
(FR-4.3.11 module-based and pool-based schemes) must keep working on
top of any layout.
- FR-13.2.5: **Config registers / watchdog / statistics as pluggable
blocks**: implementations following the existing
`registers.py`/`watchdog.py`/`stats.py` pattern, one set per
profile. Devices lacking a Modbus watchdog or statistics block
expose nothing (or an illegal-address exception) there — that is a
device fact, not a simulator limitation.
- FR-13.2.6: **Trace annotations per profile**: `trace.py` address
annotations (currently 750-362 Tables 119–122) must come from the
active profile so verbose tracing stays meaningful for every device.
- FR-13.2.7: **Profile selection**: node config gains a `device:` key
(e.g. `device: wago-750-362`, the default for backward
compatibility); CLI/`WAGO_SIM_NODE` paths unchanged. `wago-sim
validate` must report unknown devices and cross-brand module mixes
as clean errors.
### 13.3 Candidate devices (informative, non-binding)
- WAGO 750 Modbus TCP family beyond the 362 (352/363/860…): same
register-map family — profile effort is mainly constants and
identity registers.
- Other vendors' Modbus TCP I/O heads / controllers (e.g., Phoenix
Contact Inline, Beckhoff BK/BC series): a profile written against
the vendor manual; effort dominated by the address map and
configuration-register semantics.
### 13.4 Acceptance criteria (draft)
1. The 750-362 profile passes the full existing test suite unchanged
after the profile extraction.
2. A second profile (from §13.3) serves a PLC master that reads/writes
its documented process image; identity/description registers match
the vendor manual; the same scenario DSL drives its channels without
modification.
3. Scenarios written for one device run unchanged against another
device with equivalent channel configuration.
4. `wago-sim validate` reports unknown device keys and cross-brand
module mixes with clean one-line errors.
---
## 14. Feature Request — Additional Fieldbus Protocols (PROFINET, COMLI)
> **Status: requested 2026-10-03, not yet planned into a roadmap phase.**
> Extend the simulator beyond Modbus TCP, motivated by ABB AC800M/800xA
> systems and their distributed I/O: PROFINET IO (AC800M PN controllers
> with S800 I/O via CI840-class interface modules) and COMLI (ABB's
> protocol between AC800M controllers and S800/S900 I/O via CI51x/CI522
> communication interfaces, TCP-encapsulated).
### 14.1 Motivation
An AC800M FAT bench talks to its distributed I/O over the protocol the
controller is actually configured for. Simulating the I/O side of
those protocols lets the same scenario engine, datasets (§8), bench
mode (§9), and reporting drive FAT benches for ABB controllers, not
only Modbus TCP masters.
The architecture separates cleanly along the same seams the
multi-vendor profiles (§13) expose: the scenario engine couples to a
node only through the channel model and the datastore's set/read
surface; a new protocol is a new transport + protocol layer binding to
the same process-image core.
### 14.2 Requirements
- FR-14.2.1: **Protocol abstraction**: a protocol binding defines how
the field-side process image is exposed to a master: wire protocol,
address/identifier model, cyclic vs. transactional access, and the
device's diagnostics/alarm model. The simulator core (process image,
scenario engine, plugins, reports) stays protocol-independent.
- FR-14.2.2: **PROFINET IO device simulation** (candidate; scope to be
defined against AC800M PN-controller configuration): simulate the
device side of PROFINET IO — DCP identify/boot, cyclic RT I/O data
exchange for a configured module list, and enough record/identification
data for the IO controller to bring the device into data exchange.
Scope note: a full PROFINET stack is a major undertaking; a
realistic first target is an Ethernet-level RT simulation of one
S800-style station profile with a fixed module list, driven by the
existing deterministic clock and scenario engine.
- FR-14.2.3: **COMLI device simulation** (candidate; scope to be
defined against AC800M/CI51x/CI522 documentation): simulate the
I/O-side endpoint of ABB COMLI over TCP as used between AC800M and
S800/S900 I/O through CI communication interfaces — the
register/point addressing model, cyclic polling, and the diagnostics
responses the CI interface expects. COMLI's request/response
register semantics map naturally onto the datastore's set/read
surface.
- FR-14.2.4: **Shared scenario engine**: scenarios, aliases, macros,
assertions, plugins, and datasets (§8) work unchanged against
protocol bindings; only the channel/identifier model of the target
I/O system is expressed in the node config (module list with
brand-specific article numbers). A scenario must not name wire-level
addresses; alias resolution stays per-device via FR-4.3.11/FR-13.2.4.
- FR-14.2.5: **Trace and diagnostics parity**: per-protocol verbose
tracing equivalent to the Modbus plain-text trace (NFR-6.4):
PROFINET RT frames and COMLI requests decoded in readable form with
device-level annotations; protocol error counters analogous to the
Modbus statistics block.
- FR-14.2.6: **Fault-injection parity** (per protocol): for PROFINET —
frame loss/corruption, device pull/plug, subslot mismatch,
communication/watchdog timeouts (the PN equivalent of the Modbus
watchdog §3.4); for COMLI — request timeouts, malformed messages,
CI-interface failure simulation. These must be scriptable from
scenarios like the Modbus fault paths (§3.6).
- FR-14.2.7: **Deployment parity**: protocol servers run under the
same service/container model (§12): env-var configuration, graceful
shutdown; one container may host multiple protocol bindings for a
node (a station reachable via different protocols in different bench
setups).
### 14.3 Scoping questions (answer before planning)
1. PROFINET: full device stack vs. targeted RT simulation of one
station profile? Which controller-side tooling (e.g., ABB Compact
Builder) must accept the simulated device?
2. COMLI: exact CI51x/CI522 variants in scope; is TCP encapsulation
sufficient, or is the serial CI variant also needed?
3. Are both protocols needed, or is one the realistic FAT target?
(COMLI is far smaller in scope; PROFINET conformance is the larger
effort by an order of magnitude.)
4. Which I/O module families per protocol (S800 modules via
PROFINET/COMLI interfaces; WAGO 750 modules over Modbus as today)?
### 14.4 Acceptance criteria (draft)
1. An AC800M controller (or a protocol-level test master representing
one) establishes data exchange with the simulated I/O station and
reads/writes the documented process data.
2. The existing scenario DSL drives input changes and observes output
reactions through the new protocol binding, unchanged.
3. Verbose tracing shows per-protocol decoded traffic with device
annotations; fault-injection steps behave like the Modbus
equivalents.
4. The 750-362 Modbus behavior is unaffected (regression suite green).
---
## 15. Roadmap (post-framework)
| Phase | Feature |
|---|---|
| 2 | Module catalog expansion (analog, counter, SSI, DALI/DSI, serial modules), mailbox parameters; **bench mode tooling (§9)**: `--set` seeding, console monitor, runtime manipulation commands |
| 3 | **Long-duration simulation (§8)**: CSV dataset playback, degradation models, invariants, soak-test reporting |
| 4 | FAT report generator (HTML/PDF), scenario comparison, template library per project type |
| 5 | Multi-node orchestration (several couplers in one FAT bench), UDP variant, WBM simulation; **web UI (§10)**; **runtime device configuration (§11)**: module hot-configuration with image rebuild, DIP-switch emulation, WBM-equivalent pages, device-state persistence |
| 6 | CI integration: run scenarios headless, export JUnit XML |
| ? | **Multi-vendor device profiles (§13)** and **additional fieldbus protocols PROFINET/COMLI (§14)** — not yet scheduled; phase assignment after §13.3/§14.3 scoping |
---
## 16. References
- WAGO 750-362 manual `m07500362_xxxxxxxx_0en.pdf`, sections 7.2–7.3 (process image), 11.2 (Modbus), 12.2 (module data architecture)
- WAGO I/O System 750 system description `m075xxxxx_xxxxxxxx_0en.pdf`
- MODBUS Application Protocol Specification V1.1b3 / MODBUS TCP specification (modbus.org)
- ABB CI51x/CI522 communication interface and S800/S900 I/O documentation (COMLI)
- PROFINET specification (PI, profibus.com) — if PROFINET scope is confirmed