🎉 Initial commit

This commit is contained in:
Jonas Bengtsson
2026-10-03 09:46:11 +02:00
commit 973d619cee
34 changed files with 5787 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
# --- Python ---
__pycache__/
*.py[cod]
*$py.class
*.so
# Distribution / packaging
build/
dist/
*.egg-info/
*.egg
MANIFEST
# --- Virtual environments ---
.venv/
venv/
env/
# --- Test / coverage ---
.pytest_cache/
.coverage
.coverage.*
htmlcov/
.tox/
.nox/
# --- IDE / editor ---
# .vscode/ is kept in the repo: it contains the shared debug
# configurations (launch.json) for running the simulator CLI.
# Uncomment to ignore it if that is not desired:
# .vscode/
# --- Project artifacts ---
# Scenario run reports written by `wago-sim run --report-jsonl/--report-csv`
out/
# Extracted helper text from the WAGO PDF manuals (doc-extraction scratch)
docs/_extract*.txt
# --- OS junk ---
Thumbs.db
Desktop.ini
.DS_Store
+69
View File
@@ -0,0 +1,69 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
// Run the Modbus TCP server against the example pump-station
// node. Listens on 127.0.0.1:5020 (use 502 for a real FAT
// bench; 502 requires admin privileges on Windows/Linux).
"name": "wago-sim: serve (pump node)",
"type": "debugpy",
"request": "launch",
"module": "wago_sim.cli",
"args": [
"serve",
"--node", "config/pump_node.yaml",
"--host", "127.0.0.1",
"--port", "5020"
],
"console": "integratedTerminal"
},
{
// Run the example FAT scenario headless and write reports.
"name": "wago-sim: run (pump_station_fat)",
"type": "debugpy",
"request": "launch",
"module": "wago_sim.cli",
"args": [
"run",
"--node", "config/pump_node.yaml",
"--scenario", "scenarios/pump_station_fat.yaml",
"--report-jsonl", "out/pump_station_fat.jsonl",
"--report-csv", "out/pump_station_fat.csv"
],
"console": "integratedTerminal"
},
{
// Validate a scenario file without running it.
"name": "wago-sim: validate (pump_station_fat)",
"type": "debugpy",
"request": "launch",
"module": "wago_sim.cli",
"args": [
"validate",
"--scenario", "scenarios/pump_station_fat.yaml"
],
"console": "integratedTerminal"
},
{
// Debug a single scenario step or engine logic against any
// scenario; pick the arguments at launch time.
"name": "wago-sim: run (pick scenario + args)",
"type": "debugpy",
"request": "launch",
"module": "wago_sim.cli",
"args": "${command:pickArgs}",
"console": "integratedTerminal"
},
{
"name": "Python Debugger: Current File with Arguments",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"args": "${command:pickArgs}"
}
]
}
+1
View File
@@ -0,0 +1 @@
Keep the specification file and READE TODO section in phase.
+243
View File
@@ -0,0 +1,243 @@
# wago-modbustcp-simulator-py
**WAGO 750-362 Modbus TCP bus coupler simulator for autonomous PLC FAT testing.**
The simulator replaces a physical WAGO I/O node during Factory
Acceptance Tests: the PLC under test connects via Modbus TCP and talks to
a virtual node whose I/O behavior is driven by scripted scenarios.
## Features
- Faithful WAGO 750-362 register map (manual §11.2.4): process image
words/bits with the exact mapped/unmapped areas and exception behavior,
read-back mirrors (+`0x0200` / +`0x1000`), configuration registers
`0x1000–0x2FFF`, constants `0x2000–0x2008` (byte-order check `0x1234`).
- Function codes FC1, FC2, FC3, FC4, FC5, FC6, FC11, FC15, FC16, FC22,
FC23 with the device limits (2000/800 bits, 125/100 registers).
- Modbus watchdog (`0x1000–0x100B`), statistics block (`0x1029`),
response-delay injection (`0x1037`), module diagnosis (`0x1050`).
- Virtual node built from a YAML module list (WAGO 750 catalog subset,
extensible).
- **Scenario engine**: YAML scenarios with aliases, macros, ramps,
pulses, waits, and PASS/FAIL assertions with timeouts; state machines
and Python action plugins for complex plant models.
- **Two I/O addressing schemes** for aliases: *module-based* —
`{ type: di, module: 2, channel: 1 }` = `DI2.1` (module slot and
channel both 0-based) — and *pool-based* — `{ type: di, channel: 3 }`
(1-based index across the node's DI pool). Both may be mixed.
- **Bench mode** *(requested, planned — spec §9)*: run the simulator
without a scenario (`wago-sim serve --node ...` already works);
planned additions: `--set DI0.1=ON` startup seeding, an interactive
console monitor with live I/O table, and runtime set/pulse/watch
commands.
- **Web UI** *(requested, planned — spec §10)*: browser front-end to
overlook and manipulate the virtual node's I/O — live channel tables
with `DI0.1`-style addresses, toggle/force/pulse/ramp actions,
watchdog/statistics/diagnostics views with fault injection, and
multi-client consistency for collaborative FAT benches.
- **Long-duration (continuous) simulation** *(requested, planned — see
`docs/spec/software-spec.md` §8)*: endurance/soak testing over days or
weeks of simulated time. Designated inputs are driven from streaming
CSV datasets (historian exports, demand/operator profiles, seamless
`repeat`, `time_factor` acceleration), while degradation models age
plant parameters (e.g., pump capacity) from wear counters — exercising
the PLC's compensation, maintenance-alarm and service-recovery logic.
Continuous `invariants` and `rate_limits` produce bounded, episode-based
reports for unattended runs.
- ≤ 15 concurrent Modbus TCP connections, idle-timeout handling.
## Install (dev)
```bash
pip install -e .[dev]
```
## Usage
Run the Modbus TCP server against a node configuration:
```bash
wago-sim serve --node config/pump_node.yaml --port 502
```
Validate a scenario:
```bash
wago-sim validate --scenario scenarios/pump_station_fat.yaml
```
Run a scenario headless and write reports:
```bash
wago-sim run --node config/pump_node.yaml \
--scenario scenarios/pump_station_fat.yaml \
--report-jsonl out/report.jsonl --report-csv out/report.csv
```
## Documentation
- `docs/spec/software-spec.md` — the software specification
(requirements, architecture, scenario DSL, acceptance criteria,
and the long-duration simulation feature request in §8).
- `docs/m07500362_xxxxxxxx_0en.pdf` — WAGO 750-362 reference manual.
## Testing
```bash
pytest
```
## TODO
Work remaining, tracked against the software specification
(`docs/spec/software-spec.md`). Completed items are checked and grouped
first; open items follow, grouped by spec phase, with the spec section or
requirement ID for each item in parentheses.
### Completed — framework phase (spec §3 + §4 core, 124 tests passing)
- [x] MBAP + PDU codecs: function codes FC1, FC2, FC3, FC4, FC5, FC6,
FC11, FC15, FC16, FC22, FC23 with the 750-362 device limits
(2000/800 bits, 125/100 registers) and exception codes (§3.2).
- [x] WAGO register map (§3.3): word/bit process-image areas with the
exact mapped/unmapped ranges, illegal-address exceptions, output
read-back mirrors (+`0x0200` / +`0x1000`), digital
channel-number bit addressing, constants `0x2000–0x2008`
(incl. byte-order check `0x2002` = `0x1234`).
- [x] Configuration/diagnostic/firmware registers (§3.5): image sizes
`0x1022–0x1025`, statistics `0x1029`, connections `0x102A`, idle
timeout `0x1030`, MAC-ID `0x1031`, response delay `0x1037`,
module diagnosis `0x1050`, LED error `0x1020/0x1021`.
- [x] Modbus watchdog (§3.4): registers `0x1000–0x100B`, Standard and
Alternative modes, trigger/masks/stop sequences, expiry clears
outputs, address-aware `0x04` lock in Standard mode, optional
TCP close on timeout.
- [x] Fault-injection paths (§3.6): response-delay injection,
connection refusal beyond 15, illegal-address probing responses,
diagnosis/LED registers scriptable.
- [x] Virtual node built from a YAML module list (13-module WAGO 750
catalog subset; complex-first, bit-byte-packed image layout per
manual §7.3.2.1).
- [x] Scenario DSL (§4): named aliases with `scale`/`offset`, macros
with parameters (inline expansion, blocking waits inside macros),
sequence steps `set` / `reset` / `toggle` / `pulse` / `ramp` /
`wait` / `assert` / `report`, rising-edge detection on all
aliases.
- [x] Two I/O addressing schemes for scenario aliases (FR-4.3.11):
module-based (`{ type: di, module: 2, channel: 1 }` = `DI2.1`,
module slot and channel 0-based) and pool-based (`channel:` =
1-based index within the node's kind pool); kind/module/channel
validation with clear errors; both verified by tests.
- [x] Assertions with `within:` timeouts → PASS/FAIL verdicts with
sim-time stamps (FR-4.3.5).
- [x] Python action/plant-model plugins: per-tick models and step
actions (FR-4.3.8), e.g. `plugins/pump_plant.py`.
- [x] Deterministic tick-driven engine: sim-time decoupled from
wall-clock (FR-4.3.9 core).
- [x] CLI: `wago-sim serve / run / validate`.
- [x] Reports: console verdict summary, JSONL and CSV export
(`--report-jsonl` / `--report-csv`).
- [x] asyncio Modbus TCP server: ≤ 15 concurrent connections, idle
connection reaper, response-delay task.
- [x] Test suite: 124 tests incl. real-socket server tests and
`pymodbus` interop; spec §7 acceptance criteria 1–7 covered.
### Framework gaps (spec §4 — Scenario & Macro Engine)
- [ ] State machines: `states:` with entry actions, transitions on
input/output edges or conditions, and guards (FR-4.3.2) —
currently only the time-ordered `sequence:` is implemented.
- [ ] Actions `noise`, `sin`, `hold` on simulated inputs (FR-4.3.4) —
`set`/`reset`/`pulse`/`toggle`/`ramp` are done.
- [ ] `wait_edge` step is accepted but not yet blocking — implement
edge-wait semantics in the engine loop.
- [ ] Assertions on statistics registers, e.g. "watchdog expired",
"BadAddress counter > 0" (FR-4.3.6).
- [ ] Determinism guard: seed/record plugin RNG state so identical runs
produce identical reports (FR-4.3.9, NFR-6.3) — needed once
`noise`/`sin` or randomizing plugins exist.
- [ ] Multi-node scenarios — several couplers in one scenario
(FR-4.3.10).
### Phase 2 — Module catalog expansion & bench mode (spec §9)
- [ ] Analog module variants, counter, pulse-width, SSI, incremental
encoder, serial interface, DALI/DSI modules with their
process-data layouts (spec §12.2 of the WAGO manual).
- [ ] Mailbox parameterization traffic beyond process data.
- [ ] Bench mode tooling (§9): `--set DI0.1=ON` startup seeding,
interactive console monitor (`--monitor`) with live channel table
and change highlighting (FR-9.2.2–9.2.3).
- [ ] Console runtime manipulation commands: `set`, `pulse`, `watch`,
`reset stats` (FR-9.2.4).
- [ ] `serve --scenario` optional combination + passive-node hint
(FR-9.2.5). *(Plain `serve` without a scenario already works —
see Completed above.)*
### Phase 3 — Long-duration (continuous) simulation (spec §8)
The soak-testing feature requested 2026-10-03 — specified in §8,
not yet implemented:
- [ ] `scenario/datasets.py` — streaming CSV playback for designated
inputs: header-driven time column, `interpolation: linear|hold`,
`repeat` with seamless seams, `time_factor` acceleration,
`until` limits, bounded memory, ERROR verdict with file/line on
malformed data (FR-8.2.1–8.2.11).
- [ ] `scenario/degradation.py` — wear counters (runtime hours, start
cycles, load-hours) and degradation models (`linear`,
`exponential`, `staircase`, plugin shapes) computing plant
parameters such as `pump_capacity` (FR-8.3.1–8.3.2).
- [ ] Degradation hooks in plant plugins: `engine.parameter()` accessor
used in loop physics, e.g. actual flow = nominal × capacity
(FR-8.3.3).
- [ ] `maintenance_at` thresholds: diagnosis injection via register
`0x1050`, maintenance-alarm assertions, `maintenance_reset`
action restoring capacity and clearing diagnoses (FR-8.3.4–8.3.5,
FR-8.3.8).
- [ ] `scenario/invariants.py` — per-tick `invariants` with bounded
episode aggregation and `rate_limits` over sliding one-hour
windows (FR-8.4.1–8.4.2).
- [ ] `snapshot_every` periodic dumps of aliases, wear counters, and
degradation parameters (FR-8.4.3).
- [ ] Continuous-mode verdict rules and bounded reporting for unattended
multi-day runs (FR-8.4.4–8.4.5).
- [ ] Scheduled sequence steps via `at: <duration>` interleaved with
datasets on the sim-time axis (FR-8.4.6).
- [ ] Acceptance tests per spec §8.8 (streaming 100k-row CSV,
interpolation checks, repeat determinism, time_factor math,
alias-ownership validation, degradation/recovery round-trip,
episode recording, full 28-day soak).
### Phase 4 — Reporting (spec §11)
- [ ] FAT report generator (HTML/PDF) from scenario results and Modbus
traces (console/JSONL/CSV export is done — see Completed above).
- [ ] Scenario comparison (run-vs-run diff) and template library per
project type.
### Phase 5 — Multi-node orchestration & protocol variants (spec §11)
- [ ] Several couplers in one FAT bench (ties into FR-4.3.10).
- [ ] Modbus UDP variant.
- [ ] WBM (web-based management) simulation pages.
- [ ] **Web UI (§10)**: browser front-end for overlooking and
manipulating I/O — node/module/channel tables with `DI0.1`-style
and Modbus addresses (FR-10.2.2), live value updates from all
sources (FR-10.2.3), toggle/force/pulse/ramp manipulation through
Modbus-consistent paths (FR-10.2.4), diagnostics views with
fault injection and statistics reset (FR-10.2.5), scenario/run
view (FR-10.2.6), multi-client consistency (FR-10.2.7), optional
read-only/access control (FR-10.2.8), alias-ownership protection
during scenario runs (FR-10.2.9).
### Phase 6 — CI integration (spec §11)
- [ ] Headless scenario execution in CI pipelines.
- [ ] JUnit XML export of verdicts for test dashboards.
### Operational hardening (spec §6 — NFRs)
- [ ] Per-transaction Modbus trace log with sim-time + wall-time stamps
and PDU hex (NFR-6.4).
- [ ] Performance budget validation: ≥ 500 requests/s, ≤ 10 ms added
latency (NFR-6.1).
+12
View File
@@ -0,0 +1,12 @@
# Virtual WAGO node configuration — pump station FAT bench
# Simulated device: WAGO 750-362 Modbus TCP coupler
name: pump_station_node
coupler: 750-362
modules:
- "750-430" # 8-channel digital input 24VDC
- "750-1405" # 16-channel digital input 24VDC
- "750-530" # 8-channel digital output 24VDC
- "750-1516" # 16-channel digital output 24VDC
- "750-467" # 2-channel analog input 4-20mA
- "750-554" # 2-channel analog output 4-20mA
Binary file not shown.
Binary file not shown.
+875
View File
@@ -0,0 +1,875 @@
# Software Specification
**WAGO Modbus TCP Simulator — Autonomous FAT Testing Framework**
| | |
|---|---|
| Project | `wago-modbustcp-simulator-py` |
| Version | 1.1 (Draft for review) |
| Date | 2026-10-03 |
| Status | Framework phase; module addressing implemented (FR-4.3.11); long-duration simulation (§8), bench mode (§9) and web UI (§10) requested |
| Target device simulated | WAGO 750-362 / 750-362/0000-0001 FC Modbus TCP (G4) bus coupler |
| Reference documents | `docs/m07500362_xxxxxxxx_0en.pdf` (750-362 Modbus TCP manual), Modbus TCP specification (modbus.org) |
---
## 1. Introduction
### 1.1 Purpose
This document specifies a software system that **simulates a WAGO 750-362 Modbus TCP fieldbus coupler** on a standard PC, to be used as a test bench during **Factory Acceptance Tests (FAT)** of PLC programs.
During FAT the PLC under test is normally wired to a real WAGO I/O node. This simulator replaces the physical node entirely: the PLC (or any Modbus TCP master/SCADA) connects over TCP/IP to the simulator, which responds exactly like a 750-362 coupler — same function codes, same register map, same exception behavior, same timing characteristics.
### 1.2 Problem being solved
FAT of PLC programs requires either:
1. Physical I/O hardware (expensive, must be wired per project, error-prone, single-purpose), or
2. A generic Modbus slave (does not implement the WAGO-specific register map, digital-channel addressing, watchdog, statistics, or module-diagnostics behavior; PLC programs validated against it may fail against a real node).
The simulator provides the **exact WAGO behavior** plus **scriptable, autonomous scenario execution**, so a complete FAT round — including fault cases — can run unattended and produce a machine-verifiable result.
### 1.3 Scope
In scope (v1.0 framework):
- Modbus TCP server (slave) with the 750-362 function-code set.
- WAGO 750-362 register map: process image (words + bits), configuration, diagnostic, constant, and firmware-info registers.
- Virtual I/O node: a configurable list of WAGO 750/753 I/O modules forming the process image.
- **Scenario engine**: scripted macros that observe input states and act on outputs (the automation core).
- Deterministic simulation clock and scheduler.
- CLI runner with configuration files.
Out of scope (v1.0): HTTP/WBM web interface, FTP, SNMP, UDP variant of Modbus, DHCP/BootP provisioning, real-time behavior beyond response latency simulation, and module parameterization (mailbox traffic) beyond process data.
### 1.4 Definitions
| Term | Meaning |
|---|---|
| PII | Process Input Image — data the coupler presents to the master (from input modules) |
| PIO | Process Output Image — data the master writes (to output modules) |
| MBAP | Modbus Application Protocol header (7-byte TCP header) |
| FC | Modbus function code |
| FAT | Factory Acceptance Test |
| Scenario | A scripted, timed sequence of state changes and assertions |
| Macro | A reusable scripted action (e.g., "pulse output 3 for 500 ms") |
| Node | One simulated fieldbus coupler + its I/O module configuration |
| LTS | Long-Time Simulation — continuous (“soak”) mode running hours to weeks of simulated time |
| Dataset | A CSV time-series file driving designated input aliases in continuous mode |
| Wear counter | Engine-maintained per-alias statistic: runtime hours, edge counts, load-hours |
| Degradation model | Time/cycle-dependent transformation of a plant parameter (e.g., pump capacity) |
| Invariant | A condition that must hold every tick throughout a continuous run |
| Episode | One aggregated violation period of an invariant (bounded reporting) |
---
## 2. System Overview
```mermaid
flowchart LR
subgraph DUT["Devices under test"]
PLC["PLC 1..n\n(Modbus TCP masters)"]
end
subgraph SIM["Simulator (this system)"]
MB["Modbus TCP Server\n(asyncio, ≤ 15 connections)"]
DS["Datastore\n(WAGO word & bit maps)"]
NODE["Virtual WAGO Node\n(module list → process image)"]
SE["Scenario Engine\n(states → actions)"]
CLK["Deterministic Clock\n+ Scheduler"]
LOG["Event Log / Traces"]
end
PLC -- "MBAP frames (FC1..FC23)" --> MB
MB <--> DS
DS <--> NODE
SE -- "read inputs / write outputs" --> NODE
SE <--> CLK
NODE -- change events --> SE
LOG -.records.- MB
LOG -.records.- SE
```
The system is a pure **slave/server**: it never initiates Modbus traffic. Autonomy comes from the **scenario engine**, which drives the *field side* of the virtual node (as if sensors moved and actuators did something), while the PLC drives the *master side* through normal Modbus writes.
---
## 3. Functional Requirements — Modbus Server
### 3.1 Transport & connections
- FR-3.1.1: The server shall listen on TCP port 502 (configurable) and speak Modbus TCP with the standard MBAP header.
- FR-3.1.2: The server shall support **up to 15 concurrent Modbus TCP connections** (per 750-362 spec, §7.3). Connection number 16 shall be refused.
- FR-3.1.3: Idle connections shall be closed after the timeout configured in register `0x1030` (default 600 × 100 ms). Outputs remain in last state (§11.2.5.5).
- FR-3.1.4: Protocol identifier other than 0 in MBAP shall increment the `BadProtocol` statistic and be ignored.
- FR-3.1.5: Unit identifier shall be ignored (accepted, echoed back), as on the real device.
### 3.2 Function codes
The simulator shall implement exactly the 750-362 function set (§11.2.2) and reject all others with exception `0x01` (Illegal Function):
| FC | Function | Limits (R/W) |
|----|----------|--------------|
| 01 | Read Coils | 2000 / — |
| 02 | Read Discrete Inputs | 2000 / — |
| 03 | Read Holding Registers | 125 / — |
| 04 | Read Input Registers | 125 / — |
| 05 | Write Single Coil | — / 1 |
| 06 | Write Single Register | — / 1 |
| 11 | Get Comm Event Count | — |
| 15 | Write Multiple Coils | — / 800 |
| 16 | Write Multiple Registers | — / 100 |
| 22 | Mask Write Register | — / 1 |
| 23 | Read/Write Multiple Registers | 125 / 100 |
Exceeding limits → exception `0x03` (Illegal Data Value) and increment `TooManyRegisters` / `TooManyBits`.
Exception codes (§11.2.3): `0x01` Illegal Function, `0x02` Illegal Data Address, `0x03` Illegal Data Value, `0x04` Slave Device Failure, `0x05` Acknowledge, `0x06` Server Busy, `0x08` Memory Parity Error, `0x0A`/`0x0B` Gateway errors.
### 3.3 Register map (WAGO 750-362)
#### 3.3.1 Word (register) access — reading (FC3/FC4)
| Modbus address (hex) | Area | Behavior |
|---|---|---|
| `0x0000–0x00FF` | Physical input area (1), %IW0–255 | First 256 words of input process image |
| `0x0100–0x01FF` | — | Exception `0x02` |
| `0x0200–0x02FF` | Physical output area (1) | Read-back mirror of `%QW0–255` (outputs written at `0x0000` are readable here, +`0x0200` offset) |
| `0x0300–0x0FFF` | — | Exception `0x02` |
| `0x1000–0x2FFF` | Configuration registers | See §3.5 |
| `0x3000–0x5FFF` | — | Exception `0x02` |
| `0x6000–0x62FB` | Physical input area (2), %IW256–1020 | Additional 764 words |
| `0x62FC–0x6FFF` | — | Exception `0x02` |
| `0x7000–0x72FB` | Physical output area (2), %QW256–1020 | Read-back mirror, +`0x01000` offset from write area (2) |
| `0x72FC–0xFFFF` | — | Exception `0x02` |
#### 3.3.2 Word access — writing (FC6/FC16)
| Modbus address (hex) | Area |
|---|---|
| `0x0000–0x00FF` | Physical output area (1), %QW0–255 |
| `0x0100–0x01FF` | Exception `0x02` |
| `0x0200–0x02FF` | Physical output area (1) (mirror accepted for writing) |
| `0x0300–0x0FFF` | Exception `0x02` |
| `0x1000–0x2FFF` | Configuration registers |
| `0x3000–0x5FFF` | Exception `0x02` |
| `0x6000–0x62FB` | Physical output area (2), %QW256–1020 |
| `0x62FC–0x6FFF` | Exception `0x02` |
| `0x7000–0x72FB` | Physical output area (2) (mirror) |
| `0x72FC–0xFFFF` | Exception `0x02` |
#### 3.3.3 Bit (coil) access — reading (FC1/FC2) & writing (FC5/FC15)
| Modbus address (hex) | Area |
|---|---|
| `0x0000–0x01FF` | Physical input area (1) — first 512 digital inputs (read) |
| `0x0200–0x03FF` | Physical output area (1) — first 512 digital outputs (read mirror + write) |
| `0x0800–0x85F7` | Physical input area (2) — digital inputs 513–2039 |
| `0x9000–0x95F7` | Physical output area (2) — digital outputs 513–2039 |
| all others | Exception `0x02` |
**Digital channel addressing rule** (§11.2.4): bit addresses are **channel numbers** — digital input no. 47 has Modbus address 46 — independent of the physical module layout. Word-oriented modules are laid out first (in physical order, from word 0), then bit-oriented modules are byte-packed in physical order.
#### 3.3.4 Constant registers (FC3/FC4 read-only)
| Addr | Value | Purpose |
|---|---|---|
| `0x2000` | `0x0000` | Zero |
| `0x2001` | `0xFFFF` | Ones |
| `0x2002` | `0x1234` | Byte-order check (Intel format expected) |
| `0x2003` | `0xAAAAl` | Bit mask 1 |
| `0x2004` | `0x5555` | Bit mask 2 |
| `0x2005` | `0x7FFF` | Max positive |
| `0x2006` | `0x8000` | Max negative |
| `0x2007` | `0x3FFF` | Half positive |
| `0x2008` | `0x4000` | Half negative |
#### 3.3.5 Firmware-info registers (read-only)
`0x2010` revision, `0x2011` series code (`0x02EE` = 750), `0x2012` coupler code (`0x0166` = 362), `0x2013`/`0x2014` major/minor, `0x2020` description (16 words), `0x2021` compile time, `0x2022` compile date, `0x2023` firmware loader info, `0x2030–0x2033` connected-module descriptions, `0x2035` process-image settings, `0x2036` diagnostics, `0x2040` software reset (write `0x55AA`/`0xAA55`), `0x2043` factory settings.
### 3.4 Watchdog (§11.2.5.2/3)
The Modbus watchdog shall be simulated:
- `0x1000` timeout value ×100 ms (default `0x0064`), writable only when WD inactive.
- `0x1001`/`0x1002` coding masks (bit *n−1* of the mask = FC *n*; default `0xFFFF`).
- `0x1003` trigger (write nonzero, alternating values), `0x1005` stop (`0xAAAA` then `0x5555`), `0x1008` simple stop (`0xAA55`/`0x55AA`), `0x1007` restart (write `1`).
- `0x1006` status: `0` inactive / `1` active / `2` expired.
- `0x1009` close-socket-on-timeout, `0x100A` type Standard/Alternative, `0x100B` save WD params (remanent).
- On timeout: **all analog outputs → 0, all digital outputs → OFF**; in Standard mode further process-data access is answered with exception `0x04`; WD registers remain accessible; `0x1009 = 1` additionally closes TCP connections.
- Watchdog events shall be visible to the scenario engine (assertions: "WD triggered at t seconds").
### 3.5 Configuration & diagnostic registers
- `0x1022`–`0x1025`: image sizes (analog in/out bits, digital in/out bits) computed from the configured module list.
- `0x1029` (9 words) Modbus TCP statistics: SlaveDeviceFailure, BadProtocol, BadLength, BadFunction, BadAddress, BadData, TooManyRegisters, TooManyBits, ModTcpMessageCounter. Reset by writing `0xAA55`/`0x55AA`.
- `0x102A` active TCP connections.
- `0x1030` Modbus TCP idle timeout (×100 ms).
- `0x1031` MAC-ID (3 words, configurable to emulate a specific device).
- `0x1037` response delay (ms) — the simulator shall actually delay responses, to emulate link latency.
- `0x1038` TOS/DSCP (stored, echoed).
- `0x1050` module diagnosis (3 words): module, channel, diagnosis bits (underrange … collective failure) — scriptable fault injection.
- `0x1020`/`0x1021` LED error code/argument — scenario-settable.
- `0x102B` KBUS reset (write): re-reads the module configuration; process image rebuilt.
### 3.6 Fault injection
FR-3.6.1: The scenario engine shall be able to simulate fieldbus faults:
- KBUS/local-bus failure → `SlaveDeviceFailure` statistic increments and process-data reads may return exception `0x04`.
- Response-delay injection via `0x1037`.
- Connection refusal beyond 15 concurrent connections.
- Illegal-address probing (read/write the exception areas) → correct `0x02` responses (this validates master error handling).
### 3.7 Process data semantics
- Word mapping of digital channels follows the **Intel format** (§7.3.3): channel 1 = bit 0 of the low byte (`D0`), high byte `D1` on top.
- Digital word mapping: `Bit15 … Bit0` = `Byte D1 D0`, bit 0 = lowest channel number.
- Analog modules contribute 16-bit data words (control/status bytes not accessible via Modbus).
- A module list defines the image; the framework computes addresses and exposes them in the log and via module-description registers (`0x2030+`).
---
## 4. Functional Requirements — Scenario & Macro Engine
### 4.1 Concept
A **scenario** is a scripted, time-ordered description of the *world around the PLC*: sensor inputs change over time, actuators respond, and the script asserts on PLC outputs. This is what makes FAT autonomous — no operator needs to toggle signals.
- **State-based**: rules keyed on input/output conditions (`when input 5 turns ON …`).
- **Time-based**: scheduled events (`after 30 s …`).
- **Output actions**: `set`, `reset`, `pulse`, `toggle`, `ramp`, `hold_value` on simulated field outputs (what the PLC sees as its inputs).
- **Input observations**: the engine reads the process output image (what the PLC wrote) with edge detection (rising/falling).
- **Assertions**: expected PLC behavior (output X ON within 2 s after input Y) → FAT verdicts.
### 4.2 Scenario DSL (YAML)
```yaml
name: pump_station_fat
description: "Full FAT round for pump station PLC program"
node: config/pump_node.yaml # module configuration
timing: { tick: 10ms } # simulation tick
inputs: # named aliases → digital/analog inputs
start_button: { type: di, module: 0, channel: 0 } # DI0.0
level_sensor: { type: ai, module: 4, channel: 0, scale: 0.1 } # AI4.0, mm → counts
em_stop_ok: { type: di, module: 0, channel: 3 } # DI0.3
outputs: # named aliases → PLC outputs (read-only for script)
pump_relay: { type: do, module: 2, channel: 0 } # DO2.0
valve_open: { type: do, module: 2, channel: 1 } # DO2.1
speed_setpoint: { type: ao, module: 5, channel: 0, scale: 0.1 } # AO5.0
macros:
pulse_start: # reusable macro
- set: start_button = ON
- wait: 250ms
- set: start_button = OFF
states: # optional state machine
idle:
on: { em_stop_ok: ON }
goto: running
running:
entry: [ ramp: level_sensor, to: 8500, over: 30s ]
on: { pump_relay: RISING }
assert: { valve_open: ON, within: 2s }
goto: full
...
sequence: # time-ordered script (or fully state-driven)
- macro: pulse_start
- assert: { pump_relay: ON, within: 2s }
- wait: 5s
- set: level_sensor = 4200
- assert: { speed_setpoint: ">= 1000", within: 3s }
- report: PASS
```
### 4.3 Requirements
- FR-4.3.1: Scenarios shall be defined in YAML (no programming required for standard FAT sequences).
- FR-4.3.2: The engine shall support **state machines** (states, entry actions, transitions on input/output edges or conditions, guards).
- FR-4.3.3: The engine shall support **macros** (reusable action groups, parameterizable).
- FR-4.3.4: Actions on simulated inputs: `set`, `reset`, `pulse`, `toggle`, `ramp` (linear over time), `noise` (± band), `sin`, `hold`.
- FR-4.3.5: Assertions on PLC outputs with timeout → **PASS/FAIL/ERROR verdict per check**, with timestamps; final scenario verdict.
- FR-4.3.6: Assertions may compare against expected values, ranges, edges, and statistics registers (e.g., "watchdog expired").
- FR-4.3.7: All scenario events and all Modbus transactions shall be logged (CSV/JSONL) for the FAT report.
- FR-4.3.8: Python **action plugins** shall be supported for actions beyond the built-in set (registered callables), for complex plant-model physics.
- FR-4.3.9: The engine shall run **deterministically**: simulation time decoupled from wall-clock; results reproducible.
- FR-4.3.10: A scenario may load multiple nodes (multi-coupler FAT benches).
- FR-4.3.11: **Module-based channel addressing** — aliases may be addressed
as *module type + module number + channel number*, all components
explicitly configurable: `{ type: di, module: 2, channel: 1 }` selects
digital input channel 1 (0-based) on module slot 2 (0-based position in
the node's module list), i.e. `DI2.1`. Both numbers are 0-based to match
physical slot/channel labeling. The channel kind must match the module
(`DO` on a DI-only module is a validation error). This is an alternative
to pool addressing (`{ type: di, channel: 3 }` = 3rd DI channel across
the whole node); both schemes are supported and may be mixed, but an
alias uses exactly one. **Implemented** in v1.1 (`channel_by_module`,
`Alias.module`, `alias_location`).
### 4.4 Example FAT flow
1. PLC program boots, connects to simulator (Modbus TCP client), reads input image.
2. Scenario `pump_station_fat` starts in `idle` state; `em_stop_ok` turns ON.
3. Scenario pulses `start_button`; PLC sees input, starts pump relay (writes coil).
4. Engine detects rising edge on `pump_relay`, asserts `valve_open` within 2 s.
5. Scenario ramps `level_sensor` to 8500 counts; PLC adjusts `speed_setpoint`; assertions verify.
6. Scenario injects fault (KBUS error / watchdog timeout); PLC must react (e.g., switch to safe state) — asserted.
7. Report generated: verdicts, timing, Modbus trace.
---
## 5. Architecture
### 5.1 Package layout
```
wago-modbustcp-simulator-py/
├── docs/ # WAGO manuals + this spec
│ └── spec/
│ ├── software-spec.md # this document
│ └── register-map.md # extracted WAGO tables
├── src/
│ └── wago_sim/
│ ├── __init__.py
│ ├── mbap.py # MBAP frame codec
│ ├── pdu.py # PDU codec (request/response/exception)
│ ├── server.py # asyncio Modbus TCP server
│ ├── datastore.py # WAGO word/coil datastores
│ │ # (holds process images + register bank)
│ ├── registers.py # config/diagnostic/constant/firmware regs
│ ├── modules.py # virtual WAGO I/O modules (module catalog)
│ ├── node.py # node = modules → process image mapping
│ ├── watchdog.py # Modbus watchdog simulation
│ ├── clock.py # deterministic clock + scheduler
│ ├── scenario/
│ │ ├── __init__.py
│ │ ├── engine.py # interpreter for scenario DSL
│ │ ├── dsl.py # YAML schema + validation
│ │ │ ├── actions.py # set/pulse/ramp/... + plugin registry
│ │ │ ├── assertions.py # condition matcher
│ │ │ ├── statemachine.py
│ │ └── report.py # verdicts, CSV/JSONL log
│ ├── stats.py # Modbus TCP statistics counters
│ ├── logging_setup.py
│ └── cli.py # entry point (argparse)
├── config/
│ └── pump_node.yaml # example node configuration
├── scenarios/
│ └── pump_station_fat.yaml # example scenario
├── tests/
│ ├── test_mbap.py
│ ├── test_pdu.py
│ ├── test_server.py
│ ├── test_datastore.py
│ ├── test_registers.py
│ � ├── test_watchdog.py
│ ├── test_scenario_engine.py
│ └── conftest.py
├── pyproject.toml
└── README.md
```
### 5.2 Design decisions
| Decision | Rationale |
|---|---|
| Pure-Python, asyncio TCP server | No external Modbus library needed; full control over WAGO-specific behavior (register-map exceptions, statistics, watchdog); testable |
| Deterministic clock | Reproducible FAT runs; scenario times independent of host speed |
| YAML DSL + plugin boundary | Test engineers write scenarios, not code; complex physics via Python plugins |
| Datastore split: process image vs. register bank | Mirrors real device: process image vs. internal registers; the readback-mirror rule (+0x0200) is dataflow, not copy-on-read |
| Statistics as first-class objects | FAT assertions can check communication health (`0x1029`) |
### 5.3 Threading & concurrency model
- asyncio event loop owns the Modbus server and the scheduler tick.
- Scenario engine runs in the same loop (coroutines), so image updates and Modbus reads see consistent data without locks.
- Response delay (`0x1037`) implemented as asyncio sleep before response send.
### 5.4 Data model
```mermaid
erDiagram
NodeConfig ||--|{ ModuleConfig : modules
NodeConfig ||--|| ProcessImage : builds
ModuleConfig ||--|{ Channel : channels
ProcessImage ||--|| InputImage : inputs
ProcessImage ||--|| OutputImage : outputs
OutputImage ||--|| InputMirror : "read-back at +0x200/+0x1000"
Scenario ||--|{ State : states
Scenario ||--|{ SequenceStep : sequence
State ||--|{ Action : entry
State ||--|{ Transition : on
SequenceStep ||--|{ Action : actions
Scenario ||--|{ Alias : "inputs/outputs aliases"
Scenario ||--|{ Macro : macros
Macro ||--|{ Action : steps
```
### 5.5 State chart — server session
```mermaid
stateDiagram-v2
[*] --> Listening
Listening --> Connected: TCP accept (≤15)
Connected --> RequestParsing: MBAP complete
RequestParsing --> Dispatching: PDU decoded
Dispatching --> Responding: result
Dispatching --> ExceptionResponse: FC/addr/val error
Responding --> Connected
ExceptionResponse --> Connected
RequestParsing --> Connected: malformed (count BadLength)
Connected --> Closed: idle timeout / WD 0x1009=1 / client close
```
---
## 6. Non-Functional Requirements
- NFR-6.1 Performance: sustain ≥ 500 requests/s with ≤ 10 ms added latency (typical FAT bench load).
- NFR-6.2 Portability: Python 3.11+ on Windows/Linux.
- NFR-6.3 Determinism: two runs of a scenario with identical inputs and timing produce identical verdict logs (except wall-clock stamps).
- NFR-6.4 Observability: every Modbus transaction logged with timestamps (sim-time + wall-time), PDU hex, connection id.
- NFR-6.5 Robustness: malformed frames never crash the server; counted in statistics.
- NFR-6.6 Configurability: port, bind address, node config, firmware identity registers, MAC-ID configurable without code changes.
---
## 7. Acceptance Criteria (framework phase)
1. A Modbus TCP master (e.g., `pymodbus` client in tests) can connect, read constants `0x2000–0x2008`, and gets exactly `0x0000, 0xFFFF, 0x1234, 0xAAAA, 0x5555, 0x0166, ...` per spec — byte order verified.
2. Reads/writes in all mapped/unmapped areas produce values/exceptions per §3.3 tables.
3. Digital channel addressing: a configured 16-channel DI module + 8-channel DO module yields channel-number bit addressing (DI 47 → bit address 46).
4. Output readback mirrors at +`0x0200` (word) and +`0x0200`/+`0x1000` (bit/word area 2) work.
5. A YAML scenario can set a digital input, detect a PLC output rising edge, and produce a PASS/FAIL report file.
6. Watchdog: configure `0x1000`, trigger via `0x1003`, let it expire, outputs clear, `0x1006` = 2, process access → exception `0x04`, restart via `0x1007`.
7. Statistics counters increment correctly on bad frames/addresses; reset via `0x1029` write `0xAA55`.
---
## 8. Feature Request — Long-Duration (Continuous) Simulation
> **Status: requested 2026-10-03, planned (roadmap phase 3), not yet implemented.**
> Extends the scenario engine (§4) with a `continuous` mode for endurance
> (“soak”) testing: the facility runs for hours, days or weeks of simulated
> time against evolving inputs and an aging plant.
### 8.1 Motivation
FAT rounds (§4) validate functional behavior over minutes. A facility must
additionally be proven **over long time**:
- **Endurance/soak**: memory leaks, counter rollovers, watchdog stability over days.
- **Trend & historian verification**: SCADA trends, averaging, alarm shelving.
- **Cycle behavior**: daily/weekly demand profiles, shift patterns, weekend idle.
- **Maintenance logic**: wear-based alarms, degradation detection, service recovery.
- **Clock edge cases**: midnight rollovers and (via absolute-timestamp datasets) DST transitions.
Long runs cannot be hand-scripted step by step. Two mechanisms replace the
scripted `sequence`:
1. **Dataset playback** — designated inputs are driven from time-series files
(e.g., CSV exported from a plant historian, or synthesized profiles).
2. **Degradation models** — plant-model parameters (e.g., pump capacity)
degrade as a function of runtime and cycles, exercising the PLC’s
compensation and maintenance logic.
### 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 the dataset schedule (plus any `until:`
duration) is exhausted.
- FR-8.2.2: Input aliases (di/ai) may be driven from **datasets**. Output
aliases must not appear in datasets (validation error). An endogenous process
variable (e.g., tank level) may instead be computed by a plant-model plugin
(FR-4.3.8, closed loop). **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.
### 8.3 Requirements — degradation models (plant logic over time)
- FR-8.3.1: The engine maintains **wear counters** per alias, updated every
tick: runtime hours (integral of truth), edge counts (rising/falling), and
load-hours (integral of the analog value). Counters are deterministic state.
- FR-8.3.2: A **degradation model** computes a named plant parameter (e.g.
`pump_capacity`, range 0…1) from wear counters and sim-time. Built-in
shapes: `linear` (per runtime hour, per start cycle), `exponential`,
`staircase` (threshold table); custom shapes via Python plugin.
- FR-8.3.3: Plant-model plugins read degraded parameters via
`engine.parameter("pump_capacity")` and shall use them in the loop physics
(e.g., actual pump flow = nominal flow × capacity), so the PLC must
compensate (e.g., speed creep) — the behavior under test.
- FR-8.3.4: `maintenance_at:` threshold crossing fires configured actions:
fault injection (module diagnosis, register `0x1050`), assertions
(maintenance alarm within a time bound), and logging. A
`maintenance_reset` restores the parameter to 1.0 and clears injected
diagnoses — verifying the PLC’s alarm clearing and performance recovery.
- FR-8.3.5: `floor:`/`ceiling:` clamp the parameter so the simulated plant
stays physically plausible.
- FR-8.3.6: Models must be pure functions of (sim-time, wear counters,
configuration) — no wall-clock — keeping runs reproducible.
- FR-8.3.7: Degradation state and wear counters are included in snapshots
(§8.4) and the final report.
- FR-8.3.8: New DSL actions introduced: `inject_diagnosis` / `clear_diagnosis`
(module-diagnosis fault injection) and `maintenance_reset`.
### 8.4 Requirements — continuous assertions & monitoring
- FR-8.4.1: `invariants:` — conditions evaluated **every tick** (analog
bounds, expected digital states). Violations are recorded as **episodes**
(start, end, extreme value, tick count) so a 30-day run yields a bounded
report, not a per-tick flood.
- FR-8.4.2: `rate_limits:` — maximum rising/falling edges per sliding hour
window per alias (e.g., pump starts ≤ 6/h), verifying anti-cycling logic.
- FR-8.4.3: `report.snapshot_every: <duration>` — periodic dumps of all
alias values, wear counters, and degradation parameters (CSV/JSONL) for
trend/historian verification.
- FR-8.4.4: Continuous-mode verdict: **PASS** iff the dataset schedule
completed, no invariant episode occurred, no rate limit was exceeded, and
no assertion failed; otherwise FAIL/ERROR accordingly.
- FR-8.4.5: Report and log sizes shall be bounded independent of run length
(episode aggregation + ring-buffered recent events).
- FR-8.4.6: A limited `sequence:` may still be used in continuous mode;
steps may be scheduled via `at: <sim duration>` (e.g., a service visit at
day 12). Scheduled steps and datasets interleave on the same sim-time axis.
### 8.5 Example — 28-day pump-station soak
Scenario (YAML):
```yaml
name: pump_station_soak_28d
description: "28-day soak: demand/operator playback, closed-loop plant with pump degradation"
mode: continuous
timing: { tick: 100ms }
inputs:
start_button: { type: di, channel: 1 } # exogenous (operator, CSV)
em_stop_ok: { type: di, channel: 2 } # exogenous (CSV)
inflow_valve: { type: di, channel: 3 } # exogenous (CSV)
inflow_rate: { type: ai, channel: 2, scale: 0.01 } # exogenous (CSV), m³/h → counts
level_sensor: { type: ai, channel: 1, scale: 0.1 } # endogenous (plant model computes)
outputs:
pump_relay: { type: do, channel: 1 }
valve_open: { type: do, channel: 2 }
speed_setpoint: { type: ao, channel: 1, scale: 0.1 }
maintenance_alarm: { type: do, channel: 3 }
datasets:
demand_week: # exogenous demand profile
file: data/demand_week.csv
aliases: [inflow_rate]
interpolation: linear
repeat: 4 # 7-day profile × 4 = 28 sim-days
operator_week: # operator actions
file: data/operator_week.csv
aliases: [start_button, em_stop_ok, inflow_valve]
interpolation: hold
repeat: 4
degradation:
pump_wear:
model: linear
parameter: pump_capacity # consumed by the plant model
per_runtime_hour: 0.0004 # 0.04 %/h → ~10 % after ~10.4 sim-days
per_start_cycle: 0.0002 # 0.02 % per start
floor: 0.80
maintenance_at: 0.90
on_threshold: # fires when capacity crosses 90 %
- inject_diagnosis: { module: 4, channel: 1, bits: [user_overrange] }
- assert: { label: "maintenance alarm raised",
conditions: { maintenance_alarm: ON }, within: 60s }
on_reset: # after maintenance_reset
- clear_diagnosis: { module: 4 }
sequence: # scheduled events (sim-time)
- at: 12d
maintenance_reset: pump_wear # service visit: capacity restored
plugins: [plugins.pump_plant.pump_plant] # loop: d(level)/dt = inflow − pump_flow(speed, capacity)
invariants:
- { alias: level_sensor, min: 200, max: 9000, label: "tank level bounds" }
rate_limits:
- { alias: pump_relay, edge: rising, max_per_hour: 6, label: "pump start cycles" }
report:
snapshot_every: 1h
aggregate: episodes
```
Datasets (CSV):
```csv
# data/demand_week.csv (elapsed seconds, inflow in m³/h)
time,inflow_rate
0,12.0
3600,12.4
7200,13.1
...
604800,12.0 # week boundary — loops seamlessly with repeat: 4
```
```csv
# data/operator_week.csv
time,start_button,em_stop_ok,inflow_valve
0,OFF,ON,OFF
28800,ON,ON,ON # Mon 08:00: shift start, valve opened
28810,OFF,ON,ON
432000,OFF,OFF,ON # Sat 00:00: weekend e-stop drill
440000,OFF,ON,ON
```
### 8.6 Architecture impact (planned modules)
```
src/wago_sim/scenario/
├── datasets.py # CsvDataset: streaming reader, interpolation, repeat, time_factor
├── degradation.py # WearCounters, DegradationModel registry, maintenance actions
├── invariants.py # invariant & rate-limit evaluation, episode aggregation
└── engine.py # mode: continuous, parameter() accessor, snapshot scheduler
```
```mermaid
erDiagram
ContinuousScenario ||--|{ Dataset : datasets
Dataset ||--|{ ColumnMapping : "alias columns"
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"
```
### 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. 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: requested 2026-10-03, planned (roadmap phase 5), not yet
> implemented.** 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.
- FR-10.2.3: Values shall update live (WebSocket push or short-polling),
including changes made by the PLC/master over Modbus, by scenarios, by
the console monitor, or by other UI clients — one shared source of truth.
- FR-10.2.4: **Manipulation**: clicking a DI/DO toggles it; an AI/AO gets a
numeric input (engineering units, using alias scale/offset when
defined); `pulse` (with duration) and `ramp` (target + duration) actions
on DI/DO/AI. All writes go through the datastore's Modbus-equivalent
paths (FR-9.2.6) so mirrors, watchdog, and statistics stay consistent.
- FR-10.2.5: **Diagnostics view**: watchdog status (0x1006), statistics
block (0x1029, with reset button), module diagnosis (0x1050), LED error
registers (0x1020/0x1021) — mirroring WBM pages of the real device —
with controls to inject/clear faults for FAT fault cases.
- FR-10.2.6: A scenario/run view: which scenario (if any) is loaded,
current step, verdicts so far, and basic controls (start/abort). Headless
result reports (JSONL/CSV) remain downloadable via the CLI.
- FR-10.2.7: Multi-client consistency: several simultaneous browser
clients see the same state; forced values propagate to all clients and
to the Modbus side within one refresh interval.
- FR-10.2.8: Access control for shared benches: optional read-only mode
and (v2) token-based login, so witnesses can observe without accident
rights.
- FR-10.2.9: The UI must not disturb deterministic scenario runs: when a
scenario owns an alias, UI writes to it are rejected with a clear
message (alias-ownership rules of §8 FR-8.2.2 apply).
### 10.3 Architecture sketch (planned)
```
src/wago_sim/
├── webui/
│ ├── __init__.py
│ ├── app.py # HTTP + WebSocket server (asyncio, same loop as Modbus)
│ ├── api.py # REST endpoints: node layout, values, writes, diagnostics
│ └── static/ # single-page front-end (no build step)
```
The web server runs **in the same asyncio event loop** as the Modbus TCP
server, so a UI write and a Modbus read never race on the datastore.
### 10.4 Acceptance criteria
1. Opening the UI shows the node's module list and channel table exactly
as configured, with `DI0.1`-style addresses and Modbus addresses.
2. Toggling a DO in the browser is immediately visible to a connected
Modbus master (and vice versa: a master write appears in the UI within
one refresh interval).
3. Forcing an AI value uses engineering units and the configured scale.
4. The diagnostics page shows live watchdog status and statistics; the
reset button clears the counters (register 0x1029 semantics).
5. With a running scenario, writes to scenario-owned aliases are rejected
with a visible message; the scenario continues unaffected.
6. Two simultaneous browsers stay consistent with each other and with
the Modbus image.
---
## 11. 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)** |
| 6 | CI integration: run scenarios headless, export JUnit XML |
---
## 12. 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)
+67
View File
@@ -0,0 +1,67 @@
"""Example plant/PLC model plugin for the pump-station FAT scenario.
In a real FAT bench the PLC under test drives the outputs. For a
headless demo (or pre-FAT scenario smoke tests) this plugin emulates
the expected PLC behavior so the scenario can PASS end-to-end.
Register it in a scenario via:
plugins: [plugins.pump_plant.pump_plant]
The engine calls it once per tick.
"""
from __future__ import annotations
# latch state per engine instance (module-level dict keyed by id)
_LATCH = {}
def pump_plant(engine, value) -> None:
"""Emulated PLC with classic start/stop latch circuit:
- rising edge on start_button latches the pump ON
- e-stop OFF breaks the latch (and the safe state follows)
- valve follows the pump; speed setpoint follows level above 4000
"""
key = id(engine)
start = bool(engine.alias_value("start_button"))
em_stop = bool(engine.alias_value("em_stop_ok"))
level = float(engine.alias_value("level_sensor"))
# rising edge of the start button sets the latch; e-stop clears it
if engine.consume_rising_edge("start_button"):
_LATCH[key] = True
if not em_stop:
_LATCH[key] = False
pump_on = _LATCH.get(key, False) and em_stop
speed = int(max(0.0, (level - 4000.0) * 10.0)) if pump_on else 0
_set_do(engine, "pump_relay", pump_on)
_set_do(engine, "valve_open", pump_on)
_set_ao(engine, "speed_setpoint", speed)
def _set_do(engine, alias_name: str, value: bool) -> None:
"""Emulate a Modbus master writing the output coil.
Uses the engine's global alias location: ``loc.bit`` is the 0-based
global digital channel index == the Modbus bit address (Table 122).
"""
loc = engine.alias_location(alias_name)
result = engine.datastore.write_bits(loc.bit, [bool(value)])
assert not isinstance(result, Exception), result
def _set_ao(engine, alias_name: str, value: int) -> None:
"""Emulate a Modbus master writing the output register.
``loc.word`` is the global analog output word index == the Modbus
word address in output area 1 (0x0000-0x00FF).
"""
alias = engine._aliases[alias_name]
loc = engine.alias_location(alias_name)
raw = int(value * alias.scale + alias.offset) & 0xFFFF
result = engine.datastore.write_words(loc.word, [raw])
assert not isinstance(result, Exception), result
+33
View File
@@ -0,0 +1,33 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "wago-modbustcp-simulator"
version = "0.1.0"
description = "WAGO 750-362 Modbus TCP bus coupler simulator for autonomous PLC FAT testing"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "Proprietary" }
authors = [{ name = "Vivab" }]
dependencies = [
"PyYAML>=6.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"pymodbus>=3.6",
"debugpy>=1.8",
]
[project.scripts]
wago-sim = "wago_sim.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
+53
View File
@@ -0,0 +1,53 @@
# Example FAT scenario for a pump-station PLC program.
#
# With a REAL PLC under test: run `wago-sim serve` against this node
# configuration; the PLC drives the outputs and the scenario asserts on
# them. Remove the `plugins:` line below — the PLC is the plant.
#
# Headless (no PLC): the plugins line loads an emulated plant model so
# the scenario can be smoke-tested end-to-end before the FAT bench is
# wired.
name: pump_station_fat
description: "Basic FAT round: start button -> pump relay -> valve -> level ramp"
plugins: [plugins.pump_plant.pump_plant] # remove when a real PLC is connected
inputs: # simulated field signals (what the PLC reads)
# module-based addressing: module slot and
# channel number on the module, both 0-based
start_button: { type: di, module: 0, channel: 0 } # DI0.0
em_stop_ok: { type: di, module: 0, channel: 1 } # DI0.1
level_sensor: { type: ai, module: 4, channel: 0, scale: 0.1 } # AI4.0, level mm -> counts
outputs: # PLC outputs observed by the scenario
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:
- set: { start_button: ON }
- wait: 250ms
- set: { start_button: OFF }
sequence:
# --- startup checks -------------------------------------------------------
- set: { em_stop_ok: ON }
- assert: { label: "pump idle", conditions: { pump_relay: OFF }, within: 1s }
# --- start sequence ---------------------------------------------------------
- macro: pulse_start
- assert: { label: "pump starts", conditions: { pump_relay: ON }, within: 2s }
- assert: { label: "valve opens", conditions: { valve_open: ON }, within: 2s }
# --- process dynamics -------------------------------------------------------
- ramp: { name: level_sensor, to: 8500, over: 30s }
- wait: 31s
- assert: { label: "speed setpoint raised", conditions: { speed_setpoint: ">= 1000" }, within: 3s }
# --- stop sequence ------------------------------------------------------------
- set: { em_stop_ok: OFF }
- assert: { label: "safe state on e-stop", conditions: { pump_relay: OFF, valve_open: OFF }, within: 2s }
- report: PASS
+7
View File
@@ -0,0 +1,7 @@
"""WAGO Modbus TCP Simulator.
Simulates a WAGO 750-362 Modbus TCP fieldbus coupler for autonomous
FAT (Factory Acceptance Test) testing of PLC programs.
"""
__version__ = "0.1.0"
+107
View File
@@ -0,0 +1,107 @@
"""Application assembly: wiring datastore, registers, watchdog, node, server."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from .datastore import WagoDatastore
from .modules import ModuleType, VirtualNode
from .registers import ConfigRegisterBank
from .server import WagoSimServer
from .stats import ModbusStatistics
from .watchdog import ModbusWatchdog
@dataclass
class SimulatorApp:
"""Fully wired simulator instance for one virtual node."""
datastore: WagoDatastore
node: VirtualNode
stats: ModbusStatistics
watchdog: ModbusWatchdog
registers: ConfigRegisterBank
server: WagoSimServer
@property
def modules(self) -> list[ModuleType]:
return self.node.modules
def build_app(
*,
modules: list[ModuleType],
host: str = "0.0.0.0",
port: int = 502,
mac_id: tuple[int, int, int, int, int, int] = (0x00, 0x30, 0xDE, 0x11, 0x22, 0x33),
firmware_description: str = "WAGO 750-362 Simulator",
) -> SimulatorApp:
"""Assemble all simulator components for a node configuration."""
stats = ModbusStatistics()
server_ref: dict[str, WagoSimServer] = {}
def on_expire() -> None:
datastore.clear_outputs()
stats.slave_device_failure += 1
def on_connections_close() -> None:
"""Watchdog 0x1009=1: close TCP connections (from sync context).
Called by the watchdog (possibly from its tick task). Schedules
the async close on the running event loop when one exists.
"""
server = server_ref.get("server")
if server is None:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
loop.create_task(server.close_all_connections())
watchdog = ModbusWatchdog(
on_expire=on_expire,
on_connections_close=on_connections_close,
)
datastore = WagoDatastore(config_bank=None) # type: ignore[arg-type]
registers = ConfigRegisterBank(
statistics=stats,
watchdog=watchdog,
tcp_connection_counter=lambda: server.connection_count,
mac_id=mac_id,
firmware_description=firmware_description,
on_software_reset=lambda: datastore.clear_outputs(),
on_kbus_reset=lambda: None,
)
datastore._config = registers # late binding to break the cycle
node = VirtualNode(datastore, modules)
registers.set_process_image_bits(
analog_in=node.analog_in_bits,
analog_out=node.analog_out_bits,
digital_in=node.digital_in_bits,
digital_out=node.digital_out_bits,
)
registers.set_module_description(node.description())
server = WagoSimServer(
datastore,
stats,
watchdog,
host=host,
port=port,
response_delay_provider=lambda: registers.response_delay_ms,
idle_timeout_provider=lambda: registers.idle_timeout_ms,
)
server_ref: dict[str, WagoSimServer] = {"server": server}
return SimulatorApp(
datastore=datastore,
node=node,
stats=stats,
watchdog=watchdog,
registers=registers,
server=server,
)
+125
View File
@@ -0,0 +1,125 @@
"""Command-line interface: run a node configuration and a scenario.
Examples::
wago-sim serve --node config/pump_node.yaml --port 502
wago-sim run --node config/pump_node.yaml --scenario scenarios/pump_station_fat.yaml
wago-sim validate --scenario scenarios/pump_station_fat.yaml
"""
from __future__ import annotations
import argparse
import asyncio
import inspect
import logging
import sys
from pathlib import Path
from .app import build_app
from .scenario.dsl import load_node_modules, load_yaml, validate_scenario
from .scenario.engine import ScenarioEngine
from .scenario.report import result_summary, write_csv, write_jsonl
def _build_logger(verbose: bool) -> None:
logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
async def _serve(args: argparse.Namespace) -> int:
node_config = load_yaml(args.node)
modules = load_node_modules(node_config)
app = build_app(modules=modules, host=args.host, port=args.port)
await app.server.start()
print(f"Simulating WAGO 750-362 with {len(modules)} modules "
f"on {args.host}:{args.port} (Ctrl+C to stop)")
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
pass
finally:
await app.server.stop()
return 0
def _run_scenario(args: argparse.Namespace) -> int:
node_config = load_yaml(args.node)
modules = load_node_modules(node_config)
scenario = load_yaml(args.scenario)
problems = validate_scenario(scenario)
if problems:
print("Scenario validation problems:", file=sys.stderr)
for p in problems:
print(f" - {p}", file=sys.stderr)
return 2
app = build_app(modules=modules)
engine = ScenarioEngine(app.node, scenario)
engine.start()
result = None
max_ticks = 2_000_000
ticks = 0
while result is None and ticks < max_ticks:
result = engine.pump()
ticks += 1
if result is None:
print("Scenario did not terminate (tick limit reached)", file=sys.stderr)
return 3
print(result_summary(result))
if args.report_jsonl:
write_jsonl(result, args.report_jsonl)
if args.report_csv:
write_csv(result, args.report_csv)
return 0 if result.passed else 1
def _validate(args: argparse.Namespace) -> int:
scenario = load_yaml(args.scenario)
problems = validate_scenario(scenario)
if problems:
print("Scenario validation problems:")
for p in problems:
print(f" - {p}")
return 2
print("OK")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="wago-sim",
description="WAGO 750-362 Modbus TCP simulator for PLC FAT testing",
)
parser.add_argument("-v", "--verbose", action="store_true")
sub = parser.add_subparsers(dest="command", required=True)
serve = sub.add_parser("serve", help="run the Modbus TCP server")
serve.add_argument("--node", required=True, help="node config YAML")
serve.add_argument("--host", default="0.0.0.0")
serve.add_argument("--port", type=int, default=502)
serve.set_defaults(func=_serve)
runp = sub.add_parser("run", help="run a scenario headless (no server)")
runp.add_argument("--node", required=True, help="node config YAML")
runp.add_argument("--scenario", required=True, help="scenario YAML")
runp.add_argument("--report-jsonl", default=None)
runp.add_argument("--report-csv", default=None)
runp.set_defaults(func=_run_scenario)
val = sub.add_parser("validate", help="validate a scenario file")
val.add_argument("--scenario", required=True)
val.set_defaults(func=_validate)
args = parser.parse_args(argv)
_build_logger(args.verbose)
if inspect.iscoroutinefunction(args.func):
return asyncio.run(args.func(args))
return int(args.func(args))
if __name__ == "__main__":
raise SystemExit(main())
+407
View File
@@ -0,0 +1,407 @@
"""WAGO 750-362 data model: process images and address-map dispatch.
Implements the register mapping tables of the 750-362 manual §11.2.4:
Word area:
0x0000-0x00FF input image area 1 (first 256 words) [R]
0x0100-0x01FF illegal [R/W]
0x0200-0x02FF output image area 1 readback (+0x0200) [R, mirrored W]
0x0300-0x0FFF illegal [R/W]
0x1000-0x2FFF configuration registers [R/W]
0x3000-0x5FFF illegal [R/W]
0x6000-0x62FB input image area 2 (words 256-1020) [R]
0x62FC-0x6FFF illegal [R/W]
0x7000-0x72FB output image area 2 readback (+0x1000) [R, mirrored W]
0x72FC-0xFFFF illegal [R/W]
Word writes (FC6/FC16) land in the output image:
0x0000-0x00FF output image area 1
0x0200-0x02FF output image area 1 (mirror accepted)
0x6000-0x62FB output image area 2
0x7000-0x72FB output image area 2 (mirror accepted)
Bit area (FC1/2/5/15), addresses are *digital channel numbers*:
0x0000-0x01FF digital inputs 1-512 [R]
0x0200-0x03FF digital outputs 1-512 [R/W]
0x0800-0x85F7 digital inputs 513-2039 [R]
0x9000-0x95F7 digital outputs 513-2039 [R/W]
everything else illegal
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Protocol
from .pdu import (
EXC_ILLEGAL_DATA_ADDRESS,
EXC_ILLEGAL_DATA_VALUE,
ExceptionResponse,
MaskWriteRegisterRequest,
ReadRequest,
ReadWriteMultipleRegistersRequest,
WriteMultipleCoilsRequest,
WriteMultipleRegistersRequest,
WriteSingleCoilRequest,
WriteSingleRegisterRequest,
)
# --- Word area geometry -----------------------------------------------------
AREA1_INPUT_WORDS = 256 # 0x0000-0x00FF
AREA2_INPUT_WORDS = 764 # 0x6000-0x62FB (%IW256-%IW1020)
AREA1_OUTPUT_WORDS = 256 # 0x0200-0x02FF mirror of %QW0-255
AREA2_OUTPUT_WORDS = 764 # 0x7000-0x72FB mirror of %QW256-1020
WORD_IN1_BASE = 0x0000
WORD_IN2_BASE = 0x6000
WORD_OUT1_BASE = 0x0000
WORD_OUT1_MIRROR_BASE = 0x0200
WORD_OUT2_BASE = 0x6000
WORD_OUT2_MIRROR_BASE = 0x7000
WORD_CONFIG_BASE = 0x1000
WORD_CONFIG_END = 0x2FFF
# --- Bit area geometry ------------------------------------------------------
BIT_IN1_BASE = 0x0000 # 0x0000-0x01FF DI 1-512
BIT_IN1_COUNT = 512
BIT_OUT1_BASE = 0x0200 # 0x0200-0x03FF DO 1-512
BIT_OUT1_COUNT = 512
BIT_IN2_BASE = 0x8000 # 0x8000-0x85F7 DI 513-2039
BIT_IN2_COUNT = 1527
BIT_OUT2_BASE = 0x9000 # 0x9000-0x95F7 DO 513-2039
BIT_OUT2_COUNT = 1527
DIAG_READ_LIMIT = 125
DIAG_WRITE_LIMIT = 100
COIL_READ_LIMIT = 2000
COIL_WRITE_LIMIT = 800
class AddressError(Exception):
"""Modbus address outside any mapped area (exception 0x02)."""
def __init__(self, address: int) -> None:
self.address = address
super().__init__(f"illegal address: {address:#06x}")
class ConfigRegisterError(Exception):
"""A configuration-register operation was rejected."""
class ConfigRegisters(Protocol):
"""Interface to the configuration-register bank (0x1000-0x2FFF)."""
def read_config(self, address: int, count: int) -> list[int]: ...
def write_config(self, address: int, values: list[int]) -> None: ...
class WagoDatastore:
"""Holds both process images and dispatches word/bit accesses.
The process images are plain word arrays; digital data is packed into
words using the Intel format (manual §7.3.3): channel 1 = bit 0 of
the low byte D0.
"""
def __init__(self, *, config_bank: ConfigRegisters) -> None:
self._config = config_bank
self._in_words = [0] * (AREA1_INPUT_WORDS + AREA2_INPUT_WORDS)
self._out_words = [0] * (AREA1_OUTPUT_WORDS + AREA2_OUTPUT_WORDS)
self._listeners: list[Callable[[], None]] = []
# Digital channels are byte-packed AFTER the complex (analog)
# modules in the image (manual §7.3.2.1): bit address g (channel
# number - 1) lives at word offset + g//16, bit g%16.
self._digital_in_word_offset = 0
self._digital_out_word_offset = 0
def set_digital_word_offsets(self, in_words: int, out_words: int) -> None:
"""Tell the datastore where the byte-packed digital area starts.
Called by the VirtualNode after layout: `in_words`/`out_words`
are the total analog (word-oriented) module word counts.
"""
self._digital_in_word_offset = in_words
self._digital_out_word_offset = out_words
# -- subscription (scenario engine observes output writes) --------------
def add_listener(self, fn: Callable[[], None]) -> None:
self._listeners.append(fn)
def _notify(self) -> None:
for fn in self._listeners:
fn()
# -- process image access (scenario engine / node side) ------------------
@property
def input_words(self) -> list[int]:
"""Simulated inputs (PII), indices 0..1019 (word 0..255 area 1)."""
return self._in_words
@property
def output_words(self) -> list[int]:
"""PLC-written outputs (PIO), indices 0..1019."""
return self._out_words
def set_input_word(self, index: int, value: int) -> None:
self._validate_index(index, len(self._in_words))
self._in_words[index] = value & 0xFFFF
def get_input_word(self, index: int) -> int:
self._validate_index(index, len(self._in_words))
return self._in_words[index]
def set_input_bit(self, index: int, bit: int, value: bool) -> None:
self._validate_index(index, len(self._in_words))
if value:
self._in_words[index] |= 1 << bit
else:
self._in_words[index] &= ~(1 << bit) & 0xFFFF
def get_input_bit(self, index: int, bit: int) -> bool:
return bool(self._in_words[index] & (1 << bit))
def get_output_bit(self, index: int, bit: int) -> bool:
return bool(self._out_words[index] & (1 << bit))
def get_output_word(self, index: int) -> int:
return self._out_words[index]
def clear_outputs(self) -> None:
"""Watchdog timeout reaction: analog->0, digital->OFF."""
self._out_words = [0] * len(self._out_words)
self._notify()
# -- word area dispatch ---------------------------------------------------
def read_words(self, address: int, count: int) -> list[int] | ExceptionResponse:
if not 1 <= count <= DIAG_READ_LIMIT:
return ExceptionResponse(0x03, EXC_ILLEGAL_DATA_VALUE)
try:
return self._read_words_impl(address, count)
except AddressError:
return ExceptionResponse(0x03, EXC_ILLEGAL_DATA_ADDRESS)
def _read_words_impl(self, address: int, count: int) -> list[int]:
end = address + count
if WORD_IN1_BASE <= address and end <= WORD_IN1_BASE + AREA1_INPUT_WORDS:
return self._in_words[address - WORD_IN1_BASE:end - WORD_IN1_BASE]
if WORD_OUT1_MIRROR_BASE <= address and end <= WORD_OUT1_MIRROR_BASE + AREA1_OUTPUT_WORDS:
off = address - WORD_OUT1_MIRROR_BASE
return self._out_words[off:off + count]
if WORD_CONFIG_BASE <= address and end <= WORD_CONFIG_END + 1:
return self._config.read_config(address, count)
if WORD_IN2_BASE <= address and end <= WORD_IN2_BASE + AREA2_INPUT_WORDS:
off = address - WORD_IN2_BASE + AREA1_INPUT_WORDS
return self._in_words[off:off + count]
if WORD_OUT2_MIRROR_BASE <= address and end <= WORD_OUT2_MIRROR_BASE + AREA2_OUTPUT_WORDS:
off = address - WORD_OUT2_MIRROR_BASE + AREA1_OUTPUT_WORDS
return self._out_words[off:off + count]
raise AddressError(address)
def write_words(self, address: int, values: list[int]) -> None | ExceptionResponse:
if not 1 <= len(values) <= DIAG_WRITE_LIMIT:
return ExceptionResponse(0x03, EXC_ILLEGAL_DATA_VALUE)
try:
self._write_words_impl(address, values)
return None
except AddressError:
return ExceptionResponse(0x03, EXC_ILLEGAL_DATA_ADDRESS)
def _write_words_impl(self, address: int, values: list[int]) -> None:
count = len(values)
end = address + count
if WORD_OUT1_BASE <= address and end <= WORD_OUT1_BASE + AREA1_OUTPUT_WORDS:
self._out_words[address - WORD_OUT1_BASE:end - WORD_OUT1_BASE] = [
v & 0xFFFF for v in values
]
self._notify()
return
if WORD_OUT1_MIRROR_BASE <= address and end <= WORD_OUT1_MIRROR_BASE + AREA1_OUTPUT_WORDS:
off = address - WORD_OUT1_MIRROR_BASE
self._out_words[off:off + count] = [v & 0xFFFF for v in values]
self._notify()
return
if WORD_CONFIG_BASE <= address and end <= WORD_CONFIG_END + 1:
self._config.write_config(address, values)
return
if WORD_OUT2_BASE <= address and end <= WORD_OUT2_BASE + AREA2_OUTPUT_WORDS:
off = address - WORD_OUT2_BASE + AREA1_OUTPUT_WORDS
self._out_words[off:off + count] = [v & 0xFFFF for v in values]
self._notify()
return
if WORD_OUT2_MIRROR_BASE <= address and end <= WORD_OUT2_MIRROR_BASE + AREA2_OUTPUT_WORDS:
off = address - WORD_OUT2_MIRROR_BASE + AREA1_OUTPUT_WORDS
self._out_words[off:off + count] = [v & 0xFFFF for v in values]
self._notify()
return
raise AddressError(address)
def mask_write_word(self, address: int, and_mask: int, or_mask: int) -> int | ExceptionResponse:
"""FC22: result = (current AND and_mask) OR (or_mask AND NOT and_mask).
The 'current' value is the register under the WRITE mapping
(FC6/FC16, Table 120): address 0x0000-0x00FF is %QW0-255.
"""
try:
current = self._read_write_target(address)
except AddressError:
return ExceptionResponse(0x03, EXC_ILLEGAL_DATA_ADDRESS)
value = (current & and_mask) | (or_mask & ~and_mask & 0xFFFF)
result = self.write_words(address, [value])
if isinstance(result, ExceptionResponse):
return result
return value
def _read_write_target(self, address: int) -> int:
"""Current value at `address` under the write (FC6/FC16) mapping."""
if WORD_OUT1_BASE <= address < WORD_OUT1_BASE + AREA1_OUTPUT_WORDS:
return self._out_words[address - WORD_OUT1_BASE]
if (WORD_OUT1_MIRROR_BASE <= address
< WORD_OUT1_MIRROR_BASE + AREA1_OUTPUT_WORDS):
return self._out_words[address - WORD_OUT1_MIRROR_BASE]
if WORD_CONFIG_BASE <= address <= WORD_CONFIG_END:
return self._config.read_config(address, 1)[0]
if WORD_OUT2_BASE <= address < WORD_OUT2_BASE + AREA2_OUTPUT_WORDS:
return self._out_words[address - WORD_OUT2_BASE + AREA1_OUTPUT_WORDS]
if (WORD_OUT2_MIRROR_BASE <= address
< WORD_OUT2_MIRROR_BASE + AREA2_OUTPUT_WORDS):
return self._out_words[
address - WORD_OUT2_MIRROR_BASE + AREA1_OUTPUT_WORDS
]
raise AddressError(address)
# -- bit area dispatch -----------------------------------------------------
def read_bits(self, address: int, count: int) -> list[bool] | ExceptionResponse:
"""FC1/FC2 bit reads: the ADDRESS selects the area (Table 121).
0x0000-0x01FF -> inputs (channels 1-512),
0x0200-0x03FF -> outputs (read-back mirror),
0x8000-0x85F7 -> inputs 513-2039,
0x9000-0x95F7 -> outputs 513-2039.
"""
if not 1 <= count <= COIL_READ_LIMIT:
return ExceptionResponse(0x03, EXC_ILLEGAL_DATA_VALUE)
try:
return self._read_bits_impl(address, count)
except AddressError:
return ExceptionResponse(0x03, EXC_ILLEGAL_DATA_ADDRESS)
def _read_bits_impl(self, address: int, count: int) -> list[bool]:
direction, g0, area_bits = self._bit_read_area(address)
if g0 + count > area_bits:
raise AddressError(address) # spans into an illegal area
words, offset = self._direction_arrays(direction)
bits: list[bool] = []
for i in range(count):
g = g0 + i
word = offset + g // 16
if word >= len(words):
raise AddressError(address) # beyond configured image
bits.append(bool(words[word] & (1 << (g % 16))))
return bits
def write_bits(self, address: int, values: list[bool]) -> None | ExceptionResponse:
if not 1 <= len(values) <= COIL_WRITE_LIMIT:
return ExceptionResponse(0x03, EXC_ILLEGAL_DATA_VALUE)
try:
self._write_bits_impl(address, values)
return None
except AddressError:
return ExceptionResponse(0x03, EXC_ILLEGAL_DATA_ADDRESS)
def _write_bits_impl(self, address: int, values: list[bool]) -> None:
direction, g0, area_bits = self._bit_write_area(address)
if g0 + len(values) > area_bits:
raise AddressError(address) # spans into an illegal area
words, offset = self._direction_arrays(direction)
for i, value in enumerate(values):
g = g0 + i
word = offset + g // 16
if word >= len(words):
raise AddressError(address) # beyond configured image
if value:
words[word] |= 1 << (g % 16)
else:
words[word] &= ~(1 << (g % 16)) & 0xFFFF
self._notify()
def _direction_arrays(self, direction: str) -> tuple[list[int], int]:
if direction == "in":
return self._in_words, self._digital_in_word_offset
return self._out_words, self._digital_out_word_offset
def _bit_read_area(self, address: int) -> tuple[str, int, int]:
"""Map a bit-read address to (direction, global bit index, area size).
The global bit index is the digital channel number - 1; storage
word = digital offset + g//16 (byte-packing, §7.3.2.1).
"""
if BIT_IN1_BASE <= address < BIT_IN1_BASE + BIT_IN1_COUNT:
return "in", address, BIT_IN1_COUNT
if BIT_OUT1_BASE <= address < BIT_OUT1_BASE + BIT_OUT1_COUNT:
return "out", address - BIT_OUT1_BASE, BIT_OUT1_COUNT
if BIT_IN2_BASE <= address < BIT_IN2_BASE + BIT_IN2_COUNT:
return "in", BIT_IN1_COUNT + (address - BIT_IN2_BASE), BIT_IN2_COUNT
if BIT_OUT2_BASE <= address < BIT_OUT2_BASE + BIT_OUT2_COUNT:
return "out", BIT_OUT1_COUNT + (address - BIT_OUT2_BASE), BIT_OUT2_COUNT
raise AddressError(address)
def _bit_write_area(self, address: int) -> tuple[str, int, int]:
"""Map a bit-write address to (direction, global bit index, area size).
Table 122: ALL bit writes target OUTPUT areas —
0x0000-0x01FF and 0x0200-0x03FF -> outputs 1-512,
0x8000-0x85F7 and 0x9000-0x95F7 -> outputs 513-2039.
"""
if address < BIT_IN2_BASE:
if address < BIT_IN1_COUNT:
return "out", address, BIT_IN1_COUNT
if BIT_OUT1_BASE <= address < BIT_OUT1_BASE + BIT_OUT1_COUNT:
return "out", address - BIT_OUT1_BASE, BIT_OUT1_COUNT
raise AddressError(address)
if BIT_IN2_BASE <= address < BIT_IN2_BASE + BIT_IN2_COUNT:
return "out", BIT_IN1_COUNT + (address - BIT_IN2_BASE), BIT_IN2_COUNT
if BIT_OUT2_BASE <= address < BIT_OUT2_BASE + BIT_OUT2_COUNT:
return "out", BIT_OUT1_COUNT + (address - BIT_OUT2_BASE), BIT_OUT2_COUNT
raise AddressError(address)
# -- helpers for the scenario engine ---------------------------------------
def digital_input_channel(self, channel: int) -> bool:
"""Digital input channel number (1-based) -> state."""
return self._bit_value(
self._in_words, self._digital_in_word_offset, channel - 1
)
def set_digital_input_channel(self, channel: int, value: bool) -> None:
self._set_bit(
self._in_words, self._digital_in_word_offset, channel - 1, value
)
def digital_output_channel(self, channel: int) -> bool:
return self._bit_value(
self._out_words, self._digital_out_word_offset, channel - 1
)
@staticmethod
def _bit_value(words: list[int], offset: int, bit_index: int) -> bool:
return bool(words[offset + bit_index // 16] & (1 << (bit_index % 16)))
@staticmethod
def _set_bit(words: list[int], offset: int, bit_index: int, value: bool) -> None:
w = offset + bit_index // 16
if value:
words[w] = words[w] | (1 << (bit_index % 16))
else:
words[w] = words[w] & ~(1 << (bit_index % 16)) & 0xFFFF
@staticmethod
def _validate_index(index: int, size: int) -> None:
if not 0 <= index < size:
raise IndexError(f"word index out of range: {index}")
+125
View File
@@ -0,0 +1,125 @@
"""MBAP (Modbus Application Protocol) header codec for Modbus TCP.
Frame layout (7-byte header + PDU):
Bytes 0-1 Transaction identifier (echoed by server)
Bytes 2-3 Protocol identifier (0 for Modbus)
Bytes 4-5 Length field (bytes following, i.e. unit id + PDU)
Byte 6 Unit identifier
Bytes 7.. PDU (function code + data), max 253 bytes
"""
from __future__ import annotations
from dataclasses import dataclass
MODBUS_PORT = 502
PROTOCOL_ID = 0
MAX_PDU_SIZE = 253
MBAP_HEADER_SIZE = 7
class MbapError(ValueError):
"""Raised for structurally invalid MBAP frames."""
@dataclass(slots=True, frozen=True)
class MbapHeader:
"""Decoded MBAP header."""
transaction_id: int
protocol_id: int
length: int
unit_id: int
@property
def pdu_size(self) -> int:
"""Bytes of PDU following the header (length - 1)."""
return self.length - 1
def encode(self) -> bytes:
if not 0 <= self.transaction_id <= 0xFFFF:
raise MbapError(f"transaction id out of range: {self.transaction_id}")
if self.protocol_id != PROTOCOL_ID:
raise MbapError(f"protocol id must be 0, got {self.protocol_id}")
if not 1 <= self.length <= MAX_PDU_SIZE + 1:
raise MbapError(f"length out of range: {self.length}")
return (
self.transaction_id.to_bytes(2, "big")
+ self.protocol_id.to_bytes(2, "big")
+ self.length.to_bytes(2, "big")
+ self.unit_id.to_bytes(1, "big")
)
@staticmethod
def decode(buf: bytes) -> MbapHeader:
"""Decode a complete frame buffer (header + PDU)."""
if len(buf) < MBAP_HEADER_SIZE:
raise MbapError(f"frame too short for MBAP header: {len(buf)} bytes")
transaction_id = int.from_bytes(buf[0:2], "big")
protocol_id = int.from_bytes(buf[2:4], "big")
length = int.from_bytes(buf[4:6], "big")
unit_id = buf[6]
if protocol_id != PROTOCOL_ID:
raise MbapError(f"protocol id != 0: {protocol_id}")
if length < 2:
raise MbapError(f"length field too small: {length}")
if length - 1 > MAX_PDU_SIZE:
raise MbapError(f"length field too large: {length}")
if len(buf) < MBAP_HEADER_SIZE + length - 1:
raise MbapError(
f"frame incomplete: header announces {length - 1} PDU bytes, "
f"got {len(buf) - MBAP_HEADER_SIZE}"
)
return MbapHeader(transaction_id, protocol_id, length, unit_id)
def build_frame(transaction_id: int, unit_id: int, pdu: bytes) -> bytes:
"""Build a complete Modbus TCP frame from header fields and a PDU."""
header = MbapHeader(
transaction_id=transaction_id,
protocol_id=PROTOCOL_ID,
length=1 + len(pdu),
unit_id=unit_id,
)
return header.encode() + pdu
class MbapFrameReader:
"""Incremental frame reassembly from a byte stream.
Feed arbitrary chunks with :meth:`feed`; extract complete frames
with :meth:`pop_frame` until it returns ``None``.
"""
def __init__(self, max_frame_size: int = MBAP_HEADER_SIZE + MAX_PDU_SIZE) -> None:
self._buf = bytearray()
self._max = max_frame_size
def feed(self, chunk: bytes) -> None:
self._buf.extend(chunk)
if len(self._buf) > self._max * 4: # generous garbage guard
raise MbapError(
f"receive buffer exceeded {self._max * 4} bytes without a valid frame"
)
def pending(self) -> int:
return len(self._buf)
def pop_frame(self) -> tuple[MbapHeader, bytes] | None:
"""Return the next complete (header, pdu) or None if incomplete."""
if len(self._buf) < MBAP_HEADER_SIZE:
return None
protocol_id = int.from_bytes(self._buf[2:4], "big")
length = int.from_bytes(self._buf[4:6], "big")
if protocol_id != PROTOCOL_ID:
raise MbapError(f"protocol id != 0: {protocol_id}")
if length < 2 or length - 1 > MAX_PDU_SIZE:
raise MbapError(f"invalid length field: {length}")
total = MBAP_HEADER_SIZE + length - 1
if len(self._buf) < total:
return None
header = MbapHeader.decode(bytes(self._buf[:total]))
pdu = bytes(self._buf[MBAP_HEADER_SIZE:total])
del self._buf[:total]
return header, pdu
+281
View File
@@ -0,0 +1,281 @@
"""Virtual WAGO I/O modules and process-image layout.
The 750-362 builds its process image as follows (manual §7.3.2.1):
1. Complex (word-oriented) modules are laid out first, in physical
order, starting at word 0 — separately for inputs and outputs.
2. Bit-oriented modules follow, byte-packed in physical order; a byte
fills with channels until 8 bits are complete, then the next byte
starts.
Digital Modbus bit addresses equal the *channel numbers*, independent
of the physical layout (manual §11.2.4): DI channel 47 -> bit address 46.
This module provides a small catalog of common WAGO 750 modules with
their process-data widths, and :class:`VirtualNode`, which maps a
configured module list onto the datastore's process images.
"""
from __future__ import annotations
from dataclasses import dataclass
from .datastore import WagoDatastore
@dataclass(frozen=True)
class Channel:
"""One I/O channel of a module."""
name: str
kind: str # "di" | "do" | "ai" | "ao"
@dataclass(frozen=True)
class ModuleType:
"""Process-data description of a WAGO I/O module."""
article: str # e.g. "750-430"
description: str
input_words: int # word-oriented input data (analog modules)
output_words: int # word-oriented output data (analog modules)
input_bits: int # bit-oriented input channels (digital)
output_bits: int # bit-oriented output channels (digital)
channels: tuple[Channel, ...] = ()
@property
def is_complex(self) -> bool:
"""Word-oriented modules are laid out first in the image."""
return self.input_words > 0 or self.output_words > 0
# --- catalog (subset; extend per project needs) ------------------------------
DI_2 = ModuleType("750-400", "2-channel DI 24VDC", 0, 0, 2, 0,
(Channel("DI 1", "di"), Channel("DI 2", "di")))
DI_4 = ModuleType("750-402", "4-channel DI 24VDC", 0, 0, 4, 0,
tuple(Channel(f"DI {i}", "di") for i in range(1, 5)))
DI_8 = ModuleType("750-430", "8-channel DI 24VDC", 0, 0, 8, 0,
tuple(Channel(f"DI {i}", "di") for i in range(1, 9)))
DI_16 = ModuleType("750-1405", "16-channel DI 24VDC", 0, 0, 16, 0,
tuple(Channel(f"DI {i}", "di") for i in range(1, 17)))
DO_2 = ModuleType("750-501", "2-channel DO 24VDC", 0, 0, 0, 2,
(Channel("DO 1", "do"), Channel("DO 2", "do")))
DO_4 = ModuleType("750-504", "4-channel DO 24VDC", 0, 0, 0, 4,
tuple(Channel(f"DO {i}", "do") for i in range(1, 5)))
DO_8 = ModuleType("750-530", "8-channel DO 24VDC", 0, 0, 0, 8,
tuple(Channel(f"DO {i}", "do") for i in range(1, 9)))
DO_16 = ModuleType("750-1516", "16-channel DO 24VDC", 0, 0, 0, 16,
tuple(Channel(f"DO {i}", "do") for i in range(1, 17)))
AI_1 = ModuleType("750-461", "1-channel AI Pt100", 1, 0, 0, 0,
(Channel("AI 1", "ai"),))
AI_2 = ModuleType("750-467", "2-channel AI 4-20mA", 2, 0, 0, 0,
tuple(Channel(f"AI {i}", "ai") for i in range(1, 3)))
AI_4 = ModuleType("750-466", "4-channel AI 0-10V", 4, 0, 0, 0,
tuple(Channel(f"AI {i}", "ai") for i in range(1, 5)))
AO_2 = ModuleType("750-554", "2-channel AO 4-20mA", 0, 2, 0, 0,
tuple(Channel(f"AO {i}", "ao") for i in range(1, 3)))
AO_4 = ModuleType("750-556", "4-channel AO 0-10V", 0, 4, 0, 0,
tuple(Channel(f"AO {i}", "ao") for i in range(1, 5)))
CATALOG: dict[str, ModuleType] = {m.article: m for m in (
DI_2, DI_4, DI_8, DI_16, DO_2, DO_4, DO_8, DO_16,
AI_1, AI_2, AI_4, AO_2, AO_4,
)}
def resolve_module(name: str) -> ModuleType:
"""Look up a module by article number (e.g. '750-430')."""
try:
return CATALOG[name]
except KeyError:
raise ModuleUnknown(name) from None
class ModuleUnknown(ValueError):
def __init__(self, name: str) -> None:
self.name = name
super().__init__(
f"unknown module {name!r}; known: {', '.join(sorted(CATALOG))}"
)
class ChannelMismatch(ValueError):
"""The requested channel kind does not exist on the module."""
@dataclass
class ChannelLocation:
"""Resolved address of a channel for scenario aliases."""
kind: str # "di" | "do" | "ai" | "ao"
module_index: int # position in the node's module list
channel: int # 1-based channel number on the module
word: int = -1 # for analog channels: word index in the image
bit: int = -1 # for digital channels: global bit index (0-based)
class VirtualNode:
"""A configured bus coupler with its I/O modules.
Computes the WAGO process-image layout for a module list and binds
it to a :class:`WagoDatastore`.
"""
def __init__(self, datastore: WagoDatastore, modules: list[ModuleType]) -> None:
self.datastore = datastore
self.modules = modules
self._di_channels: list[ChannelLocation] = []
self._do_channels: list[ChannelLocation] = []
self._ai_channels: list[ChannelLocation] = []
self._ao_channels: list[ChannelLocation] = []
self._layout()
def _layout(self) -> None:
complex_mods = [m for m in self.modules if m.is_complex]
bit_mods = [m for m in self.modules if not m.is_complex]
# --- word area (analog modules first, physical order) -------------
in_word = 0
out_word = 0
analog_in_bits = 0
analog_out_bits = 0
for m in complex_mods:
for c in range(m.input_words):
self._ai_channels.append(
ChannelLocation("ai", self.modules.index(m), c + 1, word=in_word)
)
in_word += 1
analog_in_bits += 16
for c in range(m.output_words):
self._ao_channels.append(
ChannelLocation("ao", self.modules.index(m), c + 1, word=out_word)
)
out_word += 1
analog_out_bits += 16
# --- bit area (digital modules byte-packed after analog words) -----
di_bit = 0
do_bit = 0
digital_in_bits = 0
digital_out_bits = 0
for m in bit_mods:
for c in range(m.input_bits):
self._di_channels.append(
ChannelLocation("di", self.modules.index(m), c + 1, bit=di_bit)
)
di_bit += 1
digital_in_bits += 1
for c in range(m.output_bits):
self._do_channels.append(
ChannelLocation("do", self.modules.index(m), c + 1, bit=do_bit)
)
do_bit += 1
digital_out_bits += 1
# tell the datastore where the byte-packed digital areas start:
# digital channels are stored AFTER the analog words (§7.3.2.1)
self.datastore.set_digital_word_offsets(
in_words=in_word, out_words=out_word
)
# register 0x1022-0x1025 report these sizes
self.analog_in_bits = analog_in_bits
self.analog_out_bits = analog_out_bits
self.digital_in_bits = digital_in_bits
self.digital_out_bits = digital_out_bits
def description(self) -> str:
"""Text for firmware-info register 0x2030 (module description)."""
return ", ".join(f"{i}:{m.article}" for i, m in enumerate(self.modules))
# -- named channel access (scenario engine API) -----------------------------
@property
def di(self) -> list[ChannelLocation]:
return self._di_channels
@property
def do(self) -> list[ChannelLocation]:
return self._do_channels
@property
def ai(self) -> list[ChannelLocation]:
return self._ai_channels
@property
def ao(self) -> list[ChannelLocation]:
return self._ao_channels
def channel(self, kind: str, index: int) -> ChannelLocation:
"""Access a channel by kind ('di'/'do'/'ai'/'ao') and 1-based index."""
pools = {"di": self._di_channels, "do": self._do_channels,
"ai": self._ai_channels, "ao": self._ao_channels}
pool = pools[kind]
if not 1 <= index <= len(pool):
raise IndexError(f"no {kind} channel {index} (have {len(pool)})")
return pool[index - 1]
def channel_by_module(self, kind: str, module: int, channel: int) -> ChannelLocation:
"""Access a channel by module position and channel number, both 0-based.
Addresses a channel as *module type + module number + channel
number*, e.g. DI0.1 = digital input, module slot 0, channel 1.
The module number is the position in the configured module list;
the channel number is the channel on that module (0-based), i.e.
DI0.1 selects the module's 2nd digital input.
The channel kind must match what the module actually provides —
DI0.1 on a module with no inputs is a configuration error.
"""
if not 0 <= module < len(self.modules):
raise IndexError(
f"no module {module} (node has {len(self.modules)} modules, "
f"numbered 0-{len(self.modules) - 1})"
)
mod = self.modules[module]
pool_map = {
"di": (self._di_channels, mod.input_bits),
"do": (self._do_channels, mod.output_bits),
"ai": (self._ai_channels, mod.input_words),
"ao": (self._ao_channels, mod.output_words),
}
if kind not in pool_map:
raise ValueError(f"unknown channel kind: {kind!r}")
pool, module_channel_count = pool_map[kind]
if module_channel_count == 0:
raise ChannelMismatch(
f"module {module} ({mod.article}) has no {kind.upper()} channels"
)
if not 0 <= channel < module_channel_count:
raise IndexError(
f"module {module} ({mod.article}) has no {kind.upper()} "
f"channel {channel} (has {module_channel_count}: 0-"
f"{module_channel_count - 1})"
)
# find the location: channels of one module are contiguous in the
# kind pool; locate the first channel of this module in the pool
for loc in pool:
if loc.module_index == module and loc.channel == channel + 1:
return loc
raise ChannelMismatch(
f"module {module} ({mod.article}) channel {channel} is not a "
f"{kind.upper()} channel"
)
# -- field-side setters (simulated world) -------------------------------------
def set_di(self, channel: int, value: bool) -> None:
"""Digital channels are global bit indexes (channel numbers - 1):
the datastore applies its digital word offset."""
self.datastore.set_digital_input_channel(channel, value)
def set_ai(self, channel: int, value: int) -> None:
loc = self.channel("ai", channel)
self.datastore.set_input_word(loc.word, value)
def get_do(self, channel: int) -> bool:
return self.datastore.digital_output_channel(channel)
def get_ao(self, channel: int) -> int:
loc = self.channel("ao", channel)
return self.datastore.get_output_word(loc.word)
+392
View File
@@ -0,0 +1,392 @@
"""Modbus PDU (Protocol Data Unit) codec.
Encodes/decodes request and response PDUs for the function codes
supported by the WAGO 750-362 coupler:
FC1 Read Coils (max 2000 bits)
FC2 Read Discrete Inputs (max 2000 bits)
FC3 Read Holding Registers (max 125 words)
FC4 Read Input Registers (max 125 words)
FC5 Write Single Coil
FC6 Write Single Register
FC11 Get Comm Event Count
FC15 Write Multiple Coils (max 800 bits)
FC16 Write Multiple Registers (max 100 words)
FC22 Mask Write Register
FC23 Read/Write Multiple Registers (125 read / 100 write)
"""
from __future__ import annotations
from dataclasses import dataclass, field
# Function codes supported by the WAGO 750-362 (manual §11.2.2)
FC_READ_COILS = 0x01
FC_READ_DISCRETE_INPUTS = 0x02
FC_READ_HOLDING_REGISTERS = 0x03
FC_READ_INPUT_REGISTERS = 0x04
FC_WRITE_SINGLE_COIL = 0x05
FC_WRITE_SINGLE_REGISTER = 0x06
FC_GET_COMM_EVENT_COUNT = 0x0B
FC_WRITE_MULTIPLE_COILS = 0x0F
FC_WRITE_MULTIPLE_REGISTERS = 0x10
FC_MASK_WRITE_REGISTER = 0x16
FC_READ_WRITE_MULTIPLE_REGISTERS = 0x17
SUPPORTED_FUNCTION_CODES = frozenset(
{
FC_READ_COILS,
FC_READ_DISCRETE_INPUTS,
FC_READ_HOLDING_REGISTERS,
FC_READ_INPUT_REGISTERS,
FC_WRITE_SINGLE_COIL,
FC_WRITE_SINGLE_REGISTER,
FC_GET_COMM_EVENT_COUNT,
FC_WRITE_MULTIPLE_COILS,
FC_WRITE_MULTIPLE_REGISTERS,
FC_MASK_WRITE_REGISTER,
FC_READ_WRITE_MULTIPLE_REGISTERS,
}
)
# Access limits per 750-362 (statistics counters TooManyRegisters/TooManyBits)
MAX_READ_BITS = 2000
MAX_WRITE_BITS = 800
MAX_READ_REGISTERS = 125
MAX_WRITE_REGISTERS = 100
# Exception codes (manual §11.2.3, Table 83)
EXC_ILLEGAL_FUNCTION = 0x01
EXC_ILLEGAL_DATA_ADDRESS = 0x02
EXC_ILLEGAL_DATA_VALUE = 0x03
EXC_SLAVE_DEVICE_FAILURE = 0x04
EXC_ACKNOWLEDGE = 0x05
EXC_SERVER_BUSY = 0x06
COIL_ON = 0xFF00
COIL_OFF = 0x0000
class PduError(ValueError):
"""Raised for structurally invalid PDUs."""
@dataclass(slots=True, frozen=True)
class ReadRequest:
"""FC1/2/3/4 common request shape."""
function_code: int
address: int
count: int
@dataclass(slots=True, frozen=True)
class WriteSingleCoilRequest:
function_code: int = FC_WRITE_SINGLE_COIL
address: int = 0
value: bool = False
@dataclass(slots=True, frozen=True)
class WriteSingleRegisterRequest:
function_code: int = FC_WRITE_SINGLE_REGISTER
address: int = 0
value: int = 0
@dataclass(slots=True, frozen=True)
class WriteMultipleCoilsRequest:
function_code: int = FC_WRITE_MULTIPLE_COILS
address: int = 0
values: tuple[bool, ...] = field(default=())
@dataclass(slots=True, frozen=True)
class WriteMultipleRegistersRequest:
function_code: int = FC_WRITE_MULTIPLE_REGISTERS
address: int = 0
values: tuple[int, ...] = field(default=())
@dataclass(slots=True, frozen=True)
class MaskWriteRegisterRequest:
function_code: int = FC_MASK_WRITE_REGISTER
address: int = 0
and_mask: int = 0xFFFF
or_mask: int = 0x0000
@dataclass(slots=True, frozen=True)
class ReadWriteMultipleRegistersRequest:
function_code: int = FC_READ_WRITE_MULTIPLE_REGISTERS
read_address: int = 0
read_count: int = 0
write_address: int = 0
values: tuple[int, ...] = field(default=())
@dataclass(slots=True, frozen=True)
class GetCommEventCountRequest:
function_code: int = FC_GET_COMM_EVENT_COUNT
Request = (
ReadRequest
| WriteSingleCoilRequest
| WriteSingleRegisterRequest
| WriteMultipleCoilsRequest
| WriteMultipleRegistersRequest
| MaskWriteRegisterRequest
| ReadWriteMultipleRegistersRequest
| GetCommEventCountRequest
)
class ReadResponse:
"""Response for FC1/2 (bits) or FC3/4 (words)."""
__slots__ = ("function_code", "bits", "registers")
def __init__(self, function_code: int, bits: list[bool] | None = None,
registers: list[int] | None = None) -> None:
self.function_code = function_code
self.bits = bits if bits is not None else []
self.registers = registers if registers is not None else []
def encode(self) -> bytes:
if self.function_code in (FC_READ_COILS, FC_READ_DISCRETE_INPUTS):
payload = self._pack_bits(self.bits)
return bytes([self.function_code, len(payload)]) + payload
data = b"".join(v.to_bytes(2, "big") for v in self.registers)
byte_count = len(data)
if byte_count != 2 * len(self.registers):
raise PduError("register count mismatch")
return bytes([self.function_code, byte_count]) + data
@staticmethod
def _pack_bits(bits: list[bool]) -> bytes:
out = bytearray((len(bits) + 7) // 8)
for i, bit in enumerate(bits):
if bit:
out[i // 8] |= 1 << (i % 8)
return bytes(out)
class WriteSingleCoilResponse:
__slots__ = ("function_code", "address", "value")
def __init__(self, address: int, value: bool) -> None:
self.function_code = FC_WRITE_SINGLE_COIL
self.address = address
self.value = value
def encode(self) -> bytes:
return (
bytes([self.function_code])
+ self.address.to_bytes(2, "big")
+ (COIL_ON if self.value else COIL_OFF).to_bytes(2, "big")
)
class WriteSingleRegisterResponse:
__slots__ = ("function_code", "address", "value")
def __init__(self, address: int, value: int) -> None:
self.function_code = FC_WRITE_SINGLE_REGISTER
self.address = address
self.value = value
def encode(self) -> bytes:
return (
bytes([self.function_code])
+ self.address.to_bytes(2, "big")
+ self.value.to_bytes(2, "big")
)
class WriteMultipleResponse:
__slots__ = ("function_code", "address", "count")
def __init__(self, function_code: int, address: int, count: int) -> None:
self.function_code = function_code
self.address = address
self.count = count
def encode(self) -> bytes:
return (
bytes([self.function_code])
+ self.address.to_bytes(2, "big")
+ self.count.to_bytes(2, "big")
)
class MaskWriteRegisterResponse:
__slots__ = ("function_code", "address", "and_mask", "or_mask")
def __init__(self, address: int, and_mask: int, or_mask: int) -> None:
self.function_code = FC_MASK_WRITE_REGISTER
self.address = address
self.and_mask = and_mask
self.or_mask = or_mask
def encode(self) -> bytes:
return (
bytes([self.function_code])
+ self.address.to_bytes(2, "big")
+ self.and_mask.to_bytes(2, "big")
+ self.or_mask.to_bytes(2, "big")
)
class GetCommEventCountResponse:
__slots__ = ("function_code", "status", "event_count")
def __init__(self, status: int = 0, event_count: int = 0) -> None:
self.function_code = FC_GET_COMM_EVENT_COUNT
self.status = status
self.event_count = event_count
def encode(self) -> bytes:
return (
bytes([self.function_code])
+ self.status.to_bytes(2, "big")
+ self.event_count.to_bytes(2, "big")
)
class ExceptionResponse:
__slots__ = ("function_code", "exception_code")
def __init__(self, function_code: int, exception_code: int) -> None:
self.function_code = function_code
self.exception_code = exception_code
def encode(self) -> bytes:
return bytes([self.function_code | 0x80, self.exception_code])
Response = (
ReadResponse
| WriteSingleCoilResponse
| WriteSingleRegisterResponse
| WriteMultipleResponse
| MaskWriteRegisterResponse
| GetCommEventCountResponse
| ExceptionResponse
)
def decode_request(pdu: bytes) -> Request:
"""Decode a request PDU.
Raises :class:`PduError` for structurally invalid PDUs
(the caller then answers with exception 0x03).
Unsupported function codes raise :class:`UnsupportedFunction`,
a subclass, so the server can reply 0x01 while counting BadFunction.
"""
if not pdu:
raise PduError("empty PDU")
fc = pdu[0]
if fc not in SUPPORTED_FUNCTION_CODES:
raise UnsupportedFunction(fc)
body = pdu[1:]
if fc in (FC_READ_COILS, FC_READ_DISCRETE_INPUTS, FC_READ_HOLDING_REGISTERS,
FC_READ_INPUT_REGISTERS):
if len(body) != 4:
raise PduError(f"FC{fc}: expected 4 data bytes, got {len(body)}")
address = int.from_bytes(body[0:2], "big")
count = int.from_bytes(body[2:4], "big")
max_count = (
MAX_READ_BITS if fc in (FC_READ_COILS, FC_READ_DISCRETE_INPUTS)
else MAX_READ_REGISTERS
)
if count < 1 or count > max_count:
raise IllegalDataValue(f"FC{fc}: count out of range: {count}")
return ReadRequest(fc, address, count)
if fc == FC_WRITE_SINGLE_COIL:
if len(body) != 4:
raise PduError(f"FC{fc}: expected 4 data bytes, got {len(body)}")
address = int.from_bytes(body[0:2], "big")
raw = int.from_bytes(body[2:4], "big")
if raw not in (COIL_ON, COIL_OFF):
raise IllegalDataValue(f"FC{fc}: invalid coil value: {raw:#06x}")
return WriteSingleCoilRequest(fc, address, raw == COIL_ON)
if fc == FC_WRITE_SINGLE_REGISTER:
if len(body) != 4:
raise PduError(f"FC{fc}: expected 4 data bytes, got {len(body)}")
address = int.from_bytes(body[0:2], "big")
value = int.from_bytes(body[2:4], "big")
return WriteSingleRegisterRequest(fc, address, value)
if fc == FC_GET_COMM_EVENT_COUNT:
if body:
raise PduError(f"FC{fc}: no data bytes expected, got {len(body)}")
return GetCommEventCountRequest(fc)
if fc == FC_WRITE_MULTIPLE_COILS:
if len(body) < 6:
raise PduError(f"FC{fc}: too short: {len(body)}")
address = int.from_bytes(body[0:2], "big")
count = int.from_bytes(body[2:4], "big")
byte_count = body[4]
if count < 1 or count > MAX_WRITE_BITS:
raise IllegalDataValue(f"FC{fc}: count out of range: {count}")
if byte_count != (count + 7) // 8 or len(body) != 5 + byte_count:
raise PduError(f"FC{fc}: byte count mismatch")
values = [
bool(body[5 + i // 8] & (1 << (i % 8))) for i in range(count)
]
return WriteMultipleCoilsRequest(fc, address, tuple(values))
if fc == FC_WRITE_MULTIPLE_REGISTERS:
if len(body) < 6:
raise PduError(f"FC{fc}: too short: {len(body)}")
address = int.from_bytes(body[0:2], "big")
count = int.from_bytes(body[2:4], "big")
byte_count = body[4]
if count < 1 or count > MAX_WRITE_REGISTERS:
raise IllegalDataValue(f"FC{fc}: count out of range: {count}")
if byte_count != 2 * count or len(body) != 5 + byte_count:
raise PduError(f"FC{fc}: byte count mismatch")
values = tuple(
int.from_bytes(body[5 + 2 * i:7 + 2 * i], "big") for i in range(count)
)
return WriteMultipleRegistersRequest(fc, address, values)
if fc == FC_MASK_WRITE_REGISTER:
if len(body) != 6:
raise PduError(f"FC{fc}: expected 6 data bytes, got {len(body)}")
address = int.from_bytes(body[0:2], "big")
and_mask = int.from_bytes(body[2:4], "big")
or_mask = int.from_bytes(body[4:6], "big")
return MaskWriteRegisterRequest(fc, address, and_mask, or_mask)
if fc == FC_READ_WRITE_MULTIPLE_REGISTERS:
if len(body) < 10:
raise PduError(f"FC{fc}: too short: {len(body)}")
read_address = int.from_bytes(body[0:2], "big")
read_count = int.from_bytes(body[2:4], "big")
write_address = int.from_bytes(body[4:6], "big")
write_count = int.from_bytes(body[6:8], "big")
byte_count = body[8] # 1-byte field per Modbus spec
if read_count < 1 or read_count > MAX_READ_REGISTERS:
raise IllegalDataValue(f"FC{fc}: read count out of range: {read_count}")
if write_count < 1 or write_count > MAX_WRITE_REGISTERS:
raise IllegalDataValue(f"FC{fc}: write count out of range: {write_count}")
if byte_count != 2 * write_count or len(body) != 9 + byte_count:
raise PduError(f"FC{fc}: byte count mismatch")
values = tuple(
int.from_bytes(body[9 + 2 * i:11 + 2 * i], "big")
for i in range(write_count)
)
return ReadWriteMultipleRegistersRequest(
fc, read_address, read_count, write_address, values
)
raise UnsupportedFunction(fc) # pragma: no cover
class UnsupportedFunction(PduError):
"""Function code not implemented by the 750-362."""
def __init__(self, function_code: int) -> None:
self.function_code = function_code
super().__init__(f"unsupported function code: {function_code:#04x}")
class IllegalDataValue(PduError):
"""Field value outside allowed range (exception 0x03 path)."""
+262
View File
@@ -0,0 +1,262 @@
"""Configuration, diagnostic, constant, and firmware-info register bank.
Implements the 0x1000-0x2FFF area of the WAGO 750-362 (manual §11.2.5).
Watchdog registers 0x1000-0x100B are delegated to :class:`ModbusWatchdog`.
"""
from __future__ import annotations
from collections.abc import Callable
from .stats import STATS_REGISTER, STATS_WORD_COUNT, ModbusStatistics
from .watchdog import ModbusWatchdog, RegisterWriteRefused
# --- register addresses -------------------------------------------------------
REG_LED_ERROR_CODE = 0x1020
REG_LED_ERROR_ARG = 0x1021
REG_ANALOG_OUT_BITS = 0x1022
REG_ANALOG_IN_BITS = 0x1023
REG_DIGITAL_OUT_BITS = 0x1024
REG_DIGITAL_IN_BITS = 0x1025
REG_TCP_CONNECTIONS = 0x102A
REG_KBUS_RESET = 0x102B
REG_TCP_IDLE_TIMEOUT = 0x1030
REG_MAC_ID = 0x1031
REG_RESPONSE_DELAY = 0x1037
REG_TOS = 0x1038
REG_MODULE_DIAG = 0x1050
# constants 0x2000-0x2008
CONSTANTS = {
0x2000: 0x0000,
0x2001: 0xFFFF,
0x2002: 0x1234,
0x2003: 0xAAAA,
0x2004: 0x5555,
0x2005: 0x7FFF,
0x2006: 0x8000,
0x2007: 0x3FFF,
0x2008: 0x4000,
}
# firmware info 0x2010-0x2043
REG_FW_REVISION = 0x2010
REG_FW_SERIES = 0x2011
REG_FW_ITEM = 0x2012
REG_FW_MAJOR = 0x2013
REG_FW_MINOR = 0x2014
REG_FW_DESCRIPTION = 0x2020 # up to 16 words
REG_FW_COMPILE_TIME = 0x2021 # up to 8 words
REG_FW_COMPILE_DATE = 0x2022 # up to 8 words
REG_FW_LOADER = 0x2023 # up to 32 words
REG_MODULE_DESC_0_64 = 0x2030 # 65 words
REG_PROCESS_IMAGE_SETTINGS = 0x2035
REG_DIAGNOSTICS = 0x2036
REG_SOFTWARE_RESET = 0x2040 # write 0x55AA/0xAA55
REG_FACTORY_SETTINGS = 0x2043
RESET_KEYS = (0x55AA, 0xAA55)
SERIES_CODE_750 = 0x02EE # 750 dec
COUPLER_CODE_362 = 0x016A # 362 dec
def _string_to_words(text: str, max_words: int) -> list[int]:
"""Pack ASCII into 16-bit words (2 chars/word, high byte first)."""
data = text.encode("ascii", errors="replace")[: 2 * max_words]
data = data.ljust(2 * max_words, b"\x00")
return [int.from_bytes(data[2 * i : 2 * i + 2], "big") for i in range(max_words)]
class ConfigRegisterBank:
"""Read/write dispatch for the 0x1000-0x2FFF configuration area."""
def __init__(
self,
*,
statistics: ModbusStatistics,
watchdog: ModbusWatchdog,
tcp_connection_counter: Callable[[], int],
mac_id: tuple[int, int, int, int, int, int] = (0x00, 0x30, 0xDE, 0x11, 0x22, 0x33),
response_delay_ms: int = 0,
idle_timeout_units: int = 600,
process_image_bits: tuple[int, int, int, int] = (0, 0, 0, 0),
firmware_description: str = "WAGO 750-362 Simulator",
on_software_reset: Callable[[], None] | None = None,
on_kbus_reset: Callable[[], None] | None = None,
) -> None:
# (analog_in_bits, analog_out_bits, digital_in_bits, digital_out_bits)
self._process_image_bits = process_image_bits
self._stats = statistics
self._watchdog = watchdog
self._tcp_connections = tcp_connection_counter
self._mac_id = mac_id
self._response_delay_ms = response_delay_ms
self._idle_timeout_units = idle_timeout_units
self._led_error_code = 0
self._led_error_arg = 0
self._fw = {
REG_FW_REVISION: 0x0001,
REG_FW_SERIES: SERIES_CODE_750,
REG_FW_ITEM: COUPLER_CODE_362,
REG_FW_MAJOR: 0x0000,
REG_FW_MINOR: 0x0001,
}
self._description = _string_to_words(firmware_description, 16)
self._compile_time = _string_to_words("10:00:00", 8)
self._compile_date = _string_to_words("2026-10-03", 8)
self._loader = _string_to_words("boot", 32)
self._module_desc: list[int] = [0] * 65
self._module_diag: tuple[int, int, int] = (0, 0, 0) # module, channel, diag
self._on_software_reset = on_software_reset
self._on_kbus_reset = on_kbus_reset
# -- image geometry (set by the node after module layout) -------------------
def set_process_image_bits(
self, analog_in: int, analog_out: int, digital_in: int, digital_out: int
) -> None:
self._process_image_bits = (analog_in, analog_out, digital_in, digital_out)
def set_module_description(self, text: str) -> None:
self._module_desc = _string_to_words(text, 65)
def set_led_error(self, code: int, argument: int = 0) -> None:
self._led_error_code = code & 0xFFFF
self._led_error_arg = argument & 0xFFFF
def set_module_diagnosis(self, module: int, channel: int, diagnosis: int) -> None:
"""Fault injection for register 0x1050."""
self._module_diag = (module & 0xFFFF, channel & 0xFFFF, diagnosis & 0xFFFF)
@property
def response_delay_ms(self) -> int:
return self._response_delay_ms
@property
def idle_timeout_ms(self) -> int:
return self._idle_timeout_units * 100
# -- register access -----------------------------------------------------------
def read_config(self, address: int, count: int) -> list[int]:
"""Read `count` words starting at `address`.
The 9-word statistics block (0x1029-0x1031) overlaps dedicated
registers in the manual (0x102A connections, 0x1031 MAC-ID):
a read *starting at* 0x1029 returns the statistics block as a
unit; individual addresses keep their dedicated meanings.
"""
out: list[int] = []
if address <= STATS_REGISTER and address + count > STATS_REGISTER:
out.extend(self._read_one(a) for a in range(address, STATS_REGISTER))
n_stats = min(count - len(out), STATS_WORD_COUNT)
out.extend(self._stats.as_words()[:n_stats])
rest_at = STATS_REGISTER + n_stats
out.extend(self._read_one(a) for a in range(rest_at, address + count))
return out
return [self._read_one(a) for a in range(address, address + count)]
def write_config(self, address: int, values: list[int]) -> None:
for i, value in enumerate(values):
self._write_one(address + i, value)
def _read_one(self, address: int) -> int:
# watchdog block
if 0x1000 <= address <= 0x100B:
return self._watchdog.read(address)
# diagnostics
if address == REG_LED_ERROR_CODE:
return self._led_error_code
if address == REG_LED_ERROR_ARG:
return self._led_error_arg
# image sizes
if address == REG_ANALOG_IN_BITS:
return self._process_image_bits[0] & 0xFFFF
if address == REG_ANALOG_OUT_BITS:
return self._process_image_bits[1] & 0xFFFF
if address == REG_DIGITAL_IN_BITS:
return self._process_image_bits[2] & 0xFFFF
if address == REG_DIGITAL_OUT_BITS:
return self._process_image_bits[3] & 0xFFFF
# statistics
if address == STATS_REGISTER:
return self._stats.slave_device_failure # first word
if address == REG_TCP_CONNECTIONS:
return self._tcp_connections()
if address == REG_KBUS_RESET:
return 0
if address == REG_TCP_IDLE_TIMEOUT:
return self._idle_timeout_units
if REG_MAC_ID <= address < REG_MAC_ID + 3:
return self._mac_id_word(address - REG_MAC_ID)
if address == REG_RESPONSE_DELAY:
return self._response_delay_ms
if address == REG_TOS:
return 0
if address == REG_MODULE_DIAG:
return self._module_diag[0]
if REG_MODULE_DIAG < address < REG_MODULE_DIAG + 3:
return self._module_diag[address - REG_MODULE_DIAG]
# constants
if address in CONSTANTS:
return CONSTANTS[address]
# firmware info
if address in self._fw:
return self._fw[address]
if REG_FW_DESCRIPTION <= address < REG_FW_DESCRIPTION + 16:
return self._description[address - REG_FW_DESCRIPTION]
if REG_FW_COMPILE_TIME <= address < REG_FW_COMPILE_TIME + 8:
return self._compile_time[address - REG_FW_COMPILE_TIME]
if REG_FW_COMPILE_DATE <= address < REG_FW_COMPILE_DATE + 8:
return self._compile_date[address - REG_FW_COMPILE_DATE]
if REG_FW_LOADER <= address < REG_FW_LOADER + 32:
return self._loader[address - REG_FW_LOADER]
if REG_MODULE_DESC_0_64 <= address < REG_MODULE_DESC_0_64 + 65:
return self._module_desc[address - REG_MODULE_DESC_0_64]
if address == REG_PROCESS_IMAGE_SETTINGS:
return 0
if address == REG_DIAGNOSTICS:
return 0
# undefined registers within the config area read as 0
return 0
def _mac_id_word(self, index: int) -> int:
"""Word i of the MAC-ID register (0x1031, 3 words).
The register exposes the first 3 MAC bytes as words (one byte per
word, per the manual's 3-word layout for 6-byte MACs).
"""
return self._mac_id[index] << 8
def _write_one(self, address: int, value: int) -> None:
value &= 0xFFFF
if 0x1000 <= address <= 0x100B:
self._watchdog.write(address, value)
return
if address == STATS_REGISTER:
self._stats.handle_reset_write(value)
return
if address == REG_TCP_IDLE_TIMEOUT:
self._idle_timeout_units = value
return
if address == REG_RESPONSE_DELAY:
self._response_delay_ms = value
return
if address == REG_TOS:
return # stored; echo on read is 0 in v1
if address == REG_KBUS_RESET:
if value in RESET_KEYS:
if self._on_kbus_reset:
self._on_kbus_reset()
return
if address == REG_SOFTWARE_RESET:
if value in RESET_KEYS:
if self._on_software_reset:
self._on_software_reset()
return
if address == REG_FACTORY_SETTINGS:
return
# remaining registers are read-only
raise RegisterWriteRefused(f"register {address:#06x} is read-only")
+1
View File
@@ -0,0 +1 @@
"""Scenario package: YAML DSL model, engine, actions, assertions, reporting."""
+89
View File
@@ -0,0 +1,89 @@
"""YAML scenario/node configuration loading and validation."""
from __future__ import annotations
from pathlib import Path
import yaml
from ..modules import ModuleType, resolve_module
from .engine import ScenarioError
def load_yaml(path: str | Path) -> dict:
path = Path(path)
with path.open("r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
if not isinstance(data, dict):
raise ScenarioError(f"{path}: top-level YAML must be a mapping")
return data
def load_node_modules(node_config: dict) -> list[ModuleType]:
"""Build the module list from a node configuration dict.
Accepted shape::
modules: ["750-430", "750-530", ...] # short form
# or
modules:
- article: "750-461"
count: 2
"""
modules: list[ModuleType] = []
entries = node_config.get("modules")
if not entries:
raise ScenarioError("node config needs a 'modules' list")
for entry in entries:
if isinstance(entry, str):
modules.append(resolve_module(entry))
elif isinstance(entry, dict):
article = entry.get("article")
if not article:
raise ScenarioError(f"module entry needs 'article': {entry!r}")
count = int(entry.get("count", 1))
module = resolve_module(str(article))
modules.extend([module] * count)
else:
raise ScenarioError(f"invalid module entry: {entry!r}")
if len(modules) > 64:
# 750-362 supports up to 64 I/O modules; keep the FAT bench realistic
raise ScenarioError(f"too many modules: {len(modules)} (max 64)")
return modules
def validate_scenario(scenario: dict) -> list[str]:
"""Structural validation; returns a list of problems (empty == valid)."""
problems: list[str] = []
if not scenario.get("name"):
problems.append("missing 'name'")
inputs = scenario.get("inputs") or {}
outputs = scenario.get("outputs") or {}
if not inputs and not outputs:
problems.append("no aliases defined under 'inputs'/'outputs'")
for group, kinds in (("inputs", {"di", "ai"}), ("outputs", {"do", "ao"})):
for name, spec in (scenario.get(group) or {}).items():
if not isinstance(spec, dict) or "type" not in spec or "channel" not in spec:
problems.append(f"{group}.{name}: needs 'type' and 'channel'")
continue
if spec["type"] not in kinds:
problems.append(
f"{group}.{name}: type {spec['type']!r} not in {sorted(kinds)}"
)
if "module" in spec:
# module-based addressing: both module and channel 0-based
if not isinstance(spec["module"], int) or spec["module"] < 0:
problems.append(
f"{group}.{name}: 'module' must be a non-negative "
f"integer (0-based module position)"
)
if not isinstance(spec["channel"], int) or spec["channel"] < 0:
problems.append(
f"{group}.{name}: 'channel' must be a non-negative "
f"integer (0-based channel on the module) when "
f"'module' is given"
)
seq = scenario.get("sequence") or []
if not seq and not scenario.get("states"):
problems.append("neither 'sequence' nor 'states' defined")
return problems
+569
View File
@@ -0,0 +1,569 @@
"""Scenario engine: DSL model and interpreter.
A scenario binds *named aliases* (start_button, pump_relay, ...) to node
channels, defines reusable *macros*, an optional *state machine*, and a
time-ordered *sequence* of steps with assertions. The engine observes the
PLC's output writes (through the datastore listener) and the simulated
inputs it drives itself, producing a PASS/FAIL report.
Execution model: a step queue is advanced one *blocking* step at a time.
Immediate steps (set/pulse/ramp/macro) are drained in the same tick;
`wait` and `assert` block until satisfied. Macros expand inline into the
queue, so their internal waits behave exactly like sequence waits.
See docs/spec/software-spec.md §4 for the YAML schema.
"""
from __future__ import annotations
import time
from collections import deque
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import Enum
from ..datastore import WagoDatastore
from ..modules import VirtualNode
class Verdict(Enum):
PENDING = "PENDING"
PASS = "PASS"
FAIL = "FAIL"
ERROR = "ERROR"
@dataclass(frozen=True)
class Alias:
"""A named channel binding.
Two addressing schemes (mutually exclusive in the spec):
- *module-based*: ``module`` + ``channel`` — module position and
channel number on the module, both 0-based (e.g. ``DI0.1`` =
digital input, module slot 0, channel 1).
- *pool-based*: ``channel`` alone — 1-based index within the node's
kind pool (all DI channels counted together).
"""
name: str
kind: str # di | do | ai | ao
channel: int # 0-based channel on the module (module addressing),
# or 1-based pool index (pool addressing)
scale: float = 1.0
offset: float = 0.0
module: int | None = None # 0-based module position; None = pool addressing
@dataclass
class StepResult:
step: str
verdict: Verdict
detail: str = ""
sim_time_ms: float = 0.0
@dataclass
class ScenarioResult:
name: str
verdict: Verdict = Verdict.PENDING
steps: list[StepResult] = field(default_factory=list)
started: float = 0.0
duration_ms: float = 0.0
@property
def passed(self) -> bool:
return self.verdict is Verdict.PASS
class ConditionMatcher:
"""Evaluates assertion conditions against alias values."""
def __init__(self, resolve: Callable[[str], tuple[str, object]]) -> None:
self._resolve = resolve
def evaluate(self, spec: dict) -> bool:
"""spec example: {'pump_relay': 'ON'}, {'speed_setpoint': '>= 1000'}"""
for alias_name, expected in spec.items():
kind, current = self._resolve(alias_name)
if not self._match_one(kind, current, expected):
return False
return True
def _match_one(self, kind: str, current: object, expected: object) -> bool:
if kind in ("di", "do"):
cur = bool(current)
if isinstance(expected, str):
exp = expected.strip().upper() in ("ON", "TRUE", "1", "YES")
return cur == exp
return cur == bool(expected)
# analog
cur_val = float(current)
if isinstance(expected, str):
text = expected.strip()
for op in (">=", "<=", "!=", "==", ">", "<"):
if text.startswith(op):
return self._compare(cur_val, op, float(text[len(op):]))
if text.startswith("="):
return self._compare(cur_val, "==", float(text[1:]))
return cur_val == float(text)
return cur_val == float(expected)
@staticmethod
def _compare(cur: float, op: str, ref: float) -> bool:
return {
">=": cur >= ref,
"<=": cur <= ref,
">": cur > ref,
"<": cur < ref,
"!=": cur != ref,
"==": cur == ref,
}[op]
class ScenarioEngine:
"""Interprets a scenario against a virtual node + datastore.
The engine is intentionally synchronous and tick-driven: the host
application (CLI server or test) pumps :meth:`pump` while sim time
advances; Modbus writes are observed via the datastore listener so
assertions see them without polling races.
"""
TICK_MS = 10
def __init__(self, node: VirtualNode, scenario: dict, *, name: str = "") -> None:
self.node = node
self.datastore: WagoDatastore = node.datastore
self.scenario = scenario
self.name = name or scenario.get("name", "scenario")
self.sim_time_ms = 0.0
self._result = ScenarioResult(name=self.name)
self._aliases: dict[str, Alias] = {}
self._parse_aliases()
self._matcher = ConditionMatcher(self._resolve_alias)
self._last_snapshot = self._snapshot_aliases()
self._edges: dict[str, bool] = {} # rising-edge latches per alias
self._ramps: list[dict] = [] # active ramp generators
self._step_queue: deque = deque(scenario.get("sequence", []) or [])
self._wait_until_ms: float | None = None
self._pending_assert: dict | None = None
self._assert_deadline_ms: float | None = None
self._pending_edge: dict | None = None
self._finished = False
self._macros: dict[str, list] = {
k: v for k, v in (scenario.get("macros") or {}).items()
}
self._macro_return_wait: float | None = None
self._plugin_actions = self._load_plugins()
# -- setup ---------------------------------------------------------------
def _parse_aliases(self) -> None:
for group in ("inputs", "outputs"):
for name, spec in (self.scenario.get(group) or {}).items():
kind = str(spec["type"]).lower()
module = spec.get("module")
if module is not None:
# module-based addressing (FR-4.2.11): module position
# and channel number both 0-based, e.g. DI0.1
alias = Alias(
name=name,
kind=kind,
channel=int(spec["channel"]),
scale=float(spec.get("scale", 1.0)),
offset=float(spec.get("offset", 0.0)),
module=int(module),
)
# validate the channel exists on that module
self.node.channel_by_module(kind, alias.module, alias.channel)
else:
# pool-based addressing: 1-based index within the pool
alias = Alias(
name=name,
kind=kind,
channel=int(spec["channel"]),
scale=float(spec.get("scale", 1.0)),
offset=float(spec.get("offset", 0.0)),
)
# validate channel exists
self.node.channel(alias.kind, alias.channel)
self._aliases[name] = alias
def _alias_location(self, alias: Alias):
"""Resolve an alias to its ChannelLocation on the node."""
if alias.module is not None:
return self.node.channel_by_module(alias.kind, alias.module, alias.channel)
return self.node.channel(alias.kind, alias.channel)
def alias_location(self, name: str):
"""Public: ChannelLocation for an alias (for plant-model plugins).
``loc.bit`` is the global 0-based digital channel index (== the
Modbus bit address); ``loc.word`` is the global analog word index.
"""
return self._alias_location(self._aliases[name])
def _load_plugins(self) -> dict[str, Callable]:
"""Python action plugins.
Two mechanisms:
- `plugins: [module.function]` in the scenario header registers
*per-tick* plant models (called every pump with the engine).
- Any step key matching a registered function name invokes it as
a step action (FR-4.3.8).
"""
plugins: dict[str, Callable] = {}
import importlib
for ref in self.scenario.get("plugins", []) or []:
mod_name, _, fn_name = ref.rpartition(".")
mod = importlib.import_module(mod_name)
fn = getattr(mod, fn_name)
plugins[fn_name] = fn
return plugins
# -- alias resolution ------------------------------------------------------
def _resolve_alias(self, name: str) -> tuple[str, object]:
"""Resolve an alias to (kind, current value) for condition matching."""
alias = self._aliases[name]
return alias.kind, self.alias_value(name)
def alias_value(self, name: str) -> object:
alias = self._aliases[name]
ds = self.datastore
loc = self._alias_location(alias)
if alias.kind == "di":
# loc.bit is the global 0-based digital channel index
return ds.digital_input_channel(loc.bit + 1)
if alias.kind == "do":
return ds.digital_output_channel(loc.bit + 1)
if alias.kind == "ai":
raw = ds.get_input_word(loc.word)
return (raw - alias.offset) / alias.scale
# ao
raw = ds.get_output_word(loc.word)
return (raw - alias.offset) / alias.scale
def set_alias(self, name: str, value: object) -> None:
alias = self._aliases[name]
loc = self._alias_location(alias)
if alias.kind == "di":
# loc.bit is the global 0-based digital channel index; the
# datastore API takes the 1-based global channel number
self.datastore.set_digital_input_channel(loc.bit + 1, bool(value))
elif alias.kind == "ai":
raw = int(float(value) * alias.scale + alias.offset)
self.datastore.set_input_word(loc.word, raw & 0xFFFF)
else:
raise ScenarioError(
f"alias {name!r} is {alias.kind!r}; only di/ai are scriptable inputs"
)
# -- edge detection ------------------------------------------------------------
def _snapshot_aliases(self) -> dict[str, object]:
"""Snapshot every alias (inputs and outputs alike) for edges."""
return {name: self.alias_value(name) for name in self._aliases}
def _detect_edges(self) -> None:
current = self._snapshot_aliases()
for name, now in current.items():
before = self._last_snapshot.get(name)
# treat a missing snapshot entry as False so the very first
# ON transition (e.g. right after a set) is detected as an edge
if not bool(before) and bool(now):
self._edges[name] = True
self._last_snapshot = current
def consume_rising_edge(self, name: str) -> bool:
if self._edges.get(name):
self._edges[name] = False
return True
return False
# -- ramp generation --------------------------------------------------------
def start_ramp(self, name: str, to: float, over_ms: float) -> None:
alias = self._aliases[name]
start = float(self.alias_value(name))
self._ramps.append(
{
"name": name,
"from": start,
"to": to,
"over_ms": over_ms,
"start_ms": self.sim_time_ms,
"alias": alias,
}
)
def _advance_ramps(self, dt_ms: float) -> None:
still_active: list[dict] = []
for ramp in self._ramps:
elapsed = self.sim_time_ms - ramp["start_ms"]
if ramp.get("_mode") == "pulse":
if elapsed >= ramp["over_ms"]:
self.set_alias(ramp["name"], False)
continue
still_active.append(ramp)
continue
if elapsed >= ramp["over_ms"]:
self.set_alias(ramp["name"], ramp["to"])
continue
frac = elapsed / ramp["over_ms"] if ramp["over_ms"] else 1.0
value = ramp["from"] + (ramp["to"] - ramp["from"]) * frac
self.set_alias(ramp["name"], value)
still_active.append(ramp)
self._ramps = still_active
# -- main loop -----------------------------------------------------------------
def start(self) -> None:
self._result.started = time.time()
self._result.verdict = Verdict.PENDING
def pump(self, dt_ms: float | None = None) -> ScenarioResult | None:
"""Advance the scenario by one tick. Returns result when finished."""
if self._finished:
return self._result
dt = dt_ms if dt_ms is not None else float(self.TICK_MS)
self.sim_time_ms += dt
# 1) detect alias edges (inputs set in the previous tick, outputs
# written by the master/plant)
self._detect_edges()
# 2) plant models react to edges and drive outputs
self._run_tick_plugins()
# 3) ramps advance the inputs they drive
self._advance_ramps(dt)
# blocking states first
if self._pending_assert is not None:
if self._matcher.evaluate(self._pending_assert["conditions"]):
self._record(
self._pending_assert["label"], Verdict.PASS,
f"matched at {self.sim_time_ms:.0f} ms",
)
self._pending_assert = None
self._assert_deadline_ms = None
elif self.sim_time_ms >= self._assert_deadline_ms:
self._record(
self._pending_assert["label"], Verdict.FAIL,
f"timeout after {self._pending_assert['within_ms']} ms",
)
self._finish(Verdict.FAIL)
return self._result
else:
return None # still waiting for the assertion
elif self._wait_until_ms is not None:
if self.sim_time_ms < self._wait_until_ms:
return None
self._wait_until_ms = None
# drain immediate steps until the queue blocks or empties
while not self._finished:
if not self._step_queue:
self._finish(Verdict.PASS)
break
blocked = self._run_next_step()
if blocked:
break
return None if not self._finished else self._result
def _run_tick_plugins(self) -> None:
"""Invoke plant-model plugins registered under `plugins:`."""
for ref in self.scenario.get("plugins", []) or []:
fn_name = ref.rpartition(".")[2]
fn = self._plugin_actions.get(fn_name)
if fn is not None:
fn(self, None)
def _run_next_step(self) -> bool:
"""Execute one queued step.
Returns True when the step blocks (wait/assert) and the engine
must wait for a later tick; False to continue draining.
"""
step = self._step_queue.popleft()
if not isinstance(step, dict):
raise ScenarioError(f"sequence step must be a mapping: {step!r}")
blocked = False
for key, value in step.items():
if key == "set":
for alias_name, v in value.items():
self.set_alias(alias_name, v)
elif key == "reset":
for alias_name in value:
kind = self._aliases[alias_name].kind
self.set_alias(alias_name, 0 if kind == "ai" else False)
elif key == "toggle":
for alias_name in value:
cur = self.alias_value(alias_name)
self.set_alias(alias_name, not cur)
elif key == "pulse":
for alias_name, duration in value.items():
self.set_alias(alias_name, True)
self._schedule_pulse_off(
alias_name, _parse_duration(duration)
)
elif key == "ramp":
self._apply_ramp(value)
elif key == "wait":
self._wait_until_ms = self.sim_time_ms + _parse_duration(value)
blocked = True
elif key == "assert":
passed_now = self._begin_assert(value)
blocked = not passed_now
elif key == "macro":
self._expand_macro(value)
elif key == "wait_edge":
self._pending_edge = value
blocked = True
elif key == "report":
self._finish(
Verdict.PASS if str(value).upper() == "PASS" else Verdict.FAIL
)
elif key in self._plugin_actions:
self._plugin_actions[key](self, value)
else:
raise ScenarioError(f"unknown sequence step: {key!r}")
return blocked
def _begin_assert(self, value: object) -> bool:
"""Arm an assertion. Returns True if it passed immediately."""
if isinstance(value, dict):
spec = dict(value) # copy: we pop keys
label = str(spec.pop("label", ""))
within = _parse_duration(spec.pop("within", 0))
conditions = spec.pop("conditions", spec) or {}
else:
label, within, conditions = "assert", 0, value
label = label or "assert"
# condition already satisfied? pass without blocking
if self._matcher.evaluate(conditions):
self._record(label, Verdict.PASS,
f"matched at {self.sim_time_ms:.0f} ms")
return True
# arm for later ticks; a within of 0 still allows one tick for
# the plant/PLC to react before the verdict
self._pending_assert = {
"label": label,
"conditions": conditions,
"within_ms": within,
}
self._assert_deadline_ms = self.sim_time_ms + max(within, self.TICK_MS)
return False
def _apply_ramp(self, value: dict) -> None:
# forms: {name: x, to: y, over: '30s'} or {'level_sensor': {to: , over: }}
if "name" in value:
name = str(value["name"])
to = float(value["to"])
over = _parse_duration(value.get("over", 0))
else:
name, spec = next(iter(value.items()))
to = float(spec["to"])
over = _parse_duration(spec.get("over", 0))
self.start_ramp(name, to, over)
def _schedule_pulse_off(self, alias_name: str, duration_ms: float) -> None:
"""Implement pulse: alias ON now, OFF after duration_ms (sim time)."""
self._ramps.append(
{
"name": alias_name,
"from": 1,
"to": 0,
"over_ms": duration_ms,
"start_ms": self.sim_time_ms,
"alias": self._aliases[alias_name],
"_mode": "pulse",
}
)
def _expand_macro(self, ref: str | dict) -> None:
"""Expand a macro inline into the step queue (waits inside macros
block exactly like sequence waits)."""
if isinstance(ref, dict):
name = str(ref.get("name"))
args = ref.get("args", {}) or {}
else:
name, args = str(ref), {}
steps = self._macros.get(name)
if steps is None:
raise ScenarioError(f"unknown macro: {name!r}")
expanded: deque = deque()
for step in steps:
if not isinstance(step, dict):
raise ScenarioError(f"macro step must be a mapping: {step!r}")
expanded.append(
{
_interpolate(k, args): _interpolate_tree(v, args)
for k, v in step.items()
}
)
# insert right after the current position: remaining steps follow
rest = list(self._step_queue)
self._step_queue.clear()
self._step_queue.extend(expanded)
self._step_queue.extend(rest)
# -- verdict handling ------------------------------------------------------------
def _record(self, label: str, verdict: Verdict, detail: str = "") -> None:
self._result.steps.append(
StepResult(label, verdict, detail, self.sim_time_ms)
)
def _finish(self, verdict: Verdict) -> None:
self._finished = True
self._result.verdict = verdict
self._result.duration_ms = self.sim_time_ms
@property
def result(self) -> ScenarioResult:
return self._result
@property
def finished(self) -> bool:
return self._finished
class ScenarioError(ValueError):
"""Invalid scenario definition or runtime misuse."""
def _parse_duration(value: str | int | float) -> float:
"""Parse '250ms', '30s', '2m', or a raw number (milliseconds)."""
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip().lower()
multipliers = {"ms": 1.0, "s": 1000.0, "m": 60000.0, "h": 3600000.0}
for suffix, mult in multipliers.items():
if text.endswith(suffix):
return float(text[: -len(suffix)]) * mult
return float(text) # default: milliseconds
def _interpolate(name: str, args: dict) -> str:
if name.startswith("$"):
return str(args[name[1:]])
return name
def _interpolate_value(value: object, args: dict) -> object:
if isinstance(value, str) and value.startswith("$"):
return args[value[1:]]
return value
def _interpolate_tree(value: object, args: dict) -> object:
"""Recursively substitute $-references in nested step payloads."""
if isinstance(value, str):
return _interpolate_value(value, args)
if isinstance(value, dict):
return {
_interpolate(k, args) if isinstance(k, str) else k: _interpolate_tree(v, args)
for k, v in value.items()
}
if isinstance(value, list):
return [_interpolate_tree(v, args) for v in value]
return value
+63
View File
@@ -0,0 +1,63 @@
"""FAT report generation: verdict summary, JSONL step log, CSV."""
from __future__ import annotations
import csv
import json
from pathlib import Path
from .engine import ScenarioResult, Verdict
def result_summary(result: ScenarioResult) -> str:
lines = [
f"Scenario: {result.name}",
f"Verdict: {result.verdict.value}",
f"Duration: {result.duration_ms:.0f} ms (sim time)",
f"Steps: {len(result.steps)}",
]
for i, step in enumerate(result.steps, 1):
lines.append(f" {i:3d}. [{step.verdict.value}] {step.step}"
f" @ {step.sim_time_ms:.0f} ms — {step.detail}")
return "\n".join(lines)
def write_jsonl(result: ScenarioResult, path: str | Path) -> None:
path = Path(path)
with path.open("w", encoding="utf-8") as fh:
fh.write(json.dumps({
"type": "scenario",
"name": result.name,
"verdict": result.verdict.value,
"duration_ms": result.duration_ms,
}) + "\n")
for step in result.steps:
fh.write(json.dumps({
"type": "step",
"step": step.step,
"verdict": step.verdict.value,
"detail": step.detail,
"sim_time_ms": step.sim_time_ms,
}) + "\n")
def write_csv(result: ScenarioResult, path: str | Path) -> None:
path = Path(path)
with path.open("w", encoding="utf-8", newline="") as fh:
writer = csv.writer(fh)
writer.writerow(["scenario", "verdict", "duration_ms"])
writer.writerow([result.name, result.verdict.value, result.duration_ms])
writer.writerow([])
writer.writerow(["step", "verdict", "detail", "sim_time_ms"])
for step in result.steps:
writer.writerow([step.step, step.verdict.value, step.detail,
step.sim_time_ms])
def verdict_badge(verdict: Verdict) -> str:
return {
Verdict.PASS: "PASS",
Verdict.FAIL: "FAIL",
Verdict.ERROR: "ERROR",
Verdict.PENDING: "PENDING",
}[verdict]
+431
View File
@@ -0,0 +1,431 @@
"""Asynchronous Modbus TCP server for the WAGO simulator.
Accepts up to 15 concurrent connections (750-362 limit), parses MBAP
frames, decodes PDUs, dispatches against the datastore + register bank,
and applies watchdog/statistics behavior.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from collections.abc import Callable
from . import pdu as pdu_mod
from .datastore import WagoDatastore
from .mbap import MBAP_HEADER_SIZE, MbapFrameReader, MbapError, build_frame
from .pdu import (
ExceptionResponse,
IllegalDataValue,
PduError,
ReadRequest,
ReadWriteMultipleRegistersRequest,
UnsupportedFunction,
WriteMultipleCoilsRequest,
WriteMultipleRegistersRequest,
WriteSingleCoilRequest,
WriteSingleRegisterRequest,
MaskWriteRegisterRequest,
GetCommEventCountRequest,
EXC_ILLEGAL_FUNCTION,
EXC_ILLEGAL_DATA_ADDRESS,
EXC_ILLEGAL_DATA_VALUE,
EXC_SLAVE_DEVICE_FAILURE,
FC_GET_COMM_EVENT_COUNT,
FC_MASK_WRITE_REGISTER,
FC_READ_COILS,
FC_READ_DISCRETE_INPUTS,
FC_READ_HOLDING_REGISTERS,
FC_READ_INPUT_REGISTERS,
FC_READ_WRITE_MULTIPLE_REGISTERS,
FC_WRITE_MULTIPLE_COILS,
FC_WRITE_MULTIPLE_REGISTERS,
FC_WRITE_SINGLE_COIL,
FC_WRITE_SINGLE_REGISTER,
)
from .stats import ModbusStatistics
from .watchdog import ModbusWatchdog, RegisterWriteRefused
log = logging.getLogger("wago_sim.server")
MAX_CONNECTIONS = 15 # 750-362: 15 concurrent Modbus TCP connections
IDLE_CHECK_INTERVAL_S = 0.5
WATCHDOG_TICK_S = 0.1 # watchdog time base is 100 ms (0x1000 unit)
class WagoSimServer:
"""Modbus TCP server bound to a datastore and register bank."""
def __init__(
self,
datastore: WagoDatastore,
statistics: ModbusStatistics,
watchdog: ModbusWatchdog,
*,
host: str = "0.0.0.0",
port: int = 502,
response_delay_provider: Callable[[], int] | None = None,
idle_timeout_provider: Callable[[], int] | None = None,
) -> None:
self.datastore = datastore
self.stats = statistics
self.watchdog = watchdog
self.host = host
self.port = port
self._response_delay_provider = response_delay_provider or (lambda: 0)
self._idle_timeout_provider = idle_timeout_provider or (lambda: 0)
self._server: asyncio.Server | None = None
self._connections: set[asyncio.StreamWriter] = set()
self._last_activity: dict[int, float] = {} # id(writer) -> loop time
self._event_counter = 0
self._watchdog_task: asyncio.Task | None = None
@property
def connection_count(self) -> int:
return len(self._connections)
async def start(self) -> None:
self._server = await asyncio.start_server(
self._handle_connection, self.host, self.port
)
log.info("Modbus TCP server listening on %s:%d", self.host, self.port)
self._watchdog_task = asyncio.create_task(self._watchdog_ticker())
asyncio.create_task(self._idle_reaper())
async def stop(self) -> None:
if self._server is not None:
self._server.close()
await self._server.wait_closed()
self._server = None
for writer in list(self._connections):
await self._close(writer)
async def _watchdog_ticker(self) -> None:
"""Advance the Modbus watchdog every 100 ms of wall time."""
while self._server is not None:
await asyncio.sleep(WATCHDOG_TICK_S)
self.watchdog.tick()
async def serve_forever(self) -> None:
if self._server is None:
await self.start()
assert self._server is not None
async with self._server:
await self._server.serve_forever()
# -- connection handling ------------------------------------------------------
async def _handle_connection(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
if len(self._connections) >= MAX_CONNECTIONS:
log.info("refusing connection (limit %d reached)", MAX_CONNECTIONS)
writer.close()
return
peer = writer.get_extra_info("peername")
self._connections.add(writer)
self._last_activity[id(writer)] = asyncio.get_running_loop().time()
log.info("connection from %s (open: %d)", peer, len(self._connections))
frame_reader = MbapFrameReader()
try:
while True:
try:
chunk = await reader.read(4096)
except (ConnectionError, OSError):
break
if not chunk:
break
try:
frame_reader.feed(chunk)
except MbapError as exc:
self.stats.bad_length += 1
log.warning("dropping malformed stream data: %s", exc)
break
while True:
try:
pair = frame_reader.pop_frame()
except MbapError as exc:
self.stats.bad_protocol += 1
log.warning("bad MBAP: %s", exc)
break
if pair is None:
break
header, request_pdu = pair
response = await self._process(header, request_pdu, writer)
if response is None:
continue # e.g. connection closed by watchdog
writer.write(response)
await writer.drain()
finally:
await self._close(writer)
async def _close(self, writer: asyncio.StreamWriter) -> None:
self._connections.discard(writer)
self._last_activity.pop(id(writer), None)
with contextlib.suppress(ConnectionError, OSError):
writer.close()
await writer.wait_closed()
async def close_all_connections(self) -> None:
"""Watchdog 0x1009=1: close TCP connections on timeout."""
for writer in list(self._connections):
log.info("closing connection per watchdog policy")
await self._close(writer)
async def _idle_reaper(self) -> None:
while self._server is not None:
await asyncio.sleep(IDLE_CHECK_INTERVAL_S)
timeout_ms = self._idle_timeout_provider() or 0
if timeout_ms <= 0:
continue
now = asyncio.get_running_loop().time()
for writer in list(self._connections):
last = self._last_activity.get(id(writer))
if last is not None and (now - last) * 1000 > timeout_ms:
log.info("closing idle connection (timeout %d ms)", timeout_ms)
await self._close(writer)
def _touch(self, writer: asyncio.StreamWriter) -> None:
self._last_activity[id(writer)] = asyncio.get_running_loop().time()
# -- request processing ---------------------------------------------------------
async def _process(
self, header, request_pdu: bytes, writer: asyncio.StreamWriter
) -> bytes | None:
self._touch(writer)
self.stats.mod_tcp_message_counter += 1
try:
request = pdu_mod.decode_request(request_pdu)
except UnsupportedFunction as exc:
self.stats.bad_function += 1
log.debug("FC%#04x unsupported: %s", exc.function_code, exc)
response: pdu_mod.Response = ExceptionResponse(
exc.function_code, EXC_ILLEGAL_FUNCTION
)
return self._finish(header, request_pdu, response, writer)
except IllegalDataValue as exc:
self.stats.bad_data += 1
response = ExceptionResponse(
request_pdu[0] if request_pdu else 0, EXC_ILLEGAL_DATA_VALUE
)
return self._finish(header, request_pdu, response, writer)
except PduError as exc:
self.stats.bad_length += 1
log.warning("malformed PDU: %s", exc)
response = ExceptionResponse(
request_pdu[0] if request_pdu else 0, EXC_ILLEGAL_DATA_VALUE
)
return self._finish(header, request_pdu, response, writer)
# watchdog monitors configured function codes
self.watchdog.on_function_code(request.function_code)
# Standard-mode lock after watchdog expiry: process-data access is
# answered with 0x04, while watchdog/config registers stay
# accessible (manual §11.2.5.2, Behavior 2)
if self.watchdog.process_data_blocked and _targets_process_data(request):
self.stats.slave_device_failure += 1
response = ExceptionResponse(
request.function_code, EXC_SLAVE_DEVICE_FAILURE
)
return self._finish(header, request_pdu, response, writer)
try:
response = self._dispatch(request)
except RegisterWriteRefused:
response = ExceptionResponse(
request.function_code, EXC_ILLEGAL_DATA_VALUE
)
except Exception:
log.exception("internal error while processing %r", request)
self.stats.slave_device_failure += 1
response = ExceptionResponse(
request.function_code, EXC_SLAVE_DEVICE_FAILURE
)
return self._finish(header, request_pdu, response, writer)
def _dispatch(self, request: pdu_mod.Request) -> pdu_mod.Response:
if isinstance(request, ReadRequest):
return self._dispatch_read(request)
if isinstance(request, WriteSingleCoilRequest):
result = self.datastore.write_bits(
request.address, [request.value]
)
if isinstance(result, ExceptionResponse):
self.stats.bad_address += 1
return result
return pdu_mod.WriteSingleCoilResponse(request.address, request.value)
if isinstance(request, WriteSingleRegisterRequest):
result = self.datastore.write_words(request.address, [request.value])
if isinstance(result, ExceptionResponse):
self.stats.bad_address += 1
return result
return pdu_mod.WriteSingleRegisterResponse(
request.address, request.value
)
if isinstance(request, WriteMultipleCoilsRequest):
result = self.datastore.write_bits(
request.address, list(request.values)
)
if isinstance(result, ExceptionResponse):
self.stats.bad_address += 1
return result
return pdu_mod.WriteMultipleResponse(
request.function_code, request.address, len(request.values)
)
if isinstance(request, WriteMultipleRegistersRequest):
result = self.datastore.write_words(
request.address, list(request.values)
)
if isinstance(result, ExceptionResponse):
self.stats.bad_address += 1
return result
return pdu_mod.WriteMultipleResponse(
request.function_code, request.address, len(request.values)
)
if isinstance(request, MaskWriteRegisterRequest):
result = self.datastore.mask_write_word(
request.address, request.and_mask, request.or_mask
)
if isinstance(result, ExceptionResponse):
self.stats.bad_address += 1
return result
return pdu_mod.MaskWriteRegisterResponse(
request.address, request.and_mask, request.or_mask
)
if isinstance(request, ReadWriteMultipleRegistersRequest):
write_result = self.datastore.write_words(
request.write_address, list(request.values)
)
if isinstance(write_result, ExceptionResponse):
self.stats.bad_address += 1
return write_result
read = self.datastore.read_words(request.read_address, request.read_count)
if isinstance(read, ExceptionResponse):
self.stats.bad_address += 1
return read
resp = pdu_mod.ReadResponse(request.function_code)
resp.registers = read
return resp
if isinstance(request, GetCommEventCountRequest):
self._event_counter += 1
return pdu_mod.GetCommEventCountResponse(0, self._event_counter)
raise AssertionError(f"unhandled request type: {request!r}")
def _dispatch_read(self, request: ReadRequest) -> pdu_mod.Response:
fc = request.function_code
if fc in (FC_READ_COILS, FC_READ_DISCRETE_INPUTS):
if request.count > pdu_mod.MAX_READ_BITS:
self.stats.too_many_bits += 1
return ExceptionResponse(fc, EXC_ILLEGAL_DATA_VALUE)
bits = self.datastore.read_bits(request.address, request.count)
if isinstance(bits, ExceptionResponse):
self.stats.bad_address += 1
return bits
resp = pdu_mod.ReadResponse(fc)
resp.bits = bits
return resp
if fc in (FC_READ_HOLDING_REGISTERS, FC_READ_INPUT_REGISTERS):
if request.count > pdu_mod.MAX_READ_REGISTERS:
self.stats.too_many_registers += 1
return ExceptionResponse(fc, EXC_ILLEGAL_DATA_VALUE)
words = self.datastore.read_words(request.address, request.count)
if isinstance(words, ExceptionResponse):
self.stats.bad_address += 1
return words
resp = pdu_mod.ReadResponse(fc)
resp.registers = words
return resp
raise AssertionError(f"unexpected read FC: {fc:#04x}")
def _finish(
self,
header,
request_pdu: bytes,
response: pdu_mod.Response,
writer: asyncio.StreamWriter,
) -> bytes | None:
encoded = response.encode()
delay_ms = self._response_delay_provider()
if delay_ms:
# register 0x1037: delay the response (fire-and-forget task
# keeps the pipeline non-blocking for other connections)
async def send() -> None:
await asyncio.sleep(delay_ms / 1000)
writer.write(build_frame(header.transaction_id, header.unit_id, encoded))
await writer.drain()
asyncio.create_task(send())
return None
writer.write(build_frame(header.transaction_id, header.unit_id, encoded))
return b"" # caller must not write again
@staticmethod
def _is_process_data_fc(fc: int) -> bool:
return fc in (
FC_READ_COILS,
FC_READ_DISCRETE_INPUTS,
FC_READ_HOLDING_REGISTERS,
FC_READ_INPUT_REGISTERS,
FC_WRITE_SINGLE_COIL,
FC_WRITE_SINGLE_REGISTER,
FC_WRITE_MULTIPLE_COILS,
FC_WRITE_MULTIPLE_REGISTERS,
FC_READ_WRITE_MULTIPLE_REGISTERS,
)
def _targets_process_data(request: pdu_mod.Request) -> bool:
"""Whether the request addresses process data (vs. config registers).
Word process areas: 0x0000-0x02FF, 0x6000-0x62FB, 0x7000-0x72FB.
Bit process areas: 0x0000-0x03FF, 0x8000-0x85F7, 0x9000-0x95F7.
Configuration registers (0x1000-0x2FFF) stay accessible after a
watchdog timeout in Standard mode (manual §11.2.5.2).
"""
if isinstance(request, ReadRequest | WriteSingleCoilRequest
| WriteSingleRegisterRequest | MaskWriteRegisterRequest):
return _is_process_address(request.address)
if isinstance(request, WriteMultipleCoilsRequest | WriteMultipleRegistersRequest):
return _is_process_address(request.address)
if isinstance(request, ReadWriteMultipleRegistersRequest):
return (_is_process_address(request.read_address)
or _is_process_address(request.write_address))
# GetCommEventCount has no address
return False
def _is_process_address(address: int) -> bool:
"""Address inside the word/bit process-image areas (not config regs)."""
word = (
0x0000 <= address <= 0x02FF
or 0x6000 <= address <= 0x62FB
or 0x7000 <= address <= 0x72FB
)
bit = (
0x0000 <= address <= 0x03FF
or 0x8000 <= address <= 0x85F7
or 0x9000 <= address <= 0x95F7
)
return word or bit
def _is_process_data_fc(fc: int) -> bool:
return fc in (
FC_READ_COILS,
FC_READ_DISCRETE_INPUTS,
FC_READ_HOLDING_REGISTERS,
FC_READ_INPUT_REGISTERS,
FC_WRITE_SINGLE_COIL,
FC_WRITE_SINGLE_REGISTER,
FC_WRITE_MULTIPLE_COILS,
FC_WRITE_MULTIPLE_REGISTERS,
FC_READ_WRITE_MULTIPLE_REGISTERS,
) or fc == FC_MASK_WRITE_REGISTER
def is_process_data_fc(fc: int) -> bool:
"""Public alias for the scenario engine."""
return _is_process_data_fc(fc)
+68
View File
@@ -0,0 +1,68 @@
"""Statistics counters for the Modbus TCP interface (register 0x1029).
Mirrors the 750-362 statistics block (manual §11.2.5.5, Table 143):
Word 1 SlaveDeviceFailure local bus error, fieldbus error via watchdog
Word 2 BadProtocol error in the MODBUS TCP header
Word 3 BadLength wrong telegram length
Word 4 BadFunction invalid function code
Word 5 BadAddress invalid register address
Word 6 BadData invalid value
Word 7 TooManyRegisters register count too large (R/W 125/100)
Word 8 TooManyBits coil count too large (R/W 2000/800)
Word 9 ModTcpMessageCounter received MODBUS/TCP requests
Writing 0xAA55 or 0x55AA to 0x1029 resets all counters.
"""
from __future__ import annotations
from dataclasses import dataclass, field
STATS_REGISTER = 0x1029
STATS_WORD_COUNT = 9
RESET_KEYS = (0xAA55, 0x55AA)
@dataclass
class ModbusStatistics:
slave_device_failure: int = 0
bad_protocol: int = 0
bad_length: int = 0
bad_function: int = 0
bad_address: int = 0
bad_data: int = 0
too_many_registers: int = 0
too_many_bits: int = 0
mod_tcp_message_counter: int = 0
def as_words(self) -> list[int]:
return [
self.slave_device_failure,
self.bad_protocol,
self.bad_length,
self.bad_function,
self.bad_address,
self.bad_data,
self.too_many_registers,
self.too_many_bits,
self.mod_tcp_message_counter,
]
def reset(self) -> None:
self.slave_device_failure = 0
self.bad_protocol = 0
self.bad_length = 0
self.bad_function = 0
self.bad_address = 0
self.bad_data = 0
self.too_many_registers = 0
self.too_many_bits = 0
self.mod_tcp_message_counter = 0
def handle_reset_write(self, value: int) -> bool:
"""Returns True when `value` is a reset key and counters were reset."""
if value in RESET_KEYS:
self.reset()
return True
return False
+251
View File
@@ -0,0 +1,251 @@
"""Modbus watchdog simulation (registers 0x1000-0x100B).
Behavior per 750-362 manual §11.2.5.2/3:
- 0x1000: timeout value, unit 100 ms, default 0x0064 (100 units = 10 s).
Writable only while the watchdog is inactive.
- 0x1001: coding mask for FC1..FC16 (bit n-1 selects FC n), default 0xFFFF.
- 0x1002: coding mask FC17..FC32 (default 0xFFFF; FC17+ unsupported).
- 0x1003: alternative trigger — write nonzero to start; consecutive writes
must differ in value to re-trigger (kick).
- 0x1004: minimum trigger time (read-only, returns 0xFFFF).
- 0x1005: stop sequence — write 0xAAAA first, then 0x5555.
- 0x1006: status — 0 inactive, 1 active, 2 expired.
- 0x1007: restart — write 1 after expiry.
- 0x1008: simple stop — write 0xAA55 or 0x55AA.
- 0x1009: close TCP socket on timeout (0/1).
- 0x100A: type — 0 Standard, 1 Alternative.
- 0x100B: write 0x55AA/0xAA55 to make parameters remanent.
On timeout the outputs are cleared (analog 0 / digital OFF). In Standard
mode, subsequent process-data access returns exception 0x04 (Slave Device
Failure) while watchdog registers stay accessible. In Alternative mode the
watchdog restarts with the next monitored function code.
"""
from __future__ import annotations
import enum
from collections.abc import Callable
WD_TIME = 0x1000
WD_MASK_1_16 = 0x1001
WD_MASK_17_32 = 0x1002
WD_TRIGGER = 0x1003
WD_MIN_TRIGGER_TIME = 0x1004
WD_STOP_MASK = 0x1005
WD_STATUS = 0x1006
WD_RESTART = 0x1007
WD_SIMPLE_STOP = 0x1008
WD_CLOSE_ON_TIMEOUT = 0x1009
WD_TYPE = 0x100A
WD_SAVE_PARAMS = 0x100B
SAVE_KEYS = (0x55AA, 0xAA55)
SIMPLE_STOP_KEYS = (0xAA55, 0x55AA)
WD_STATUS_INACTIVE = 0
WD_STATUS_ACTIVE = 1
WD_STATUS_EXPIRED = 2
class WatchdogType(enum.Enum):
STANDARD = 0
ALTERNATIVE = 1
class ModbusWatchdog:
"""State machine for the Modbus watchdog registers.
The simulator calls :meth:`tick` every 100 ms of simulated time and
:meth:`on_function_code` for every processed request; the datastore is
cleared through the ``clear_outputs`` callback on expiry.
"""
def __init__(
self,
*,
on_expire: Callable[[], None],
on_connections_close: Callable[[], None] | None = None,
) -> None:
self._time_units = 0x0064 # 0x1000, unit 100 ms
self._mask_1_16 = 0xFFFF # 0x1001
self._mask_17_32 = 0xFFFF # 0x1002
self._status = WD_STATUS_INACTIVE # 0x1006
self._close_on_timeout = 0 # 0x1009
self._type = WatchdogType.STANDARD # 0x100A
self._elapsed_units = 0
self._last_trigger_value: int | None = None
self._stop_mask_state = False # saw 0xAAAA, awaiting 0x5555
self._on_expire = on_expire
self._on_connections_close = on_connections_close
# Standard-mode process data lock after expiry
self._process_data_blocked = False
# -- register interface -----------------------------------------------------
def read(self, address: int) -> int:
if address == WD_TIME:
return self._time_units
if address == WD_MASK_1_16:
return self._mask_1_16
if address == WD_MASK_17_32:
return self._mask_17_32
if address == WD_TRIGGER:
return self._last_trigger_value or 0
if address == WD_MIN_TRIGGER_TIME:
return 0xFFFF
if address == WD_STATUS:
status = self._status
if status == WD_STATUS_EXPIRED and self._type is WatchdogType.ALTERNATIVE:
# reading 0x1006 returns "expired" once; the read itself
# restarts the watchdog, so the next read shows "active"
# (manual §11.2.5.2, Behavior 1)
self._restart()
return status
if address == WD_RESTART:
return 0
if address == WD_SIMPLE_STOP:
return 0
if address == WD_CLOSE_ON_TIMEOUT:
return self._close_on_timeout
if address == WD_TYPE:
return self._type.value
if address == WD_SAVE_PARAMS:
return 0
raise KeyError(f"not a watchdog register: {address:#06x}")
def write(self, address: int, value: int) -> None:
value &= 0xFFFF
if address == WD_TIME:
if self._status != WD_STATUS_INACTIVE:
raise RegisterWriteRefused("cannot change time while running")
self._time_units = value
return
if address in (WD_MASK_1_16, WD_MASK_17_32):
if self._status != WD_STATUS_INACTIVE:
raise RegisterWriteRefused("cannot change mask while running")
if address == WD_MASK_1_16:
self._mask_1_16 = value
else:
self._mask_17_32 = value
return
if address == WD_TRIGGER:
if self._status == WD_STATUS_EXPIRED:
# a differing value resets the fault and restarts
if value != self._last_trigger_value:
self._restart()
self._last_trigger_value = value
return
if self._status == WD_STATUS_INACTIVE:
# after power-on, only a nonzero value starts the watchdog
if value != 0:
self._start()
self._last_trigger_value = value
return
# active: kick only when the value differs from the last one
# (manual example uses alternating counter values 0x0000, 0x0001,
# so zero IS a valid kick value once running)
if value != self._last_trigger_value:
self._kick()
self._last_trigger_value = value
return
if address == WD_STOP_MASK:
# sequence 0xAAAA then 0x5555
if value == 0xAAAA:
self._stop_mask_state = True
return
if value == 0x5555 and self._stop_mask_state:
self._stop_mask_state = False
self._stop()
return
self._stop_mask_state = False
return
if address == WD_RESTART:
if value == 0x0001:
self._restart()
return
if address == WD_SIMPLE_STOP:
if value in SIMPLE_STOP_KEYS:
self._stop()
self._process_data_blocked = False
return
if address == WD_CLOSE_ON_TIMEOUT:
self._close_on_timeout = 1 if value else 0
return
if address == WD_TYPE:
self._type = WatchdogType.ALTERNATIVE if value else WatchdogType.STANDARD
self._mask_1_16 = 0xFFFF # switching types resets the mask
return
if address == WD_SAVE_PARAMS:
if value in SAVE_KEYS:
# remanent storage is a no-op in the simulator
return
raise RegisterWriteRefused("invalid save key")
raise KeyError(f"not a watchdog register: {address:#06x}")
# -- runtime ------------------------------------------------------------------
@property
def status(self) -> int:
return self._status
@property
def process_data_blocked(self) -> bool:
"""Standard mode: process data access answered with 0x04 after expiry."""
return self._process_data_blocked
@property
def close_connections_on_timeout(self) -> bool:
return bool(self._close_on_timeout)
def on_function_code(self, fc: int) -> None:
"""Called for every processed request; kicks the watchdog when the
coding mask selects the function code."""
if self._status != WD_STATUS_ACTIVE:
return
if fc <= 16:
if self._mask_1_16 & (1 << (fc - 1)):
self._kick()
elif fc <= 32:
if self._mask_17_32 & (1 << (fc - 17)):
self._kick()
def tick(self) -> None:
"""Advance simulated time by 100 ms."""
if self._status != WD_STATUS_ACTIVE:
return
self._elapsed_units += 1
if self._elapsed_units >= self._time_units:
self._expire()
# -- internals -----------------------------------------------------------------
def _start(self) -> None:
self._status = WD_STATUS_ACTIVE
self._elapsed_units = 0
def _kick(self) -> None:
self._elapsed_units = 0
def _stop(self) -> None:
self._status = WD_STATUS_INACTIVE
self._elapsed_units = 0
self._process_data_blocked = False
def _restart(self) -> None:
self._status = WD_STATUS_ACTIVE
self._elapsed_units = 0
self._process_data_blocked = False
def _expire(self) -> None:
self._status = WD_STATUS_EXPIRED
if self._type is WatchdogType.STANDARD:
self._process_data_blocked = True
self._on_expire()
if self._close_on_timeout and self._on_connections_close:
self._on_connections_close()
class RegisterWriteRefused(Exception):
"""Write to a register rejected by device semantics (exception 0x03)."""
+28
View File
@@ -0,0 +1,28 @@
"""Shared fixtures for the simulator test suite."""
from __future__ import annotations
import pytest
from wago_sim.app import build_app
from wago_sim.modules import DI_8, DI_16, DO_8, DO_16, AI_2, AO_2
@pytest.fixture
def pump_modules():
return [DI_8, DI_16, DO_8, DO_16, AI_2, AO_2]
@pytest.fixture
def app(pump_modules):
return build_app(modules=pump_modules)
@pytest.fixture
def datastore(app):
return app.datastore
@pytest.fixture
def node(app):
return app.node
+157
View File
@@ -0,0 +1,157 @@
"""Datastore tests: WAGO register-map areas, mirrors, bit addressing."""
from __future__ import annotations
import pytest
from wago_sim.pdu import EXC_ILLEGAL_DATA_ADDRESS, ExceptionResponse
def _is_exc(resp) -> bool:
return isinstance(resp, ExceptionResponse)
class TestWordRead:
def test_input_area1_readable(self, datastore):
datastore.set_input_word(0, 0xBEEF)
assert datastore.read_words(0x0000, 1) == [0xBEEF]
def test_input_area1_upper_edge(self, datastore):
datastore.set_input_word(255, 0x1234)
assert datastore.read_words(0x00FF, 1) == [0x1234]
def test_hole_0x0100_illegal(self, datastore):
assert _is_exc(datastore.read_words(0x0100, 1))
def test_output_readback_area1(self, datastore):
result = datastore.write_words(0x0000, [0xABCD])
assert result is None
assert datastore.read_words(0x0200, 1) == [0xABCD]
def test_hole_0x0300_illegal(self, datastore):
assert _is_exc(datastore.read_words(0x0300, 1))
def test_config_area_readable(self, datastore):
# 0x2002 must return the byte-order constant 0x1234
assert datastore.read_words(0x2002, 1) == [0x1234]
def test_input_area2(self, datastore):
datastore.set_input_word(256, 0x00FF) # %IW256 -> 0x6000
assert datastore.read_words(0x6000, 1) == [0x00FF]
def test_output_readback_area2(self, datastore):
assert datastore.write_words(0x6000, [0x5A5A]) is None
assert datastore.read_words(0x7000, 1) == [0x5A5A]
def test_hole_0x62FC_illegal(self, datastore):
assert _is_exc(datastore.read_words(0x62FC, 1))
def test_area_end_0x72FB(self, datastore):
assert datastore.write_words(0x6000 + 763, [1]) is None
assert datastore.read_words(0x7000 + 763, 1) == [1]
def test_beyond_0x72FC_illegal(self, datastore):
assert _is_exc(datastore.read_words(0x72FC, 1))
def test_spanning_areas_rejected(self, datastore):
# a read crossing from input area 1 into the 0x0100 hole must fail
assert _is_exc(datastore.read_words(0x00FE, 4))
class TestWordWrite:
def test_write_area1(self, datastore):
assert datastore.write_words(0x0000, [0xFFFF]) is None
assert datastore.output_words[0] == 0xFFFF
def test_write_mirror_area1(self, datastore):
assert datastore.write_words(0x0200, [0x7777]) is None
assert datastore.output_words[0] == 0x7777
def test_write_config_area_watchdog_time(self, datastore):
assert datastore.write_words(0x1000, [10]) is None
def test_write_hole_0x0100_illegal(self, datastore):
assert _is_exc(datastore.write_words(0x0100, [1]))
def test_write_input_area_illegal(self, datastore):
# 0x0300 hole on write path
assert _is_exc(datastore.write_words(0x0300, [1]))
# reading input image via write FC6 at 0x0200 goes to outputs, not inputs
assert _is_exc(datastore.write_words(0x62FC, [1]))
def test_word_write_limit_100(self, datastore):
values = [1] * 101
resp = datastore.write_words(0x0000, values)
assert _is_exc(resp)
def test_word_read_limit_125(self, datastore):
assert _is_exc(datastore.read_words(0x0000, 126))
def test_mask_write(self, datastore):
assert datastore.write_words(0x0000, [0x12]) is None
result = datastore.mask_write_word(0x0000, and_mask=0xF2, or_mask=0x25)
assert result == ((0x12 & 0xF2) | (0x25 & ~0xF2 & 0xFFFF))
assert datastore.output_words[0] == result
def test_mask_write_illegal_address(self, datastore):
assert _is_exc(datastore.mask_write_word(0x0100, 0xFF, 0x00))
class TestBits:
def test_read_discrete_input_area1(self, datastore):
datastore.set_digital_input_channel(4, True)
assert datastore.read_bits(3, 1) == [True]
def test_read_coil_area1_mirror(self, datastore):
assert datastore.write_bits(0x0200, [True, False, True]) is None
assert datastore.read_bits(0x0200, 3) == [True, False, True]
def test_write_coil_area1(self, datastore):
# bit writes at 0x0000-0x01FF target OUTPUTS (Table 122), but reads
# there return INPUTS (Table 121) — readback uses the 0x0200 mirror
assert datastore.write_bits(0x0000, [True] * 8) is None
assert datastore.digital_output_channel(1) is True
assert datastore.read_bits(0x0200, 8) == [True] * 8
def test_digital_channel_addressing(self, datastore):
# channel 47 -> bit address 46 (manual §11.2.4)
datastore.set_digital_input_channel(47, True)
assert datastore.read_bits(46, 1) == [True]
def test_bit_area2_input(self, datastore):
# DI 513/514 live in input area 2 (0x8000+); digital storage starts
# after the analog words — default offset 0, so channels 513/514 map
# to storage word 32/33 via the global bit index 512/513
datastore.set_digital_input_channel(513, True)
datastore.set_digital_input_channel(514, True)
assert datastore.read_bits(0x8000 + 1, 1) == [True]
def test_bit_illegal_ranges(self, datastore):
assert _is_exc(datastore.read_bits(0x0400, 1))
assert _is_exc(datastore.read_bits(0x35F8, 1))
assert _is_exc(datastore.write_bits(0x0400, [True]))
assert _is_exc(datastore.read_bits(0x85F8, 1))
assert _is_exc(datastore.read_bits(0x95F8, 1))
def test_bit_write_limit_800(self, datastore):
assert _is_exc(datastore.write_bits(0x0000, [True] * 801))
def test_bit_read_limit_2000(self, datastore):
assert _is_exc(datastore.read_bits(0x0000, 2001))
class TestWatchdogReaction:
def test_clear_outputs(self, datastore):
assert datastore.write_words(0x0000, [0xFFFF]) is None
assert datastore.write_bits(0x0000, [True] * 16) is None
datastore.clear_outputs()
assert datastore.output_words == [0] * len(datastore.output_words)
class TestListeners:
def test_listener_notified_on_write(self, datastore):
calls = []
datastore.add_listener(lambda: calls.append(1))
datastore.write_words(0x0000, [1])
datastore.write_bits(0x0000, [True])
assert len(calls) == 2
+87
View File
@@ -0,0 +1,87 @@
"""Integration test: pymodbus as an independent Modbus TCP master.
Verifies the simulator against a third-party client implementation
(rather than our own codec) — the strongest interop check for FAT use.
Uses the pymodbus 3.15 async client API (device_id= keyword).
"""
from __future__ import annotations
import asyncio
import pytest
pymodbus = pytest.importorskip("pymodbus")
from pymodbus.client import AsyncModbusTcpClient # noqa: E402
async def _start(app, port=15021):
app.server.host, app.server.port = "127.0.0.1", port
await app.server.start()
await asyncio.sleep(0.05)
class TestPymodbusInterop:
async def test_constants_via_pymodbus(self, app):
await _start(app)
client = AsyncModbusTcpClient("127.0.0.1", port=15021)
assert await client.connect()
try:
rr = await client.read_holding_registers(0x2000, count=9, device_id=1)
assert not rr.isError()
assert rr.registers == [0x0000, 0xFFFF, 0x1234, 0xAAAA, 0x5555,
0x7FFF, 0x8000, 0x3FFF, 0x4000]
finally:
client.close()
await app.server.stop()
async def test_write_read_process_image(self, app):
await _start(app)
client = AsyncModbusTcpClient("127.0.0.1", port=15021)
assert await client.connect()
try:
# write holding register 0 (-> output %QW0)
await client.write_register(0, 0x1234, device_id=1)
# read back via the input-register mirror at +0x0200
rr = await client.read_input_registers(0x0200, count=1, device_id=1)
assert rr.registers == [0x1234]
# coil write & read-back mirror at 0x0200
await client.write_coil(3, True, device_id=1)
rr = await client.read_coils(0x0200, count=8, device_id=1)
assert rr.bits[3] is True
finally:
client.close()
await app.server.stop()
async def test_illegal_address_exception(self, app):
await _start(app)
client = AsyncModbusTcpClient("127.0.0.1", port=15021)
assert await client.connect()
try:
rr = await client.read_holding_registers(0x0100, count=1, device_id=1)
assert rr.isError()
assert rr.exception_code == 0x02
finally:
client.close()
await app.server.stop()
async def test_read_write_multiple_registers(self, app):
await _start(app)
client = AsyncModbusTcpClient("127.0.0.1", port=15021)
assert await client.connect()
try:
# FC23: read the constant 0x1234 at 0x2002 while writing %QW0
rr = await client.readwrite_registers(
read_address=0x2002, read_count=1,
write_address=0x0000, values=[0xBEEF],
device_id=1,
)
assert not rr.isError()
assert rr.registers == [0x1234]
# the write part landed in the output image -> mirror at 0x0200
rr = await client.read_input_registers(0x0200, count=1, device_id=1)
assert rr.registers == [0xBEEF]
finally:
client.close()
await app.server.stop()
+70
View File
@@ -0,0 +1,70 @@
"""MBAP codec tests."""
from __future__ import annotations
import pytest
from wago_sim.mbap import (
MBAP_HEADER_SIZE,
MbapError,
MbapFrameReader,
MbapHeader,
build_frame,
)
def test_header_roundtrip():
header = MbapHeader(transaction_id=0x1234, protocol_id=0, length=6, unit_id=1)
encoded = header.encode()
assert len(encoded) == 7
decoded = MbapHeader.decode(encoded + b"\x03\x00\x00\x00\x02")
assert decoded == header
def test_decode_requires_complete_frame():
with pytest.raises(MbapError):
MbapHeader.decode(b"\x00\x01\x00\x00\x00\x06\x01")
def test_protocol_id_rejected():
with pytest.raises(MbapError):
MbapHeader.decode(b"\x00\x01\x00\x01\x00\x06\x01\x03\x00\x00\x00\x02")
def test_build_frame():
frame = build_frame(7, 1, b"\x03\x00\x00\x00\x02")
assert frame[:2] == (7).to_bytes(2, "big")
assert frame[4:6] == (6).to_bytes(2, "big")
assert frame[6] == 1
def test_frame_reader_splits_stream():
reader = MbapFrameReader()
f1 = build_frame(1, 1, b"\x03\x00\x00\x00\x02")
f2 = build_frame(2, 1, b"\x01\x00\x00\x00\x08")
blob = f1 + f2
# feed an incomplete header first
reader.feed(blob[:5])
assert reader.pop_frame() is None
# feed through the end of frame 1 (12 bytes): it becomes complete
reader.feed(blob[5:12])
h1, p1 = reader.pop_frame()
assert h1.transaction_id == 1
assert p1 == b"\x03\x00\x00\x00\x02"
# frame 2 arrives in pieces
reader.feed(blob[12:15])
assert reader.pop_frame() is None
reader.feed(blob[15:])
h2, p2 = reader.pop_frame()
assert h2.transaction_id == 2
assert p2 == b"\x01\x00\x00\x00\x08"
assert reader.pop_frame() is None
def test_frame_reader_rejects_bad_protocol():
reader = MbapFrameReader()
reader.feed(b"\x00\x01\xDE\xAD\x00\x06\x01\x03\x00\x00\x00\x02")
import pytest
with pytest.raises(MbapError):
reader.pop_frame()
+188
View File
@@ -0,0 +1,188 @@
"""PDU codec tests: request decoding, response encoding, limits."""
from __future__ import annotations
import pytest
from wago_sim.pdu import (
COIL_OFF,
COIL_ON,
EXC_ILLEGAL_DATA_ADDRESS,
EXC_ILLEGAL_DATA_VALUE,
ExceptionResponse,
GetCommEventCountResponse,
IllegalDataValue,
MaskWriteRegisterResponse,
ReadRequest,
ReadResponse,
UnsupportedFunction,
WriteMultipleResponse,
WriteSingleCoilRequest,
WriteSingleCoilResponse,
WriteSingleRegisterRequest,
WriteSingleRegisterResponse,
decode_request,
FC_GET_COMM_EVENT_COUNT,
FC_MASK_WRITE_REGISTER,
FC_READ_COILS,
FC_READ_DISCRETE_INPUTS,
FC_READ_HOLDING_REGISTERS,
FC_READ_INPUT_REGISTERS,
FC_READ_WRITE_MULTIPLE_REGISTERS,
FC_WRITE_MULTIPLE_COILS,
FC_WRITE_MULTIPLE_REGISTERS,
FC_WRITE_SINGLE_COIL,
FC_WRITE_SINGLE_REGISTER,
MAX_READ_BITS,
MAX_READ_REGISTERS,
MAX_WRITE_BITS,
MAX_WRITE_REGISTERS,
)
def _req(fc: int, body: bytes) -> bytes:
return bytes([fc]) + body
class TestReadRequests:
@pytest.mark.parametrize("fc,max_count", [
(FC_READ_COILS, MAX_READ_BITS),
(FC_READ_DISCRETE_INPUTS, MAX_READ_BITS),
(FC_READ_HOLDING_REGISTERS, MAX_READ_REGISTERS),
(FC_READ_INPUT_REGISTERS, MAX_READ_REGISTERS),
])
def test_read_ok(self, fc, max_count):
pdu = _req(fc, (0x00).to_bytes(2, "big") + max_count.to_bytes(2, "big"))
req = decode_request(pdu)
assert isinstance(req, ReadRequest)
assert req.function_code == fc
assert req.count == max_count
def test_read_count_zero_rejected(self):
with pytest.raises(IllegalDataValue):
decode_request(_req(FC_READ_COILS, b"\x00\x00\x00\x00"))
def test_read_count_over_limit_rejected(self):
with pytest.raises(IllegalDataValue):
decode_request(_req(FC_READ_COILS, b"\x00\x00\x07\xD1")) # 2001
with pytest.raises(IllegalDataValue):
decode_request(_req(FC_READ_HOLDING_REGISTERS, b"\x00\x00\x00\x7E")) # 126
def test_read_body_length(self):
with pytest.raises(Exception):
decode_request(_req(FC_READ_COILS, b"\x00\x00\x00"))
class TestWriteRequests:
def test_write_single_coil_on(self):
req = decode_request(_req(FC_WRITE_SINGLE_COIL, b"\x00\x05\xFF\x00"))
assert isinstance(req, WriteSingleCoilRequest)
assert req.address == 5
assert req.value is True
def test_write_single_coil_bad_value(self):
with pytest.raises(IllegalDataValue):
decode_request(_req(FC_WRITE_SINGLE_COIL, b"\x00\x05\x12\x34"))
def test_write_single_register(self):
req = decode_request(_req(FC_WRITE_SINGLE_REGISTER, b"\x00\x0A\x12\x34"))
assert isinstance(req, WriteSingleRegisterRequest)
assert req.value == 0x1234
def test_write_multiple_coils(self):
# address 0, 10 coils, values 0b0000_0001_01
body = b"\x00\x00\x00\x0A\x02\x05\x01"
req = decode_request(_req(FC_WRITE_MULTIPLE_COILS, body))
assert req.function_code == FC_WRITE_MULTIPLE_COILS
assert len(req.values) == 10
assert req.values == (True, False, True, False, False, False, False,
False, True, False)
def test_write_multiple_coils_count_mismatch(self):
body = b"\x00\x00\x00\x0A\x03\x05\x01\x00" # announces 3 bytes for 10 bits
with pytest.raises(Exception):
decode_request(_req(FC_WRITE_MULTIPLE_COILS, body))
def test_write_multiple_coils_over_limit(self):
body = b"\x00\x00\x03\x21\x65" + b"\xFF" * 0x65
with pytest.raises(IllegalDataValue):
decode_request(_req(FC_WRITE_MULTIPLE_COILS, body))
def test_write_multiple_registers(self):
body = b"\x00\x00\x00\x02\x04\x12\x34\x56\x78"
req = decode_request(_req(FC_WRITE_MULTIPLE_REGISTERS, body))
assert req.values == (0x1234, 0x5678)
def test_write_multiple_registers_over_limit(self):
count = MAX_WRITE_REGISTERS + 1
body = count.to_bytes(2, "big")[:2] + (2 * count).to_bytes(1, "big") + b"\x00" * (2 * count)
# count field is 2 bytes; re-encode properly
body = b"\x00\x00" + count.to_bytes(2, "big") + (2 * count).to_bytes(1, "big") + b"\x00" * (2 * count)
with pytest.raises(IllegalDataValue):
decode_request(_req(FC_WRITE_MULTIPLE_REGISTERS, body))
def test_mask_write(self):
req = decode_request(_req(FC_MASK_WRITE_REGISTER, b"\x00\x0A\x00\xF2\x00\x25"))
assert req.address == 0x0A
assert req.and_mask == 0x00F2
assert req.or_mask == 0x0025
def test_read_write_multiple(self):
body = (b"\x00\x00\x00\x02" # read addr 0, count 2
b"\x10\x00\x00\x02" # write addr 0x1000, count 2
b"\x04\xAA\xBB\xCC\xDD") # byte count (1 byte) + 4 data bytes
req = decode_request(_req(FC_READ_WRITE_MULTIPLE_REGISTERS, body))
assert req.read_count == 2
assert req.write_address == 0x1000
assert req.values == (0xAABB, 0xCCDD)
class TestUnsupported:
def test_fc7_rejected(self):
with pytest.raises(UnsupportedFunction) as ei:
decode_request(_req(0x07, b"\x00\x00"))
assert ei.value.function_code == 0x07
def test_fc8_rejected(self):
with pytest.raises(UnsupportedFunction):
decode_request(_req(0x08, b"\x00\x00\x00\x00"))
class TestResponses:
def test_read_bits_encoding(self):
resp = ReadResponse(FC_READ_COILS)
resp.bits = [True, False, True] + [False] * 5
assert resp.encode() == b"\x01\x01\x05"
def test_read_registers_encoding(self):
resp = ReadResponse(FC_READ_HOLDING_REGISTERS)
resp.registers = [0x1234, 0x5678]
assert resp.encode() == b"\x03\x04\x12\x34\x56\x78"
def test_write_single_coil_response(self):
resp = WriteSingleCoilResponse(5, True)
assert resp.encode() == b"\x05\x00\x05\xFF\x00"
def test_write_single_register_response(self):
resp = WriteSingleRegisterResponse(2, 0xBEEF)
assert resp.encode() == b"\x06\x00\x02\xBE\xEF"
def test_write_multiple_response(self):
resp = WriteMultipleResponse(FC_WRITE_MULTIPLE_REGISTERS, 0, 4)
assert resp.encode() == b"\x10\x00\x00\x00\x04"
def test_mask_write_response(self):
resp = MaskWriteRegisterResponse(4, 0x00F2, 0x0025)
assert resp.encode() == b"\x16\x00\x04\x00\xF2\x00\x25"
def test_event_count_response(self):
resp = GetCommEventCountResponse(0, 42)
assert resp.encode() == b"\x0B\x00\x00\x00\x2A"
def test_exception_response(self):
resp = ExceptionResponse(FC_READ_COILS, EXC_ILLEGAL_DATA_ADDRESS)
assert resp.encode() == b"\x81\x02"
def test_event_count_request(self):
req = decode_request(_req(FC_GET_COMM_EVENT_COUNT, b""))
assert req.function_code == FC_GET_COMM_EVENT_COUNT
+223
View File
@@ -0,0 +1,223 @@
"""Register bank, statistics, and watchdog tests."""
from __future__ import annotations
import pytest
from wago_sim.registers import ConfigRegisterBank
from wago_sim.stats import ModbusStatistics
from wago_sim.watchdog import (
ModbusWatchdog,
RegisterWriteRefused,
WD_CLOSE_ON_TIMEOUT,
WD_MASK_1_16,
WD_SIMPLE_STOP,
WD_STATUS,
WD_STATUS_ACTIVE,
WD_STATUS_EXPIRED,
WD_STATUS_INACTIVE,
WD_TIME,
WD_TRIGGER,
WD_TYPE,
)
class TestConstants:
"""Acceptance criterion 1: constant registers 0x2000-0x2008."""
@pytest.mark.parametrize("address,expected", [
(0x2000, 0x0000),
(0x2001, 0xFFFF),
(0x2002, 0x1234),
(0x2003, 0xAAAA),
(0x2004, 0x5555),
(0x2005, 0x7FFF),
(0x2006, 0x8000),
(0x2007, 0x3FFF),
(0x2008, 0x4000),
])
def test_constant_block(self, app, address, expected):
assert app.registers.read_config(address, 1) == [expected]
def test_constant_block_bulk(self, app):
words = app.registers.read_config(0x2000, 9)
assert words == [0x0000, 0xFFFF, 0x1234, 0xAAAA, 0x5555,
0x7FFF, 0x8000, 0x3FFF, 0x4000]
class TestFirmwareInfo:
def test_series_code_is_750(self, app):
assert app.registers.read_config(0x2011, 1) == [0x02EE]
def test_description_words(self, app):
words = app.registers.read_config(0x2020, 16)
text = "".join(chr((w >> 8) & 0xFF) + chr(w & 0xFF) for w in words)
assert text.startswith("WAGO 750-362 Simulator")
def test_image_sizes(self, app):
# pump node: 2 AI words + 2 AO words, 24 DI, 24 DO
assert app.registers.read_config(0x1022, 1) == [2 * 16] # analog out bits
assert app.registers.read_config(0x1023, 1) == [2 * 16] # analog in bits
assert app.registers.read_config(0x1024, 1) == [24] # digital out bits
assert app.registers.read_config(0x1025, 1) == [24] # digital in bits
class TestStatistics:
def test_initial_zero(self, app):
words = app.registers.read_config(0x1029, 9)
assert words == [0] * 9
def test_counter_readback(self, app):
app.stats.bad_address = 3
app.stats.mod_tcp_message_counter = 42
words = app.registers.read_config(0x1029, 9)
assert words[4] == 3
assert words[8] == 42
def test_reset_via_magic_write(self, app):
app.stats.bad_address = 3
app.stats.mod_tcp_message_counter = 42
app.registers.write_config(0x1029, [0xAA55])
words = app.registers.read_config(0x1029, 9)
assert words == [0] * 9
def test_reset_key_55aa(self):
stats = ModbusStatistics()
stats.bad_length = 1
assert stats.handle_reset_write(0x55AA) is True
assert stats.bad_length == 0
assert stats.handle_reset_write(0x1234) is False
class TestWatchdog:
def _make(self):
expired = []
wd = ModbusWatchdog(on_expire=lambda: expired.append(1))
return wd, expired
def test_defaults(self):
wd, _ = self._make()
assert wd.read(WD_TIME) == 0x0064
assert wd.read(WD_MASK_1_16) == 0xFFFF
assert wd.read(WD_STATUS) == WD_STATUS_INACTIVE
def test_cannot_change_time_while_active(self):
wd, _ = self._make()
wd.write(WD_TRIGGER, 5) # start via trigger
with pytest.raises(RegisterWriteRefused):
wd.write(WD_TIME, 20)
def test_trigger_starts_and_expires(self):
wd, expired = self._make()
wd.write(WD_TIME, 5) # 500 ms
wd.write(WD_TRIGGER, 1)
assert wd.read(WD_STATUS) == WD_STATUS_ACTIVE
for _ in range(5):
wd.tick()
assert wd.read(WD_STATUS) == WD_STATUS_EXPIRED
assert expired == [1]
assert wd.process_data_blocked is True # Standard mode
def test_trigger_alternating_kicks(self):
wd, expired = self._make()
wd.write(WD_TIME, 5)
wd.write(WD_TRIGGER, 1)
for i in range(10):
wd.write(WD_TRIGGER, i % 2) # alternating 0/1 values kick
for _ in range(3):
wd.tick()
assert wd.read(WD_STATUS) == WD_STATUS_ACTIVE
assert expired == []
def test_trigger_same_value_does_not_kick(self):
wd, expired = self._make()
wd.write(WD_TIME, 2)
wd.write(WD_TRIGGER, 1)
for _ in range(4):
wd.tick()
assert wd.read(WD_STATUS) == WD_STATUS_EXPIRED
def test_function_code_mask_kick(self):
wd, expired = self._make()
wd.write(WD_TIME, 5)
wd.write(WD_MASK_1_16, 1 << 4) # only FC5 monitored
wd.write(WD_TRIGGER, 1)
wd.on_function_code(3) # not masked -> no kick
for _ in range(4):
wd.tick()
assert wd.read(WD_STATUS) == WD_STATUS_ACTIVE
wd.on_function_code(5) # masked -> kick
for _ in range(4):
wd.tick()
assert wd.read(WD_STATUS) == WD_STATUS_ACTIVE
wd.on_function_code(5)
for _ in range(5):
wd.tick()
assert wd.read(WD_STATUS) == WD_STATUS_EXPIRED
assert expired == [1]
def test_simple_stop(self):
wd, expired = self._make()
wd.write(WD_TIME, 2)
wd.write(WD_TRIGGER, 1)
wd.write(WD_SIMPLE_STOP, 0xAA55)
assert wd.read(WD_STATUS) == WD_STATUS_INACTIVE
for _ in range(5):
wd.tick()
assert expired == []
def test_restart_after_expiry(self):
wd, _ = self._make()
wd.write(WD_TIME, 2)
wd.write(WD_TRIGGER, 1)
wd.tick(); wd.tick()
assert wd.read(WD_STATUS) == WD_STATUS_EXPIRED
wd.write(0x1007, 1) # restart
assert wd.read(WD_STATUS) == WD_STATUS_ACTIVE
assert wd.process_data_blocked is False
def test_alternative_mode_restart_on_read(self):
wd, _ = self._make()
wd.write(WD_TYPE, 1) # Alternative
wd.write(WD_TIME, 2)
wd.write(WD_TRIGGER, 1)
wd.tick(); wd.tick()
# first read returns "expired" and restarts the watchdog
assert wd.read(WD_STATUS) == WD_STATUS_EXPIRED
# subsequent read shows "active" again
assert wd.read(WD_STATUS) == WD_STATUS_ACTIVE
assert wd.process_data_blocked is False
def test_close_connections_flag(self):
wd, _ = self._make()
assert wd.close_connections_on_timeout is False
wd.write(WD_CLOSE_ON_TIMEOUT, 1)
assert wd.close_connections_on_timeout is True
class TestMiscRegisters:
def test_mac_id(self, app):
words = app.registers.read_config(0x1031, 3)
# register exposes the first 3 MAC bytes, one per word (high byte)
assert words == [0x00 << 8, 0x30 << 8, 0xDE << 8]
def test_response_delay_writable(self, app):
app.registers.write_config(0x1037, [25])
assert app.registers.response_delay_ms == 25
def test_idle_timeout_writable(self, app):
app.registers.write_config(0x1030, [10])
assert app.registers.idle_timeout_ms == 1000
def test_readonly_register_rejects_write(self, app):
from wago_sim.watchdog import RegisterWriteRefused
with pytest.raises(RegisterWriteRefused):
app.registers.write_config(0x2002, [0x1111])
def test_led_error_injection(self, app):
app.registers.set_led_error(0x12, 0x34)
assert app.registers.read_config(0x1020, 2) == [0x12, 0x34]
def test_module_diagnosis_injection(self, app):
app.registers.set_module_diagnosis(module=2, channel=1, diagnosis=0b100)
assert app.registers.read_config(0x1050, 3) == [2, 1, 0b100]
+317
View File
@@ -0,0 +1,317 @@
"""Scenario engine tests: aliases, macros, pulses, ramps, assertions."""
from __future__ import annotations
import pytest
from wago_sim.scenario.engine import ScenarioEngine, ScenarioError, Verdict
def _scenario(**overrides) -> dict:
base = {
"name": "test_scenario",
"inputs": {
"start_button": {"type": "di", "channel": 1},
"level_sensor": {"type": "ai", "channel": 1, "scale": 1.0},
},
"outputs": {
"pump_relay": {"type": "do", "channel": 1},
},
"sequence": [],
}
base.update(overrides)
return base
class _Pump:
"""Tiny plant model inside the test: emulates PLC behavior.
PLC logic: relay follows the start button (combinatorial copy of
the input image into the output image, as a real master would do
via Modbus writes).
"""
def __init__(self, engine: ScenarioEngine) -> None:
self.engine = engine
# loc.bit == global 0-based digital channel index == Modbus address
self._bit = engine.alias_location("pump_relay").bit
def apply(self) -> None:
ds = self.engine.datastore
on = bool(self.engine.alias_value("start_button"))
# emulate a Modbus master writing the coil at the global address
result = ds.write_bits(self._bit, [on])
assert not isinstance(result, Exception), result
def _run(engine: ScenarioEngine, plant=None, max_ticks=10000):
engine.start()
result = None
for _ in range(max_ticks):
if plant is not None:
plant.apply()
result = engine.pump()
if result is not None:
return result
raise AssertionError("scenario did not finish")
class TestAliases:
def test_di_alias_maps_channel(self, node, datastore):
scenario = _scenario()
engine = ScenarioEngine(node, scenario)
engine.set_alias("start_button", True)
assert datastore.read_bits(0, 1) == [True]
def test_ai_alias_applies_scale(self, node):
scenario = _scenario()
engine = ScenarioEngine(node, scenario)
engine.set_alias("level_sensor", 100)
loc = node.channel("ai", 1)
assert node.datastore.get_input_word(loc.word) == 100
assert engine.alias_value("level_sensor") == 100
def test_invalid_channel_rejected(self, node):
scenario = _scenario()
scenario["inputs"]["bad"] = {"type": "di", "channel": 999}
with pytest.raises(IndexError):
ScenarioEngine(node, scenario)
def test_output_alias_read(self, node, datastore):
scenario = _scenario()
engine = ScenarioEngine(node, scenario)
bit = engine.alias_location("pump_relay").bit
assert datastore.write_bits(bit, [True]) is None
assert engine.alias_value("pump_relay") is True
class TestModuleAddressing:
"""Module type + module number + channel number addressing (DI0.1).
The pump node fixture modules: [DI_8, DI_16, DO_8, DO_16, AI_2, AO_2]
(indices 0..5). Module addressing uses 0-based module positions and
0-based channel numbers on the module.
"""
def test_module_addressing_di(self, node, datastore):
# DI0.1: module slot 0 (DI_8), channel 1 (0-based) = 2nd DI
scenario = _scenario()
scenario["inputs"]["second_di"] = {"type": "di", "module": 0, "channel": 1}
engine = ScenarioEngine(node, scenario)
engine.set_alias("second_di", True)
# global bit index 1 -> Modbus bit address 1
assert datastore.read_bits(1, 1) == [True]
assert engine.alias_value("second_di") is True
def test_module_addressing_across_modules(self, node, datastore):
# DI1.0: module slot 1 (DI_16), channel 0 = 9th DI globally
# (8 channels on module 0 come first)
scenario = _scenario()
scenario["inputs"]["di16_first"] = {"type": "di", "module": 1, "channel": 0}
engine = ScenarioEngine(node, scenario)
engine.set_alias("di16_first", True)
assert datastore.read_bits(8, 1) == [True]
def test_module_addressing_do(self, node, datastore):
# DO2.2: module slot 2 (DO_8), channel 2 = 3rd DO globally
scenario = _scenario()
scenario["outputs"]["third_do"] = {"type": "do", "module": 2, "channel": 2}
engine = ScenarioEngine(node, scenario)
bit = engine.alias_location("third_do").bit
assert bit == 2
assert datastore.write_bits(bit, [True]) is None
assert engine.alias_value("third_do") is True
def test_module_addressing_ai_ao(self, node, datastore):
# AI4.1: module slot 4 (AI_2), channel 1 = 2nd AI word
scenario = _scenario()
scenario["inputs"]["ai2"] = {"type": "ai", "module": 4, "channel": 1, "scale": 1.0}
engine = ScenarioEngine(node, scenario)
engine.set_alias("ai2", 0x1234)
loc = node.channel("ai", 2) # global 2nd AI
assert datastore.get_input_word(loc.word) == 0x1234
# AO5.1: module slot 5 (AO_2), channel 1 = 2nd AO word
scenario["outputs"] = {"ao2": {"type": "ao", "module": 5, "channel": 1}}
engine2 = ScenarioEngine(node, scenario)
bit_word = node.channel("ao", 2).word
assert datastore.write_words(bit_word, [0xBEEF]) is None
assert engine2.alias_value("ao2") == 0xBEEF
def test_module_equivalent_to_pool_addressing(self, node):
"""DI0.0 (module addressing) == di channel 1 (pool addressing)."""
scenario = _scenario()
scenario["inputs"]["m"] = {"type": "di", "module": 0, "channel": 0}
scenario["inputs"]["p"] = {"type": "di", "channel": 1}
engine = ScenarioEngine(node, scenario)
engine.set_alias("m", True)
assert engine.alias_value("p") is True
def test_module_out_of_range_rejected(self, node):
scenario = _scenario()
scenario["inputs"]["bad"] = {"type": "di", "module": 99, "channel": 0}
with pytest.raises(IndexError, match="module 99"):
ScenarioEngine(node, scenario)
def test_channel_out_of_range_rejected(self, node):
# module 0 is a DI_8 -> channels 0-7 only
scenario = _scenario()
scenario["inputs"]["bad"] = {"type": "di", "module": 0, "channel": 8}
with pytest.raises(IndexError, match="channel 8"):
ScenarioEngine(node, scenario)
def test_kind_mismatch_rejected(self, node):
# module 0 is DI-only -> DO addressing must fail
scenario = _scenario()
scenario["outputs"]["bad"] = {"type": "do", "module": 0, "channel": 0}
from wago_sim.modules import ChannelMismatch
with pytest.raises(ChannelMismatch):
ScenarioEngine(node, scenario)
def test_analog_kind_on_digital_module_rejected(self, node):
# module 0 is DI-only -> AI addressing must fail
scenario = _scenario()
scenario["inputs"]["bad"] = {"type": "ai", "module": 0, "channel": 0}
from wago_sim.modules import ChannelMismatch
with pytest.raises(ChannelMismatch):
ScenarioEngine(node, scenario)
class TestSequence:
def test_set_and_assert_pass(self, node):
scenario = _scenario(sequence=[
{"set": {"start_button": True}},
{"assert": {"label": "relay on", "conditions": {"start_button": "ON"}, "within": 0}},
])
# plant copies the input directly to the relay
engine = ScenarioEngine(node, scenario)
plant = _Pump(engine)
result = _run(engine, plant)
assert result.verdict is Verdict.PASS
def test_assert_timeout_fails(self, node):
scenario = _scenario(sequence=[
{"set": {"start_button": True}},
{"assert": {"label": "relay on", "conditions": {"pump_relay": "ON"}, "within": 100}},
])
engine = ScenarioEngine(node, scenario)
# no plant -> relay never turns on
result = _run(engine)
assert result.verdict is Verdict.FAIL
assert result.steps[-1].verdict is Verdict.FAIL
def test_wait_step(self, node):
scenario = _scenario(sequence=[
{"wait": 50},
{"assert": {"label": "done waiting", "conditions": {"start_button": "OFF"}, "within": 0}},
])
engine = ScenarioEngine(node, scenario)
result = _run(engine)
assert result.verdict is Verdict.PASS
assert result.duration_ms >= 50
def test_pulse_macro(self, node):
scenario = _scenario(
macros={
"press": [
{"set": {"start_button": True}},
{"wait": 30},
{"set": {"start_button": False}},
]
},
sequence=[
{"macro": "press"},
{"wait": 100},
{"assert": {"label": "released", "conditions": {"start_button": "OFF"}, "within": 0}},
],
)
engine = ScenarioEngine(node, scenario)
seen_on = False
engine.start()
result = None
for _ in range(500):
result = engine.pump()
if engine.alias_value("start_button"):
seen_on = True
if result is not None:
break
assert result.verdict is Verdict.PASS
assert seen_on
assert engine.alias_value("start_button") is False
def test_unknown_step_rejected(self, node):
scenario = _scenario(sequence=[{"frobnicate": {}}])
engine = ScenarioEngine(node, scenario)
engine.start()
with pytest.raises(ScenarioError):
engine.pump()
def test_report_pass_finishes(self, node):
scenario = _scenario(sequence=[{"report": "PASS"}])
engine = ScenarioEngine(node, scenario)
result = _run(engine)
assert result.verdict is Verdict.PASS
def test_report_fail(self, node):
scenario = _scenario(sequence=[{"report": "FAIL"}])
engine = ScenarioEngine(node, scenario)
result = _run(engine)
assert result.verdict is Verdict.FAIL
class TestRamps:
def test_ramp_reaches_target(self, node):
scenario = _scenario(sequence=[
{"ramp": {"name": "level_sensor", "to": 1000, "over": 100}},
{"wait": 120},
{"assert": {"label": "level", "conditions": {"level_sensor": ">= 999"}, "within": 0}},
])
engine = ScenarioEngine(node, scenario)
result = _run(engine)
assert result.verdict is Verdict.PASS
assert engine.alias_value("level_sensor") == 1000
def test_ramp_progresses_linearly(self, node):
scenario = _scenario(sequence=[
{"ramp": {"name": "level_sensor", "to": 1000, "over": 1000}},
{"wait": 100000},
])
engine = ScenarioEngine(node, scenario)
engine.start()
samples = []
for _ in range(6):
engine.pump(100)
samples.append(engine.alias_value("level_sensor"))
# ramp starts on the first tick; +100 per 100 ms thereafter
assert samples == [0, 100, 200, 300, 400, 500]
class TestEdgeDetection:
def test_rising_edge_latched(self, node, datastore):
# a pending wait keeps the scenario alive while we flip the output
scenario = _scenario(sequence=[{"wait": 10000}])
engine = ScenarioEngine(node, scenario)
bit = engine.alias_location("pump_relay").bit
engine.pump()
assert datastore.write_bits(bit, [True]) is None
engine.pump()
assert engine.consume_rising_edge("pump_relay") is True
assert engine.consume_rising_edge("pump_relay") is False
class TestConditionMatcher:
def test_operators(self, node):
scenario = _scenario()
engine = ScenarioEngine(node, scenario)
engine.set_alias("level_sensor", 500)
m = engine._matcher
resolve = engine._resolve_alias
assert m.evaluate({"level_sensor": 500})
assert m.evaluate({"level_sensor": ">= 499"})
assert not m.evaluate({"level_sensor": ">= 501"})
assert m.evaluate({"level_sensor": "< 501"})
assert m.evaluate({"level_sensor": "!= 499"})
assert m.evaluate({"level_sensor": "== 500"})
assert not m.evaluate({"level_sensor": "> 500"})
# boolean forms
assert m.evaluate({"start_button": "OFF"})
+143
View File
@@ -0,0 +1,143 @@
"""End-to-end server tests over real TCP sockets."""
from __future__ import annotations
import asyncio
import pytest
from wago_sim.mbap import build_frame
from wago_sim.pdu import (
ExceptionResponse,
FC_READ_COILS,
FC_READ_DISCRETE_INPUTS,
FC_READ_HOLDING_REGISTERS,
FC_READ_INPUT_REGISTERS,
FC_WRITE_MULTIPLE_REGISTERS,
FC_WRITE_SINGLE_COIL,
FC_WRITE_SINGLE_REGISTER,
)
async def _start(app, port=15020):
app.server.host, app.server.port = "127.0.0.1", port
await app.server.start()
await asyncio.sleep(0.05)
return port
async def _roundtrip(reader, writer, txn, request_pdu):
writer.write(build_frame(txn, 1, request_pdu))
await writer.drain()
header = await reader.readexactly(7)
length = int.from_bytes(header[4:6], "big")
body = await reader.readexactly(length - 1)
return header, body
class TestProtocolRoundtrip:
async def test_read_constants(self, app):
await _start(app)
reader, writer = await asyncio.open_connection("127.0.0.1", 15020)
# FC3 read 9 words at 0x2000
pdu = bytes([FC_READ_HOLDING_REGISTERS]) + (0x2000).to_bytes(2, "big") + (9).to_bytes(2, "big")
_, body = await _roundtrip(reader, writer, 7, pdu)
assert body[0] == FC_READ_HOLDING_REGISTERS
assert body[1] == 18
words = [int.from_bytes(body[2 + 2 * i:4 + 2 * i], "big") for i in range(9)]
assert words == [0x0000, 0xFFFF, 0x1234, 0xAAAA, 0x5555,
0x7FFF, 0x8000, 0x3FFF, 0x4000]
writer.close()
await app.server.stop()
async def test_write_and_readback(self, app):
await _start(app)
reader, writer = await asyncio.open_connection("127.0.0.1", 15020)
# FC6 write single register 0x1234 to address 0
pdu = bytes([FC_WRITE_SINGLE_REGISTER]) + (0).to_bytes(2, "big") + (0x1234).to_bytes(2, "big")
_, body = await _roundtrip(reader, writer, 1, pdu)
assert body == bytes([FC_WRITE_SINGLE_REGISTER]) + (0).to_bytes(2, "big") + (0x1234).to_bytes(2, "big")
# FC4 read back at 0x0200 mirror
pdu = bytes([FC_READ_INPUT_REGISTERS]) + (0x0200).to_bytes(2, "big") + (1).to_bytes(2, "big")
_, body = await _roundtrip(reader, writer, 2, pdu)
# response PDU: [FC, byte_count, hi, lo]
assert int.from_bytes(body[2:4], "big") == 0x1234
writer.close()
await app.server.stop()
async def test_coil_write_and_read(self, app):
await _start(app)
reader, writer = await asyncio.open_connection("127.0.0.1", 15020)
# FC5 write coil address 3 ON
pdu = bytes([FC_WRITE_SINGLE_COIL]) + (3).to_bytes(2, "big") + b"\xFF\x00"
_, body = await _roundtrip(reader, writer, 3, pdu)
assert body[0] == FC_WRITE_SINGLE_COIL
# FC1 read coils at 0x0200 (output mirror)
pdu = bytes([FC_READ_COILS]) + (0x0200).to_bytes(2, "big") + (8).to_bytes(2, "big")
_, body = await _roundtrip(reader, writer, 4, pdu)
assert body[2] & (1 << 3)
writer.close()
await app.server.stop()
async def test_illegal_address_exception(self, app):
await _start(app)
reader, writer = await asyncio.open_connection("127.0.0.1", 15020)
pdu = bytes([FC_READ_HOLDING_REGISTERS]) + (0x0100).to_bytes(2, "big") + (1).to_bytes(2, "big")
_, body = await _roundtrip(reader, writer, 5, pdu)
assert body[0] == FC_READ_HOLDING_REGISTERS | 0x80
assert body[1] == 0x02
writer.close()
await app.server.stop()
async def test_unsupported_function(self, app):
await _start(app)
reader, writer = await asyncio.open_connection("127.0.0.1", 15020)
pdu = bytes([0x07]) + b"\x00\x00"
_, body = await _roundtrip(reader, writer, 6, pdu)
assert body[0] == 0x87
assert body[1] == 0x01
assert app.stats.bad_function == 1
writer.close()
await app.server.stop()
async def test_statistics_after_errors(self, app):
await _start(app)
reader, writer = await asyncio.open_connection("127.0.0.1", 15020)
# two illegal-address reads
for txn in (10, 11):
pdu = bytes([FC_READ_HOLDING_REGISTERS]) + (0x0300).to_bytes(2, "big") + (1).to_bytes(2, "big")
await _roundtrip(reader, writer, txn, pdu)
assert app.stats.bad_address == 2
# read statistics block via FC3 at 0x1029
pdu = bytes([FC_READ_HOLDING_REGISTERS]) + (0x1029).to_bytes(2, "big") + (9).to_bytes(2, "big")
_, body = await _roundtrip(reader, writer, 12, pdu)
words = [int.from_bytes(body[2 + 2 * i:4 + 2 * i], "big") for i in range(9)]
assert words[4] == 2 # BadAddress
assert words[8] == 3 # ModTcpMessageCounter
writer.close()
await app.server.stop()
async def test_watchdog_expiry_blocks_process_data(self, app):
await _start(app)
reader, writer = await asyncio.open_connection("127.0.0.1", 15020)
# configure watchdog: 1 unit = 100 ms, monitor FC3 (bit 2)
for txn, (addr, val) in enumerate([(0x1000, 1), (0x1001, 0x0004)], start=20):
pdu = bytes([FC_WRITE_SINGLE_REGISTER]) + addr.to_bytes(2, "big") + val.to_bytes(2, "big")
await _roundtrip(reader, writer, txn, pdu)
# trigger with alternating values
pdu = bytes([FC_WRITE_SINGLE_REGISTER]) + (0x1003).to_bytes(2, "big") + (1).to_bytes(2, "big")
await _roundtrip(reader, writer, 30, pdu)
# let it expire: 100ms without masked FC3
await asyncio.sleep(0.2)
# process data read must now answer 0x04
pdu = bytes([FC_READ_HOLDING_REGISTERS]) + (0).to_bytes(2, "big") + (1).to_bytes(2, "big")
_, body = await _roundtrip(reader, writer, 31, pdu)
assert body[0] == FC_READ_HOLDING_REGISTERS | 0x80
assert body[1] == 0x04
# watchdog registers remain accessible: status reads expired (2)
pdu = bytes([FC_READ_HOLDING_REGISTERS]) + (0x1006).to_bytes(2, "big") + (1).to_bytes(2, "big")
_, body = await _roundtrip(reader, writer, 32, pdu)
# response PDU: [FC, byte_count, hi, lo]
assert int.from_bytes(body[2:4], "big") == 2
writer.close()
await app.server.stop()