commit 973d619cee3b98f233a29a08f9d366df6cfcf348 Author: Jonas Bengtsson Date: Sat Oct 3 09:46:11 2026 +0200 πŸŽ‰ Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4bd339c --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..1c9722b --- /dev/null +++ b/.vscode/launch.json @@ -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}" + } + ] +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6f4c461 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +Keep the specification file and READE TODO section in phase. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9dbd55a --- /dev/null +++ b/README.md @@ -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: ` 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). \ No newline at end of file diff --git a/config/pump_node.yaml b/config/pump_node.yaml new file mode 100644 index 0000000..ee01ea2 --- /dev/null +++ b/config/pump_node.yaml @@ -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 \ No newline at end of file diff --git a/docs/m07500362_xxxxxxxx_0en.pdf b/docs/m07500362_xxxxxxxx_0en.pdf new file mode 100644 index 0000000..bdf5608 Binary files /dev/null and b/docs/m07500362_xxxxxxxx_0en.pdf differ diff --git a/docs/m075xxxxx_xxxxxxxx_0en.pdf b/docs/m075xxxxx_xxxxxxxx_0en.pdf new file mode 100644 index 0000000..038b8bd Binary files /dev/null and b/docs/m075xxxxx_xxxxxxxx_0en.pdf differ diff --git a/docs/spec/software-spec.md b/docs/spec/software-spec.md new file mode 100644 index 0000000..a2bffea --- /dev/null +++ b/docs/spec/software-spec.md @@ -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: ` 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: ` β€” 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: ` (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 ` 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 ` 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) \ No newline at end of file diff --git a/plugins/pump_plant.py b/plugins/pump_plant.py new file mode 100644 index 0000000..86ea9ad --- /dev/null +++ b/plugins/pump_plant.py @@ -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 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..955c146 --- /dev/null +++ b/pyproject.toml @@ -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" \ No newline at end of file diff --git a/scenarios/pump_station_fat.yaml b/scenarios/pump_station_fat.yaml new file mode 100644 index 0000000..0e26deb --- /dev/null +++ b/scenarios/pump_station_fat.yaml @@ -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 \ No newline at end of file diff --git a/src/wago_sim/__init__.py b/src/wago_sim/__init__.py new file mode 100644 index 0000000..0b62b6b --- /dev/null +++ b/src/wago_sim/__init__.py @@ -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" \ No newline at end of file diff --git a/src/wago_sim/app.py b/src/wago_sim/app.py new file mode 100644 index 0000000..a8c0bad --- /dev/null +++ b/src/wago_sim/app.py @@ -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, + ) \ No newline at end of file diff --git a/src/wago_sim/cli.py b/src/wago_sim/cli.py new file mode 100644 index 0000000..6d2374c --- /dev/null +++ b/src/wago_sim/cli.py @@ -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()) \ No newline at end of file diff --git a/src/wago_sim/datastore.py b/src/wago_sim/datastore.py new file mode 100644 index 0000000..7d8ebc1 --- /dev/null +++ b/src/wago_sim/datastore.py @@ -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}") \ No newline at end of file diff --git a/src/wago_sim/mbap.py b/src/wago_sim/mbap.py new file mode 100644 index 0000000..048279c --- /dev/null +++ b/src/wago_sim/mbap.py @@ -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 \ No newline at end of file diff --git a/src/wago_sim/modules.py b/src/wago_sim/modules.py new file mode 100644 index 0000000..97753f5 --- /dev/null +++ b/src/wago_sim/modules.py @@ -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) \ No newline at end of file diff --git a/src/wago_sim/pdu.py b/src/wago_sim/pdu.py new file mode 100644 index 0000000..4bc9d39 --- /dev/null +++ b/src/wago_sim/pdu.py @@ -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).""" \ No newline at end of file diff --git a/src/wago_sim/registers.py b/src/wago_sim/registers.py new file mode 100644 index 0000000..3c7f0ba --- /dev/null +++ b/src/wago_sim/registers.py @@ -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") \ No newline at end of file diff --git a/src/wago_sim/scenario/__init__.py b/src/wago_sim/scenario/__init__.py new file mode 100644 index 0000000..aff52b8 --- /dev/null +++ b/src/wago_sim/scenario/__init__.py @@ -0,0 +1 @@ +"""Scenario package: YAML DSL model, engine, actions, assertions, reporting.""" \ No newline at end of file diff --git a/src/wago_sim/scenario/dsl.py b/src/wago_sim/scenario/dsl.py new file mode 100644 index 0000000..dc21891 --- /dev/null +++ b/src/wago_sim/scenario/dsl.py @@ -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 \ No newline at end of file diff --git a/src/wago_sim/scenario/engine.py b/src/wago_sim/scenario/engine.py new file mode 100644 index 0000000..f3261d9 --- /dev/null +++ b/src/wago_sim/scenario/engine.py @@ -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 \ No newline at end of file diff --git a/src/wago_sim/scenario/report.py b/src/wago_sim/scenario/report.py new file mode 100644 index 0000000..e13d512 --- /dev/null +++ b/src/wago_sim/scenario/report.py @@ -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] \ No newline at end of file diff --git a/src/wago_sim/server.py b/src/wago_sim/server.py new file mode 100644 index 0000000..28b7565 --- /dev/null +++ b/src/wago_sim/server.py @@ -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) \ No newline at end of file diff --git a/src/wago_sim/stats.py b/src/wago_sim/stats.py new file mode 100644 index 0000000..b0f2fc2 --- /dev/null +++ b/src/wago_sim/stats.py @@ -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 \ No newline at end of file diff --git a/src/wago_sim/watchdog.py b/src/wago_sim/watchdog.py new file mode 100644 index 0000000..4e77899 --- /dev/null +++ b/src/wago_sim/watchdog.py @@ -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).""" \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e558f8e --- /dev/null +++ b/tests/conftest.py @@ -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 \ No newline at end of file diff --git a/tests/test_datastore.py b/tests/test_datastore.py new file mode 100644 index 0000000..467e038 --- /dev/null +++ b/tests/test_datastore.py @@ -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 \ No newline at end of file diff --git a/tests/test_integration_pymodbus.py b/tests/test_integration_pymodbus.py new file mode 100644 index 0000000..343bd80 --- /dev/null +++ b/tests/test_integration_pymodbus.py @@ -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() \ No newline at end of file diff --git a/tests/test_mbap.py b/tests/test_mbap.py new file mode 100644 index 0000000..c78b8d7 --- /dev/null +++ b/tests/test_mbap.py @@ -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() \ No newline at end of file diff --git a/tests/test_pdu.py b/tests/test_pdu.py new file mode 100644 index 0000000..509bdf5 --- /dev/null +++ b/tests/test_pdu.py @@ -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 \ No newline at end of file diff --git a/tests/test_registers.py b/tests/test_registers.py new file mode 100644 index 0000000..3d04563 --- /dev/null +++ b/tests/test_registers.py @@ -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] \ No newline at end of file diff --git a/tests/test_scenario_engine.py b/tests/test_scenario_engine.py new file mode 100644 index 0000000..2224c98 --- /dev/null +++ b/tests/test_scenario_engine.py @@ -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"}) \ No newline at end of file diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..d668f54 --- /dev/null +++ b/tests/test_server.py @@ -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() \ No newline at end of file