Files
wago-modbustcp-simulator-py/docs/spec/software-spec.md
T

96 KiB
Raw Blame History

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 v1.5 per-type numbering); 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) implemented 2026-10-04 incl. PlantEnv (FR-8.2.14–16), datasets, degradation, invariants, snapshots; bench mode (§9), runtime device configuration (§11 — coupler/module/channel-name subset covered by web UI M5/M5a), multi-vendor device profiles (§13) and additional fieldbus protocols PROFINET/COMLI (§14), and host network settings (§15) requested 2026-10-06
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

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)

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 DI-module 2 (0-based number among the modules of the same type, i.e. the third DI module in the node), i.e. DI2.1. Channel numbers are 0-based. The channel kind must match the module (DO on a DI-only module is a validation error). This is an alternative to addressing by physical slot; both pool addressing ({ type: di, channel: 3 } = 3rd DI channel across the whole node) and module addressing are supported and may be mixed, but an alias uses exactly one. Implemented in v1.1 (channel_by_module, Alias.module, alias_location). Changed in v1.5 (2026-10-04, breaking): module numbers previously counted physical slots (all modules in one sequence); they now number within the module's type — DI0/DO0/AI0/AO0 start each kind's sequence (e.g. a node [DI_8, DI_16, DO_8, DO_16, AI_2, AO_2] numbers its DI modules DI0, DI1 regardless of the DI/DO interleaving). This matches how channels are named on real WAGO benches and makes rearranging modules less disruptive to existing configs. Scenarios, channel names, and analog ranges use the per-type codes.

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

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

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: implemented 2026-10-04 (roadmap phase 3 complete). 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. With the plant environment (FR-8.2.14), the decision source sees this pipeline as one atomic env tick: observe() reads the state as of the start of the tick, apply() queues the decided outputs, and the engine then advances the pipeline once — outputs written by apply() land in the output image before the next tick's observe(), so every state the decision source sees is pipeline-consistent.
  • 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.
  • FR-8.2.14: Plant environment abstraction (requested 2026-10-04, reworked same day to environment-first): PlantEnv is the primary interface for continuous plant simulation, standalone-first — it requires only a virtual node + datastore (no scenario), with the §8.3/8.4 machinery (degradation, invariants) as optional attachments. Per deterministic tick (the engine or the runner advances the tick clock, never wall-clock arithmetic): state = env.observe() returns the state of ALL modules — DI/DO/AI/AO channels, engineering units, per-type I/O codes, configured names, analog display ranges (FR-10.2.14), plus active aliases and parameters — → env.model(state, dt) (the caller-supplied plant physics) → apply() writes the new field state. The environment owns no policy; plant models are supplied by subclassing (FR-8.2.17).
  • FR-8.2.15: Action semantics: apply() primarily writes the input side (DI/AI) — the simulated field: what the sensors show, driven by plant physics reacting to the PLC's output commands. Writing outputs (DO/AO) is allowed as explicit forcing via a force=True keyword or env.force(...) — for headless runs without a PLC and for bench fault setup; forced outputs go through the datastore's internal write paths so mirrors/statistics/watchdog stay consistent (FR-9.2.6). Exactly one decision source may own an alias per tick (ownership conflicts are validation errors, extending FR-8.2.2).
  • FR-8.2.16: Environment API surface (concrete): env.observe() -> PlantState (all channels with names and analog display ranges, aliases, parameters, sim-time); env.apply(values, force=False) (keys: I/O code or alias name; DI/AI default, DO/AO require force; unknown keys are errors); env.model(state, dt_s) — override point for plant physics, returning new field values; env.is_done(state) -> bool — optional end criterion, default False (runs until manually stopped); env.reset() — deterministic fresh episode; env.tick_ms / env.sim_time_ms (read-only clock); env.parameters / env.invariants attachments. PlantState.channel(key) returns the full channel record by I/O code or by configured application name (FR-10.2.11); by_code() returns values by I/O code; channel_range(key) returns an analog channel's display range (min, max) — always defined for AI/AO (dense store, FR-10.2.14).
  • FR-8.2.17: Environment authoring & runners: environments are easy to create and call — a subclass of PlantEnv overriding model() (and optionally is_done()) in any importable module (--env myenvs.pump_station.PumpStationEnv), or a module-level factory function. Three runners: (a) wago-sim serve --env ... ticks the environment on a wall-clock-paced periodic task (10 ms tick alongside the Modbus server, real-time behavior for a live PLC) and runs until manually stopped (SIGINT/SIGTERM — the existing graceful-shutdown path); the ticker itself is owned by an EnvRunner on the app so the web UI can stop/pause/resume/reset/ restart it at runtime (FR-10.2.15); (b) wago-sim sim --node ... --env ... runs headless unpaced (as fast as determinism allows) until is_done() or SIGINT. The scenario-continuous mode (FR-8.2.1-13) remains a scripted layer on top of the same engine ticks.

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 via rate:, or per start cycle with per: cycles), exponential, staircase (staircase: level table). A model is configured with observes: naming the alias whose wear counts feed it (NB: the key is observes — a YAML on key would be parsed as boolean True by YAML 1.1).
  • 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):

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_capacity:
    observes: pump_relay             # wear counts from this alias
    model: linear
    rate: 0.0004                     # -0.04 %/h runtime → ~10 % after ~10.4 sim-days
    from: 1.0
    maintenance_at: 0.90             # threshold crossing injects diagnosis
    diagnosis: { module: 4, channel: 1, value: 0x0001 }
    # the PLC raises maintenance_alarm; the scenario asserts it:

sequence:                           # scheduled events (sim-time)
  - at: 12d
    maintenance_reset: true         # service visit: capacity restored
  - at: 12d
    assert: { label: "maintenance alarm raised",
              conditions: { maintenance_alarm: ON }, within: 60s }

plugins: [plugins.pump_plant.pump_plant]   # loop: d(level)/dt = inflow − pump_flow(speed, capacity)

invariants:
  - { alias: level_sensor, condition: '>= 200', label: "tank level floor" }
  - { alias: level_sensor, condition: '<= 9000', label: "tank level brim" }

rate_limits:
  - { alias: speed_setpoint, max_per_hour: 3000 }   # ramp-rate bound

report:
  snapshot_every: 1h
  aggregate: episodes

Datasets (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
# 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:

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
├── plantenv.py        # PlantEnv: gym-style observe/apply/reset cycle over the
│                      #   process images — the decision-source interface
│                      #   (FR-8.2.14–16); plugins become thin adapters
└── engine.py          # mode: continuous, parameter() accessor, snapshot
                       #   scheduler; fixed tick pipeline per FR-8.2.12:
                       #   datasets -> edges -> plugins/env -> degradation ->
                       #   invariants -> scheduled steps -> snapshots
erDiagram
    ContinuousScenario ||--o{ Dataset : "datasets (optional)"
    ContinuousScenario ||--|| PlantEnv : "environment (FR-8.2.14)"
    PlantEnv ||--o{ DecisionSource : "plugins / external agents"
    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"
    PlantEnv }o--|| ProcessImage : "observe/apply via datastore"

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).
  12. PlantEnv cycle (FR-8.2.14): observe() returns every input channel (values, names, codes, ranges, sim-time); apply() lands the decided outputs in the output image before the next observe() — verified by a deterministic two-tick test where the decision source copies an input to an output and the second observation contains it.
  13. Plugin parity (FR-8.2.15): the same plant logic runs unchanged as a classic plugins: entry and as an env.observe/apply decision source — identical outputs over a 1000-tick headless run (regression gate for the adapter).
  14. External agent: a decision source stepping the env over a socket (non-Python agent) produces identical results to the in-process variant on the same scenario (FR-8.2.15).

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's lower left corner, below the channel indicators, 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. DI/DO channels lay out in vertical columns of eight — channels 0–7 in the left column, 8–15 in the next — and modules are drawn as thin, tall bezels mirroring the proportions of real WAGO DIN-rail housings (narrow width, vertically stacked LEDs).
  • 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. Clicking an analog bar indicator (requested 2026-10-04) sets the value proportionally to the click position within the channel's display range (FR-10.2.14) — AI = field side, AO = master-side force; exact values remain enterable via the channel list.
  • 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; the Docker-gated host network settings page is specified in §15. §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. Signed values (2026-10-04): range bounds may be negative — -65534 <= min < max <= 65535 (the raw 16-bit image holds 0–65535; a negative display bound lets signed plant quantities map onto the bar). Simulated AI values and AO forces accept negative numbers within the same window; values are stored in the image as their 16-bit two's-complement representation. Dense runtime store (2026-10-04): node.channel_ranges is always complete — every analog channel of the current layout has an entry (unconfigured channels carry the materialized default), codes that no longer match an analog channel are pruned on rebuild, so envs and API consumers can read ranges without default-fallback guesswork. Persistence (node YAML, config export) stays lean: only explicit non-default entries are written, so defaults survive a future DEFAULT_RANGE change.
  • FR-10.2.15: Plant environment control (requested 2026-10-04): the web UI can load/unload environments at runtime — the CLI's --env becomes the startup default, not the only option. An environment card shows the loaded ref, its state (running / paused / done / none), sim-time, and tick ms; controls: start (load + tick), stop (cancel the ticker, field freezes), pause/resume (ticker cancelled or rescheduled, instance kept — pause is a deliberate FAT fault: "plant stuck"), reset (env.reset(), deterministic fresh episode, NFR-8.7.3), restart (stop + start of the same ref, fresh instance). Rules: exactly one environment at a time (one decision source, FR-8.2.15); is_done() ends the ticker (state done, no auto-restart); a module-list rebuild (SAVE CONFIG, FR-11.2.1) discards the env — its bindings (channel bindings in the model, sim state) belong to the old layout, exactly like values are lost on a KBUS reset; starting a new env is refused while one is loaded (stop first — explicit, no silent replacement); readonly mode (FR-10.2.8) disables every mutating control.
  • FR-10.2.16: Environment file authoring (requested 2026-10-04, planned): create/modify/delete environment source files (e.g. envs/*.py) through the web UI — an editor pane with the plain PlantEnv subclass pattern (FR-8.2.17), plus upload/download. Security note: the UI writing importable Python is remote code execution by design; it requires the same trust boundary as the bench network (auth, FR-10.2.8) and SHALL be disabled in readonly mode; delete is refused while the env is loaded; the on-disk directory must be explicit in configuration (today envs/ resolves from the working directory, not the installed package). Status: planned, roadmap phase 5.
  • FR-10.2.17: Hot reload of environment code (requested 2026-10-04, implemented same day as part of FR-10.2.15): Python caches modules in sys.modules; a restart of an env whose source changed on disk would silently reuse the old class. load_env(ref, reload=True) — used by the UI's start/restart — invalidates the cached module before import so a fresh instance always picks up the edited code. The CLI's startup --env and the headless sim runner do not reload (the process is new anyway).

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
GET/PUT /api/config/channel-ranges read/write analog display ranges 10.2.14
GET /api/env environment status: ref, state, sim-time, tick ms 10.2.15
POST /api/env/start load + start an env by ref (refuses while one is loaded) 10.2.15
POST /api/env/stop · pause · resume · reset · restart ticker lifecycle; reset = deterministic fresh episode 10.2.15

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)
M5b Analog display ranges (FR-10.2.14)
M6 (deferred) Scenario run view (FR-10.2.6) + alias-ownership rejection (FR-10.2.9)
M7 Plant environment control (FR-10.2.15 + FR-10.2.17): runtime load/stop/pause/resume/reset/restart + hot reload
M8 (planned) Environment file authoring (FR-10.2.16)

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.
  7. Environment control (FR-10.2.15): an env started from the UI ticks paced (its field changes visible to a Modbus master and the overview); pause freezes the field; reset returns to the initial state; a module-list SAVE CONFIG discards the loaded env (status none); an edit to the env source on disk + restart runs the new code (hot reload); starting while loaded is refused; readonly blocks all env mutations.

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.
  • FR-11.2.6: Module reordering (requested 2026-10-04, implemented same day): the web UI's I/O configuration pane supports rearranging modules two ways — drag-and-drop of module rows (HTML5 DnD) and move up/down arrow controls per slot; both are one gesture each and trigger the same process-image rebuild + persistence as any module change (FR-11.2.1–11.2.3). Because I/O codes number modules per type (FR-4.3.11 v1.5), moving a module past a module of a different kind does not renumber the other kind's channels — only the relative order within the same type affects addressing.

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. Feature Request — Host Network Settings (Docker Bench Host)

Status: requested 2026-10-06, not yet planned into a roadmap phase. A settings page in the web UI that views and manipulates the host OS's IP addresses, exposed only when an explicit environment flag is set and the simulator runs inside Docker. This section captures the requirements and the enabling architecture (a whitelisted host-side agent); it does not commit to a schedule.

15.1 Motivation

A containerized simulator (§12) cannot reach beyond its own network namespace. It can rebind its listen address (FR-11.3.3 dedicated-IP mode), but the host's network interfaces — what the bench network and the PLC under test actually see — are outside the container. As a result, FAT cases that depend on network-level events cannot be staged from inside the simulator today:

  • "Re-addressing" the virtual coupler (DIP switch, FR-11.3.1–11.3.3) changes nothing on the bench network in the default bridge deployment: the PLC keeps talking to the unchanged host address, so the reconnect/re-addressing logic in the PLC program is never exercised.
  • Cable-pull / node-power-off fault cases require stopping the whole container (coarse — every port drops at once) or shell access to the bench host, breaking the "operate everything from the browser" workflow of the web UI (§10).
  • Moving the simulated node to a different subnet mid-bench (multi-bench swaps, address conflicts) likewise needs host access.

The requested feature closes the gap: an opt-in settings page through which an authenticated operator changes the host OS's IP addresses from the bench UI — making the simulated node's network identity real on the bench network, and making network-level fault cases first-class FAT scenarios.

15.2 Requirements

  • FR-15.2.1: Opt-in via environment flag. The feature is disabled unless WAGO_SIM_HOSTNET is set truthy (same semantics as WAGO_SIM_VERBOSE: 1/true/yes/on); the flag lives in the compose file so enabling it is a visible, deliberate deployment change. Default: off. When off, the page is absent and every /api/hostnet/* route answers 404 — no dormant surface.
  • FR-15.2.2: Docker-only. Independently, the simulator must detect a container environment — binding: the compose file sets WAGO_SIM_IN_DOCKER=1 (supplementary signal: /.dockerenv exists). Outside docker the routes stay 404 regardless of the flag: a bare-metal operator has direct access to the OS network settings; the page exists precisely because a container does not.
  • FR-15.2.3: Settings page. A page in the web UI's configuration area (alongside the Port/Ethernet page, FR-11.4.1) listing the host's network interfaces — name, link state, current IPv4 addresses — read from live host state, not a desired-state mirror, so out-of-band changes made on the host appear on refresh. The page also shows the agent's health and the effective whitelist.
  • FR-15.2.4: Operations. Add an address (address + prefix length), remove an address, replace an interface's primary address — each validated client-side and agent-side (address/prefix syntax, interface exists, interface whitelisted). Changes are runtime-only (like ip addr add: non-persistent across host reboots); v1 deliberately does not write netplan/NetworkManager/ systemd-networkd configuration, so a host reboot is a guaranteed escape hatch back to the deployed addressing. Optional v2, behind the same whitelist and an explicit "disruptive" confirmation: set an interface's link up/down (cable-pull simulation).
  • FR-15.2.5: Safety rails. The agent enforces an interface whitelist from its own config file (operations on other interfaces are refused and surfaced as errors on the page); it snapshots an interface's address set before every mutation, and the page offers undo last / restore interface; every mutation and every refusal is appended to a single-line audit log (operator session, interface, old → new, timestamp) written to stdout/journald like the simulator's own logging (FR-12.2.6) and viewable (bounded) on the page.
  • FR-15.2.6: Trust boundary (same stance as FR-10.2.16): the page requires an authenticated session (FR-10.2.8); read-only mode (WAGO_SIM_WEB_READONLY) hides the page and answers mutations with 403. Changing host addresses is a privileged operation by design — the flag must never be set on a network the operator does not control.
  • FR-15.2.7: Host-side agent (binding architecture decision). A container cannot change host addresses, and the simulator container itself shall not run privileged (§12 non-root, least-privilege deployment). Instead a small companion service — wago-sim-hostnet-agent — runs on the host (systemd unit in deploy/, root or CAP_NET_ADMIN), listening on a Unix domain socket that the compose file mounts into the container read-write. The agent's protocol is a narrow whitelist of operations (health, list, snapshot, address add/remove/change, restore) over JSON lines; it never executes a shell with operator input — on Linux v1 it uses the netlink API (pyroute2, with the ip tool as a fallback). Rejected alternative, documented why: running the simulator container itself privileged with host namespaces — it forfeits the non-root deployment and couples host control to the simulator's own lifecycle and restarts.
  • FR-15.2.8: Degraded states never break the UI. Flag set but the agent socket absent/unreachable: the page shows a clear "host control unavailable" banner and mutation routes answer 503 with a message; the Modbus server and the rest of the web UI are unaffected.
  • FR-15.2.9: DIP dedicated-IP integration (FR-11.3.3). When a DIP address change is applied in dedicated-IP mode and the derived interface is whitelisted, the apply step may route through the same agent — the derived IP is added to the host NIC — so the PLC on the bench network must genuinely re-connect to the new node address, exactly like a physical coupler after a DIP change. Under network_mode: host the simulator rebinds directly per FR-11.3.3; either way the agent supplies the address on the host.

15.3 Architecture sketch (planned)

bench host (Linux)
├── wago-sim hostnet-agent --config /etc/wago-sim/hostnet.yaml
│     (systemd unit: deploy/wago-sim-hostnet-agent.service;
│      root or CAP_NET_ADMIN — it owns the interface whitelist)
│     config: socket: /run/wago-sim/hostnet.sock, whitelist: [...]
└── docker compose
      ├── mounts /run/wago-sim:/run/wago-sim (agent socket in)
      └── wago-sim serve (page + /api/hostnet/* routes,
           gated by FR-15.2.1 flag + FR-15.2.2 docker detection)

Web API, mounted only under the FR-15.2.1/2 gates:

Method/Route Purpose FR
GET /api/hostnet availability (flag, docker, agent health) + interface/address list + whitelist 15.2.3
POST /api/hostnet/interfaces/{name}/addresses add an address (address, prefix length) 15.2.4
DELETE /api/hostnet/interfaces/{name}/addresses/{addr} remove an address 15.2.4
POST /api/hostnet/interfaces/{name}/restore undo last / restore snapshot 15.2.5
GET /api/hostnet/audit bounded recent audit lines 15.2.5

15.4 Scoping questions (answer before planning)

  1. Platform scope v1: Linux bench hosts only (agent via pyroute2)? Windows hosts run the container inside Docker Desktop/WSL2 — the container's "host" is the WSL2 VM; reaching the Windows NICs needs a Windows-side agent over a named pipe — defer or include?
  2. Networking-mode story for the DIP integration (FR-15.2.9): keep the current bridge deployment (host IP + mapped port) as v1, or require network_mode: host for full parity (node IP == host IP, port 502)?
  3. Agent authentication: Unix-socket file permissions only, or a shared token in the agent config to defend against other local users?
  4. IPv6 in v1 or deferred?
  5. Does link up/down (cable pull) belong in v1 — it can sever the operator's own web UI session on that NIC — or are address add/remove/change sufficient for the planned FAT cases?

15.5 Acceptance criteria (draft)

  1. Without WAGO_SIM_HOSTNET: no page; all /api/hostnet/* routes answer 404; the rest of the UI unchanged (regression suite green).
  2. Flag set but not in docker: same as 1.
  3. Flag set + docker + agent reachable: the page lists the host's real interfaces; adding 192.168.1.33/24 to the whitelisted interface shows in ip addr on the host, and the new address is reachable from the bench network (a master connects through it).
  4. Removing that address severs the master's connection through it — the PLC's reconnect logic is exercised (the FAT case).
  5. Restore/undo returns the interface to its pre-change snapshot; a host reboot likewise restores the deployed addressing (changes are runtime-only, FR-15.2.4).
  6. An operation on a non-whitelisted interface is refused by the agent and surfaced as an error on the page; the audit log records every mutation and every refusal.
  7. Read-only mode: page hidden, mutations 403.
  8. DIP integration: applying switch position 33 in dedicated-IP mode results in 192.168.1.33 present on the whitelisted NIC (bridge mode) or the server rebind (host mode); the "restart required" semantics of FR-11.3.3 are unchanged.
  9. Agent socket missing: "host control unavailable" banner, mutations 503, Modbus server and the rest of the web UI unaffected.

16. 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), additional fieldbus protocols PROFINET/COMLI (§14), and host network settings (§15) — not yet scheduled; phase assignment after §13.3/§14.3/§15.4 scoping

17. 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