🎉 Initial commit
This commit is contained in:
+43
@@ -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
|
||||
Vendored
+69
@@ -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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
# wago-modbustcp-simulator-py
|
||||
|
||||
**WAGO 750-362 Modbus TCP bus coupler simulator for autonomous PLC FAT testing.**
|
||||
|
||||
The simulator replaces a physical WAGO I/O node during Factory
|
||||
Acceptance Tests: the PLC under test connects via Modbus TCP and talks to
|
||||
a virtual node whose I/O behavior is driven by scripted scenarios.
|
||||
|
||||
## Features
|
||||
|
||||
- Faithful WAGO 750-362 register map (manual §11.2.4): process image
|
||||
words/bits with the exact mapped/unmapped areas and exception behavior,
|
||||
read-back mirrors (+`0x0200` / +`0x1000`), configuration registers
|
||||
`0x1000–0x2FFF`, constants `0x2000–0x2008` (byte-order check `0x1234`).
|
||||
- Function codes FC1, FC2, FC3, FC4, FC5, FC6, FC11, FC15, FC16, FC22,
|
||||
FC23 with the device limits (2000/800 bits, 125/100 registers).
|
||||
- Modbus watchdog (`0x1000–0x100B`), statistics block (`0x1029`),
|
||||
response-delay injection (`0x1037`), module diagnosis (`0x1050`).
|
||||
- Virtual node built from a YAML module list (WAGO 750 catalog subset,
|
||||
extensible).
|
||||
- **Scenario engine**: YAML scenarios with aliases, macros, ramps,
|
||||
pulses, waits, and PASS/FAIL assertions with timeouts; state machines
|
||||
and Python action plugins for complex plant models.
|
||||
- **Two I/O addressing schemes** for aliases: *module-based* —
|
||||
`{ type: di, module: 2, channel: 1 }` = `DI2.1` (module slot and
|
||||
channel both 0-based) — and *pool-based* — `{ type: di, channel: 3 }`
|
||||
(1-based index across the node's DI pool). Both may be mixed.
|
||||
- **Bench mode** *(requested, planned — spec §9)*: run the simulator
|
||||
without a scenario (`wago-sim serve --node ...` already works);
|
||||
planned additions: `--set DI0.1=ON` startup seeding, an interactive
|
||||
console monitor with live I/O table, and runtime set/pulse/watch
|
||||
commands.
|
||||
- **Web UI** *(requested, planned — spec §10)*: browser front-end to
|
||||
overlook and manipulate the virtual node's I/O — live channel tables
|
||||
with `DI0.1`-style addresses, toggle/force/pulse/ramp actions,
|
||||
watchdog/statistics/diagnostics views with fault injection, and
|
||||
multi-client consistency for collaborative FAT benches.
|
||||
- **Long-duration (continuous) simulation** *(requested, planned — see
|
||||
`docs/spec/software-spec.md` §8)*: endurance/soak testing over days or
|
||||
weeks of simulated time. Designated inputs are driven from streaming
|
||||
CSV datasets (historian exports, demand/operator profiles, seamless
|
||||
`repeat`, `time_factor` acceleration), while degradation models age
|
||||
plant parameters (e.g., pump capacity) from wear counters — exercising
|
||||
the PLC's compensation, maintenance-alarm and service-recovery logic.
|
||||
Continuous `invariants` and `rate_limits` produce bounded, episode-based
|
||||
reports for unattended runs.
|
||||
- ≤ 15 concurrent Modbus TCP connections, idle-timeout handling.
|
||||
|
||||
## Install (dev)
|
||||
|
||||
```bash
|
||||
pip install -e .[dev]
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Run the Modbus TCP server against a node configuration:
|
||||
|
||||
```bash
|
||||
wago-sim serve --node config/pump_node.yaml --port 502
|
||||
```
|
||||
|
||||
Validate a scenario:
|
||||
|
||||
```bash
|
||||
wago-sim validate --scenario scenarios/pump_station_fat.yaml
|
||||
```
|
||||
|
||||
Run a scenario headless and write reports:
|
||||
|
||||
```bash
|
||||
wago-sim run --node config/pump_node.yaml \
|
||||
--scenario scenarios/pump_station_fat.yaml \
|
||||
--report-jsonl out/report.jsonl --report-csv out/report.csv
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- `docs/spec/software-spec.md` — the software specification
|
||||
(requirements, architecture, scenario DSL, acceptance criteria,
|
||||
and the long-duration simulation feature request in §8).
|
||||
- `docs/m07500362_xxxxxxxx_0en.pdf` — WAGO 750-362 reference manual.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
## TODO
|
||||
|
||||
Work remaining, tracked against the software specification
|
||||
(`docs/spec/software-spec.md`). Completed items are checked and grouped
|
||||
first; open items follow, grouped by spec phase, with the spec section or
|
||||
requirement ID for each item in parentheses.
|
||||
|
||||
### Completed — framework phase (spec §3 + §4 core, 124 tests passing)
|
||||
|
||||
- [x] MBAP + PDU codecs: function codes FC1, FC2, FC3, FC4, FC5, FC6,
|
||||
FC11, FC15, FC16, FC22, FC23 with the 750-362 device limits
|
||||
(2000/800 bits, 125/100 registers) and exception codes (§3.2).
|
||||
- [x] WAGO register map (§3.3): word/bit process-image areas with the
|
||||
exact mapped/unmapped ranges, illegal-address exceptions, output
|
||||
read-back mirrors (+`0x0200` / +`0x1000`), digital
|
||||
channel-number bit addressing, constants `0x2000–0x2008`
|
||||
(incl. byte-order check `0x2002` = `0x1234`).
|
||||
- [x] Configuration/diagnostic/firmware registers (§3.5): image sizes
|
||||
`0x1022–0x1025`, statistics `0x1029`, connections `0x102A`, idle
|
||||
timeout `0x1030`, MAC-ID `0x1031`, response delay `0x1037`,
|
||||
module diagnosis `0x1050`, LED error `0x1020/0x1021`.
|
||||
- [x] Modbus watchdog (§3.4): registers `0x1000–0x100B`, Standard and
|
||||
Alternative modes, trigger/masks/stop sequences, expiry clears
|
||||
outputs, address-aware `0x04` lock in Standard mode, optional
|
||||
TCP close on timeout.
|
||||
- [x] Fault-injection paths (§3.6): response-delay injection,
|
||||
connection refusal beyond 15, illegal-address probing responses,
|
||||
diagnosis/LED registers scriptable.
|
||||
- [x] Virtual node built from a YAML module list (13-module WAGO 750
|
||||
catalog subset; complex-first, bit-byte-packed image layout per
|
||||
manual §7.3.2.1).
|
||||
- [x] Scenario DSL (§4): named aliases with `scale`/`offset`, macros
|
||||
with parameters (inline expansion, blocking waits inside macros),
|
||||
sequence steps `set` / `reset` / `toggle` / `pulse` / `ramp` /
|
||||
`wait` / `assert` / `report`, rising-edge detection on all
|
||||
aliases.
|
||||
- [x] Two I/O addressing schemes for scenario aliases (FR-4.3.11):
|
||||
module-based (`{ type: di, module: 2, channel: 1 }` = `DI2.1`,
|
||||
module slot and channel 0-based) and pool-based (`channel:` =
|
||||
1-based index within the node's kind pool); kind/module/channel
|
||||
validation with clear errors; both verified by tests.
|
||||
- [x] Assertions with `within:` timeouts → PASS/FAIL verdicts with
|
||||
sim-time stamps (FR-4.3.5).
|
||||
- [x] Python action/plant-model plugins: per-tick models and step
|
||||
actions (FR-4.3.8), e.g. `plugins/pump_plant.py`.
|
||||
- [x] Deterministic tick-driven engine: sim-time decoupled from
|
||||
wall-clock (FR-4.3.9 core).
|
||||
- [x] CLI: `wago-sim serve / run / validate`.
|
||||
- [x] Reports: console verdict summary, JSONL and CSV export
|
||||
(`--report-jsonl` / `--report-csv`).
|
||||
- [x] asyncio Modbus TCP server: ≤ 15 concurrent connections, idle
|
||||
connection reaper, response-delay task.
|
||||
- [x] Test suite: 124 tests incl. real-socket server tests and
|
||||
`pymodbus` interop; spec §7 acceptance criteria 1–7 covered.
|
||||
|
||||
### Framework gaps (spec §4 — Scenario & Macro Engine)
|
||||
|
||||
- [ ] State machines: `states:` with entry actions, transitions on
|
||||
input/output edges or conditions, and guards (FR-4.3.2) —
|
||||
currently only the time-ordered `sequence:` is implemented.
|
||||
- [ ] Actions `noise`, `sin`, `hold` on simulated inputs (FR-4.3.4) —
|
||||
`set`/`reset`/`pulse`/`toggle`/`ramp` are done.
|
||||
- [ ] `wait_edge` step is accepted but not yet blocking — implement
|
||||
edge-wait semantics in the engine loop.
|
||||
- [ ] Assertions on statistics registers, e.g. "watchdog expired",
|
||||
"BadAddress counter > 0" (FR-4.3.6).
|
||||
- [ ] Determinism guard: seed/record plugin RNG state so identical runs
|
||||
produce identical reports (FR-4.3.9, NFR-6.3) — needed once
|
||||
`noise`/`sin` or randomizing plugins exist.
|
||||
- [ ] Multi-node scenarios — several couplers in one scenario
|
||||
(FR-4.3.10).
|
||||
|
||||
### Phase 2 — Module catalog expansion & bench mode (spec §9)
|
||||
|
||||
- [ ] Analog module variants, counter, pulse-width, SSI, incremental
|
||||
encoder, serial interface, DALI/DSI modules with their
|
||||
process-data layouts (spec §12.2 of the WAGO manual).
|
||||
- [ ] Mailbox parameterization traffic beyond process data.
|
||||
- [ ] Bench mode tooling (§9): `--set DI0.1=ON` startup seeding,
|
||||
interactive console monitor (`--monitor`) with live channel table
|
||||
and change highlighting (FR-9.2.2–9.2.3).
|
||||
- [ ] Console runtime manipulation commands: `set`, `pulse`, `watch`,
|
||||
`reset stats` (FR-9.2.4).
|
||||
- [ ] `serve --scenario` optional combination + passive-node hint
|
||||
(FR-9.2.5). *(Plain `serve` without a scenario already works —
|
||||
see Completed above.)*
|
||||
|
||||
### Phase 3 — Long-duration (continuous) simulation (spec §8)
|
||||
The soak-testing feature requested 2026-10-03 — specified in §8,
|
||||
not yet implemented:
|
||||
|
||||
- [ ] `scenario/datasets.py` — streaming CSV playback for designated
|
||||
inputs: header-driven time column, `interpolation: linear|hold`,
|
||||
`repeat` with seamless seams, `time_factor` acceleration,
|
||||
`until` limits, bounded memory, ERROR verdict with file/line on
|
||||
malformed data (FR-8.2.1–8.2.11).
|
||||
- [ ] `scenario/degradation.py` — wear counters (runtime hours, start
|
||||
cycles, load-hours) and degradation models (`linear`,
|
||||
`exponential`, `staircase`, plugin shapes) computing plant
|
||||
parameters such as `pump_capacity` (FR-8.3.1–8.3.2).
|
||||
- [ ] Degradation hooks in plant plugins: `engine.parameter()` accessor
|
||||
used in loop physics, e.g. actual flow = nominal × capacity
|
||||
(FR-8.3.3).
|
||||
- [ ] `maintenance_at` thresholds: diagnosis injection via register
|
||||
`0x1050`, maintenance-alarm assertions, `maintenance_reset`
|
||||
action restoring capacity and clearing diagnoses (FR-8.3.4–8.3.5,
|
||||
FR-8.3.8).
|
||||
- [ ] `scenario/invariants.py` — per-tick `invariants` with bounded
|
||||
episode aggregation and `rate_limits` over sliding one-hour
|
||||
windows (FR-8.4.1–8.4.2).
|
||||
- [ ] `snapshot_every` periodic dumps of aliases, wear counters, and
|
||||
degradation parameters (FR-8.4.3).
|
||||
- [ ] Continuous-mode verdict rules and bounded reporting for unattended
|
||||
multi-day runs (FR-8.4.4–8.4.5).
|
||||
- [ ] Scheduled sequence steps via `at: <duration>` interleaved with
|
||||
datasets on the sim-time axis (FR-8.4.6).
|
||||
- [ ] Acceptance tests per spec §8.8 (streaming 100k-row CSV,
|
||||
interpolation checks, repeat determinism, time_factor math,
|
||||
alias-ownership validation, degradation/recovery round-trip,
|
||||
episode recording, full 28-day soak).
|
||||
|
||||
### Phase 4 — Reporting (spec §11)
|
||||
|
||||
- [ ] FAT report generator (HTML/PDF) from scenario results and Modbus
|
||||
traces (console/JSONL/CSV export is done — see Completed above).
|
||||
- [ ] Scenario comparison (run-vs-run diff) and template library per
|
||||
project type.
|
||||
|
||||
### Phase 5 — Multi-node orchestration & protocol variants (spec §11)
|
||||
|
||||
- [ ] Several couplers in one FAT bench (ties into FR-4.3.10).
|
||||
- [ ] Modbus UDP variant.
|
||||
- [ ] WBM (web-based management) simulation pages.
|
||||
- [ ] **Web UI (§10)**: browser front-end for overlooking and
|
||||
manipulating I/O — node/module/channel tables with `DI0.1`-style
|
||||
and Modbus addresses (FR-10.2.2), live value updates from all
|
||||
sources (FR-10.2.3), toggle/force/pulse/ramp manipulation through
|
||||
Modbus-consistent paths (FR-10.2.4), diagnostics views with
|
||||
fault injection and statistics reset (FR-10.2.5), scenario/run
|
||||
view (FR-10.2.6), multi-client consistency (FR-10.2.7), optional
|
||||
read-only/access control (FR-10.2.8), alias-ownership protection
|
||||
during scenario runs (FR-10.2.9).
|
||||
|
||||
### Phase 6 — CI integration (spec §11)
|
||||
|
||||
- [ ] Headless scenario execution in CI pipelines.
|
||||
- [ ] JUnit XML export of verdicts for test dashboards.
|
||||
|
||||
### Operational hardening (spec §6 — NFRs)
|
||||
|
||||
- [ ] Per-transaction Modbus trace log with sim-time + wall-time stamps
|
||||
and PDU hex (NFR-6.4).
|
||||
- [ ] Performance budget validation: ≥ 500 requests/s, ≤ 10 ms added
|
||||
latency (NFR-6.1).
|
||||
@@ -0,0 +1,12 @@
|
||||
# Virtual WAGO node configuration — pump station FAT bench
|
||||
# Simulated device: WAGO 750-362 Modbus TCP coupler
|
||||
name: pump_station_node
|
||||
coupler: 750-362
|
||||
|
||||
modules:
|
||||
- "750-430" # 8-channel digital input 24VDC
|
||||
- "750-1405" # 16-channel digital input 24VDC
|
||||
- "750-530" # 8-channel digital output 24VDC
|
||||
- "750-1516" # 16-channel digital output 24VDC
|
||||
- "750-467" # 2-channel analog input 4-20mA
|
||||
- "750-554" # 2-channel analog output 4-20mA
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,875 @@
|
||||
# Software Specification
|
||||
|
||||
**WAGO Modbus TCP Simulator — Autonomous FAT Testing Framework**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Project | `wago-modbustcp-simulator-py` |
|
||||
| Version | 1.1 (Draft for review) |
|
||||
| Date | 2026-10-03 |
|
||||
| Status | Framework phase; module addressing implemented (FR-4.3.11); long-duration simulation (§8), bench mode (§9) and web UI (§10) requested |
|
||||
| Target device simulated | WAGO 750-362 / 750-362/0000-0001 FC Modbus TCP (G4) bus coupler |
|
||||
| Reference documents | `docs/m07500362_xxxxxxxx_0en.pdf` (750-362 Modbus TCP manual), Modbus TCP specification (modbus.org) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
### 1.1 Purpose
|
||||
|
||||
This document specifies a software system that **simulates a WAGO 750-362 Modbus TCP fieldbus coupler** on a standard PC, to be used as a test bench during **Factory Acceptance Tests (FAT)** of PLC programs.
|
||||
|
||||
During FAT the PLC under test is normally wired to a real WAGO I/O node. This simulator replaces the physical node entirely: the PLC (or any Modbus TCP master/SCADA) connects over TCP/IP to the simulator, which responds exactly like a 750-362 coupler — same function codes, same register map, same exception behavior, same timing characteristics.
|
||||
|
||||
### 1.2 Problem being solved
|
||||
|
||||
FAT of PLC programs requires either:
|
||||
|
||||
1. Physical I/O hardware (expensive, must be wired per project, error-prone, single-purpose), or
|
||||
2. A generic Modbus slave (does not implement the WAGO-specific register map, digital-channel addressing, watchdog, statistics, or module-diagnostics behavior; PLC programs validated against it may fail against a real node).
|
||||
|
||||
The simulator provides the **exact WAGO behavior** plus **scriptable, autonomous scenario execution**, so a complete FAT round — including fault cases — can run unattended and produce a machine-verifiable result.
|
||||
|
||||
### 1.3 Scope
|
||||
|
||||
In scope (v1.0 framework):
|
||||
|
||||
- Modbus TCP server (slave) with the 750-362 function-code set.
|
||||
- WAGO 750-362 register map: process image (words + bits), configuration, diagnostic, constant, and firmware-info registers.
|
||||
- Virtual I/O node: a configurable list of WAGO 750/753 I/O modules forming the process image.
|
||||
- **Scenario engine**: scripted macros that observe input states and act on outputs (the automation core).
|
||||
- Deterministic simulation clock and scheduler.
|
||||
- CLI runner with configuration files.
|
||||
|
||||
Out of scope (v1.0): HTTP/WBM web interface, FTP, SNMP, UDP variant of Modbus, DHCP/BootP provisioning, real-time behavior beyond response latency simulation, and module parameterization (mailbox traffic) beyond process data.
|
||||
|
||||
### 1.4 Definitions
|
||||
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| PII | Process Input Image — data the coupler presents to the master (from input modules) |
|
||||
| PIO | Process Output Image — data the master writes (to output modules) |
|
||||
| MBAP | Modbus Application Protocol header (7-byte TCP header) |
|
||||
| FC | Modbus function code |
|
||||
| FAT | Factory Acceptance Test |
|
||||
| Scenario | A scripted, timed sequence of state changes and assertions |
|
||||
| Macro | A reusable scripted action (e.g., "pulse output 3 for 500 ms") |
|
||||
| Node | One simulated fieldbus coupler + its I/O module configuration |
|
||||
| LTS | Long-Time Simulation — continuous (“soak”) mode running hours to weeks of simulated time |
|
||||
| Dataset | A CSV time-series file driving designated input aliases in continuous mode |
|
||||
| Wear counter | Engine-maintained per-alias statistic: runtime hours, edge counts, load-hours |
|
||||
| Degradation model | Time/cycle-dependent transformation of a plant parameter (e.g., pump capacity) |
|
||||
| Invariant | A condition that must hold every tick throughout a continuous run |
|
||||
| Episode | One aggregated violation period of an invariant (bounded reporting) |
|
||||
|
||||
---
|
||||
|
||||
## 2. System Overview
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph DUT["Devices under test"]
|
||||
PLC["PLC 1..n\n(Modbus TCP masters)"]
|
||||
end
|
||||
|
||||
subgraph SIM["Simulator (this system)"]
|
||||
MB["Modbus TCP Server\n(asyncio, ≤ 15 connections)"]
|
||||
DS["Datastore\n(WAGO word & bit maps)"]
|
||||
NODE["Virtual WAGO Node\n(module list → process image)"]
|
||||
SE["Scenario Engine\n(states → actions)"]
|
||||
CLK["Deterministic Clock\n+ Scheduler"]
|
||||
LOG["Event Log / Traces"]
|
||||
end
|
||||
|
||||
PLC -- "MBAP frames (FC1..FC23)" --> MB
|
||||
MB <--> DS
|
||||
DS <--> NODE
|
||||
SE -- "read inputs / write outputs" --> NODE
|
||||
SE <--> CLK
|
||||
NODE -- change events --> SE
|
||||
LOG -.records.- MB
|
||||
LOG -.records.- SE
|
||||
```
|
||||
|
||||
The system is a pure **slave/server**: it never initiates Modbus traffic. Autonomy comes from the **scenario engine**, which drives the *field side* of the virtual node (as if sensors moved and actuators did something), while the PLC drives the *master side* through normal Modbus writes.
|
||||
|
||||
---
|
||||
|
||||
## 3. Functional Requirements — Modbus Server
|
||||
|
||||
### 3.1 Transport & connections
|
||||
|
||||
- FR-3.1.1: The server shall listen on TCP port 502 (configurable) and speak Modbus TCP with the standard MBAP header.
|
||||
- FR-3.1.2: The server shall support **up to 15 concurrent Modbus TCP connections** (per 750-362 spec, §7.3). Connection number 16 shall be refused.
|
||||
- FR-3.1.3: Idle connections shall be closed after the timeout configured in register `0x1030` (default 600 × 100 ms). Outputs remain in last state (§11.2.5.5).
|
||||
- FR-3.1.4: Protocol identifier other than 0 in MBAP shall increment the `BadProtocol` statistic and be ignored.
|
||||
- FR-3.1.5: Unit identifier shall be ignored (accepted, echoed back), as on the real device.
|
||||
|
||||
### 3.2 Function codes
|
||||
|
||||
The simulator shall implement exactly the 750-362 function set (§11.2.2) and reject all others with exception `0x01` (Illegal Function):
|
||||
|
||||
| FC | Function | Limits (R/W) |
|
||||
|----|----------|--------------|
|
||||
| 01 | Read Coils | 2000 / — |
|
||||
| 02 | Read Discrete Inputs | 2000 / — |
|
||||
| 03 | Read Holding Registers | 125 / — |
|
||||
| 04 | Read Input Registers | 125 / — |
|
||||
| 05 | Write Single Coil | — / 1 |
|
||||
| 06 | Write Single Register | — / 1 |
|
||||
| 11 | Get Comm Event Count | — |
|
||||
| 15 | Write Multiple Coils | — / 800 |
|
||||
| 16 | Write Multiple Registers | — / 100 |
|
||||
| 22 | Mask Write Register | — / 1 |
|
||||
| 23 | Read/Write Multiple Registers | 125 / 100 |
|
||||
|
||||
Exceeding limits → exception `0x03` (Illegal Data Value) and increment `TooManyRegisters` / `TooManyBits`.
|
||||
|
||||
Exception codes (§11.2.3): `0x01` Illegal Function, `0x02` Illegal Data Address, `0x03` Illegal Data Value, `0x04` Slave Device Failure, `0x05` Acknowledge, `0x06` Server Busy, `0x08` Memory Parity Error, `0x0A`/`0x0B` Gateway errors.
|
||||
|
||||
### 3.3 Register map (WAGO 750-362)
|
||||
|
||||
#### 3.3.1 Word (register) access — reading (FC3/FC4)
|
||||
|
||||
| Modbus address (hex) | Area | Behavior |
|
||||
|---|---|---|
|
||||
| `0x0000–0x00FF` | Physical input area (1), %IW0–255 | First 256 words of input process image |
|
||||
| `0x0100–0x01FF` | — | Exception `0x02` |
|
||||
| `0x0200–0x02FF` | Physical output area (1) | Read-back mirror of `%QW0–255` (outputs written at `0x0000` are readable here, +`0x0200` offset) |
|
||||
| `0x0300–0x0FFF` | — | Exception `0x02` |
|
||||
| `0x1000–0x2FFF` | Configuration registers | See §3.5 |
|
||||
| `0x3000–0x5FFF` | — | Exception `0x02` |
|
||||
| `0x6000–0x62FB` | Physical input area (2), %IW256–1020 | Additional 764 words |
|
||||
| `0x62FC–0x6FFF` | — | Exception `0x02` |
|
||||
| `0x7000–0x72FB` | Physical output area (2), %QW256–1020 | Read-back mirror, +`0x01000` offset from write area (2) |
|
||||
| `0x72FC–0xFFFF` | — | Exception `0x02` |
|
||||
|
||||
#### 3.3.2 Word access — writing (FC6/FC16)
|
||||
|
||||
| Modbus address (hex) | Area |
|
||||
|---|---|
|
||||
| `0x0000–0x00FF` | Physical output area (1), %QW0–255 |
|
||||
| `0x0100–0x01FF` | Exception `0x02` |
|
||||
| `0x0200–0x02FF` | Physical output area (1) (mirror accepted for writing) |
|
||||
| `0x0300–0x0FFF` | Exception `0x02` |
|
||||
| `0x1000–0x2FFF` | Configuration registers |
|
||||
| `0x3000–0x5FFF` | Exception `0x02` |
|
||||
| `0x6000–0x62FB` | Physical output area (2), %QW256–1020 |
|
||||
| `0x62FC–0x6FFF` | Exception `0x02` |
|
||||
| `0x7000–0x72FB` | Physical output area (2) (mirror) |
|
||||
| `0x72FC–0xFFFF` | Exception `0x02` |
|
||||
|
||||
#### 3.3.3 Bit (coil) access — reading (FC1/FC2) & writing (FC5/FC15)
|
||||
|
||||
| Modbus address (hex) | Area |
|
||||
|---|---|
|
||||
| `0x0000–0x01FF` | Physical input area (1) — first 512 digital inputs (read) |
|
||||
| `0x0200–0x03FF` | Physical output area (1) — first 512 digital outputs (read mirror + write) |
|
||||
| `0x0800–0x85F7` | Physical input area (2) — digital inputs 513–2039 |
|
||||
| `0x9000–0x95F7` | Physical output area (2) — digital outputs 513–2039 |
|
||||
| all others | Exception `0x02` |
|
||||
|
||||
**Digital channel addressing rule** (§11.2.4): bit addresses are **channel numbers** — digital input no. 47 has Modbus address 46 — independent of the physical module layout. Word-oriented modules are laid out first (in physical order, from word 0), then bit-oriented modules are byte-packed in physical order.
|
||||
|
||||
#### 3.3.4 Constant registers (FC3/FC4 read-only)
|
||||
|
||||
| Addr | Value | Purpose |
|
||||
|---|---|---|
|
||||
| `0x2000` | `0x0000` | Zero |
|
||||
| `0x2001` | `0xFFFF` | Ones |
|
||||
| `0x2002` | `0x1234` | Byte-order check (Intel format expected) |
|
||||
| `0x2003` | `0xAAAAl` | Bit mask 1 |
|
||||
| `0x2004` | `0x5555` | Bit mask 2 |
|
||||
| `0x2005` | `0x7FFF` | Max positive |
|
||||
| `0x2006` | `0x8000` | Max negative |
|
||||
| `0x2007` | `0x3FFF` | Half positive |
|
||||
| `0x2008` | `0x4000` | Half negative |
|
||||
|
||||
#### 3.3.5 Firmware-info registers (read-only)
|
||||
|
||||
`0x2010` revision, `0x2011` series code (`0x02EE` = 750), `0x2012` coupler code (`0x0166` = 362), `0x2013`/`0x2014` major/minor, `0x2020` description (16 words), `0x2021` compile time, `0x2022` compile date, `0x2023` firmware loader info, `0x2030–0x2033` connected-module descriptions, `0x2035` process-image settings, `0x2036` diagnostics, `0x2040` software reset (write `0x55AA`/`0xAA55`), `0x2043` factory settings.
|
||||
|
||||
### 3.4 Watchdog (§11.2.5.2/3)
|
||||
|
||||
The Modbus watchdog shall be simulated:
|
||||
|
||||
- `0x1000` timeout value ×100 ms (default `0x0064`), writable only when WD inactive.
|
||||
- `0x1001`/`0x1002` coding masks (bit *n−1* of the mask = FC *n*; default `0xFFFF`).
|
||||
- `0x1003` trigger (write nonzero, alternating values), `0x1005` stop (`0xAAAA` then `0x5555`), `0x1008` simple stop (`0xAA55`/`0x55AA`), `0x1007` restart (write `1`).
|
||||
- `0x1006` status: `0` inactive / `1` active / `2` expired.
|
||||
- `0x1009` close-socket-on-timeout, `0x100A` type Standard/Alternative, `0x100B` save WD params (remanent).
|
||||
- On timeout: **all analog outputs → 0, all digital outputs → OFF**; in Standard mode further process-data access is answered with exception `0x04`; WD registers remain accessible; `0x1009 = 1` additionally closes TCP connections.
|
||||
- Watchdog events shall be visible to the scenario engine (assertions: "WD triggered at t seconds").
|
||||
|
||||
### 3.5 Configuration & diagnostic registers
|
||||
|
||||
- `0x1022`–`0x1025`: image sizes (analog in/out bits, digital in/out bits) computed from the configured module list.
|
||||
- `0x1029` (9 words) Modbus TCP statistics: SlaveDeviceFailure, BadProtocol, BadLength, BadFunction, BadAddress, BadData, TooManyRegisters, TooManyBits, ModTcpMessageCounter. Reset by writing `0xAA55`/`0x55AA`.
|
||||
- `0x102A` active TCP connections.
|
||||
- `0x1030` Modbus TCP idle timeout (×100 ms).
|
||||
- `0x1031` MAC-ID (3 words, configurable to emulate a specific device).
|
||||
- `0x1037` response delay (ms) — the simulator shall actually delay responses, to emulate link latency.
|
||||
- `0x1038` TOS/DSCP (stored, echoed).
|
||||
- `0x1050` module diagnosis (3 words): module, channel, diagnosis bits (underrange … collective failure) — scriptable fault injection.
|
||||
- `0x1020`/`0x1021` LED error code/argument — scenario-settable.
|
||||
- `0x102B` KBUS reset (write): re-reads the module configuration; process image rebuilt.
|
||||
|
||||
### 3.6 Fault injection
|
||||
|
||||
FR-3.6.1: The scenario engine shall be able to simulate fieldbus faults:
|
||||
|
||||
- KBUS/local-bus failure → `SlaveDeviceFailure` statistic increments and process-data reads may return exception `0x04`.
|
||||
- Response-delay injection via `0x1037`.
|
||||
- Connection refusal beyond 15 concurrent connections.
|
||||
- Illegal-address probing (read/write the exception areas) → correct `0x02` responses (this validates master error handling).
|
||||
|
||||
### 3.7 Process data semantics
|
||||
|
||||
- Word mapping of digital channels follows the **Intel format** (§7.3.3): channel 1 = bit 0 of the low byte (`D0`), high byte `D1` on top.
|
||||
- Digital word mapping: `Bit15 … Bit0` = `Byte D1 D0`, bit 0 = lowest channel number.
|
||||
- Analog modules contribute 16-bit data words (control/status bytes not accessible via Modbus).
|
||||
- A module list defines the image; the framework computes addresses and exposes them in the log and via module-description registers (`0x2030+`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Functional Requirements — Scenario & Macro Engine
|
||||
|
||||
### 4.1 Concept
|
||||
|
||||
A **scenario** is a scripted, time-ordered description of the *world around the PLC*: sensor inputs change over time, actuators respond, and the script asserts on PLC outputs. This is what makes FAT autonomous — no operator needs to toggle signals.
|
||||
|
||||
- **State-based**: rules keyed on input/output conditions (`when input 5 turns ON …`).
|
||||
- **Time-based**: scheduled events (`after 30 s …`).
|
||||
- **Output actions**: `set`, `reset`, `pulse`, `toggle`, `ramp`, `hold_value` on simulated field outputs (what the PLC sees as its inputs).
|
||||
- **Input observations**: the engine reads the process output image (what the PLC wrote) with edge detection (rising/falling).
|
||||
- **Assertions**: expected PLC behavior (output X ON within 2 s after input Y) → FAT verdicts.
|
||||
|
||||
### 4.2 Scenario DSL (YAML)
|
||||
|
||||
```yaml
|
||||
name: pump_station_fat
|
||||
description: "Full FAT round for pump station PLC program"
|
||||
node: config/pump_node.yaml # module configuration
|
||||
timing: { tick: 10ms } # simulation tick
|
||||
|
||||
inputs: # named aliases → digital/analog inputs
|
||||
start_button: { type: di, module: 0, channel: 0 } # DI0.0
|
||||
level_sensor: { type: ai, module: 4, channel: 0, scale: 0.1 } # AI4.0, mm → counts
|
||||
em_stop_ok: { type: di, module: 0, channel: 3 } # DI0.3
|
||||
|
||||
outputs: # named aliases → PLC outputs (read-only for script)
|
||||
pump_relay: { type: do, module: 2, channel: 0 } # DO2.0
|
||||
valve_open: { type: do, module: 2, channel: 1 } # DO2.1
|
||||
speed_setpoint: { type: ao, module: 5, channel: 0, scale: 0.1 } # AO5.0
|
||||
|
||||
macros:
|
||||
pulse_start: # reusable macro
|
||||
- set: start_button = ON
|
||||
- wait: 250ms
|
||||
- set: start_button = OFF
|
||||
|
||||
states: # optional state machine
|
||||
idle:
|
||||
on: { em_stop_ok: ON }
|
||||
goto: running
|
||||
running:
|
||||
entry: [ ramp: level_sensor, to: 8500, over: 30s ]
|
||||
on: { pump_relay: RISING }
|
||||
assert: { valve_open: ON, within: 2s }
|
||||
goto: full
|
||||
...
|
||||
|
||||
sequence: # time-ordered script (or fully state-driven)
|
||||
- macro: pulse_start
|
||||
- assert: { pump_relay: ON, within: 2s }
|
||||
- wait: 5s
|
||||
- set: level_sensor = 4200
|
||||
- assert: { speed_setpoint: ">= 1000", within: 3s }
|
||||
- report: PASS
|
||||
```
|
||||
|
||||
### 4.3 Requirements
|
||||
|
||||
- FR-4.3.1: Scenarios shall be defined in YAML (no programming required for standard FAT sequences).
|
||||
- FR-4.3.2: The engine shall support **state machines** (states, entry actions, transitions on input/output edges or conditions, guards).
|
||||
- FR-4.3.3: The engine shall support **macros** (reusable action groups, parameterizable).
|
||||
- FR-4.3.4: Actions on simulated inputs: `set`, `reset`, `pulse`, `toggle`, `ramp` (linear over time), `noise` (± band), `sin`, `hold`.
|
||||
- FR-4.3.5: Assertions on PLC outputs with timeout → **PASS/FAIL/ERROR verdict per check**, with timestamps; final scenario verdict.
|
||||
- FR-4.3.6: Assertions may compare against expected values, ranges, edges, and statistics registers (e.g., "watchdog expired").
|
||||
- FR-4.3.7: All scenario events and all Modbus transactions shall be logged (CSV/JSONL) for the FAT report.
|
||||
- FR-4.3.8: Python **action plugins** shall be supported for actions beyond the built-in set (registered callables), for complex plant-model physics.
|
||||
- FR-4.3.9: The engine shall run **deterministically**: simulation time decoupled from wall-clock; results reproducible.
|
||||
- FR-4.3.10: A scenario may load multiple nodes (multi-coupler FAT benches).
|
||||
- FR-4.3.11: **Module-based channel addressing** — aliases may be addressed
|
||||
as *module type + module number + channel number*, all components
|
||||
explicitly configurable: `{ type: di, module: 2, channel: 1 }` selects
|
||||
digital input channel 1 (0-based) on module slot 2 (0-based position in
|
||||
the node's module list), i.e. `DI2.1`. Both numbers are 0-based to match
|
||||
physical slot/channel labeling. The channel kind must match the module
|
||||
(`DO` on a DI-only module is a validation error). This is an alternative
|
||||
to pool addressing (`{ type: di, channel: 3 }` = 3rd DI channel across
|
||||
the whole node); both schemes are supported and may be mixed, but an
|
||||
alias uses exactly one. **Implemented** in v1.1 (`channel_by_module`,
|
||||
`Alias.module`, `alias_location`).
|
||||
|
||||
### 4.4 Example FAT flow
|
||||
|
||||
1. PLC program boots, connects to simulator (Modbus TCP client), reads input image.
|
||||
2. Scenario `pump_station_fat` starts in `idle` state; `em_stop_ok` turns ON.
|
||||
3. Scenario pulses `start_button`; PLC sees input, starts pump relay (writes coil).
|
||||
4. Engine detects rising edge on `pump_relay`, asserts `valve_open` within 2 s.
|
||||
5. Scenario ramps `level_sensor` to 8500 counts; PLC adjusts `speed_setpoint`; assertions verify.
|
||||
6. Scenario injects fault (KBUS error / watchdog timeout); PLC must react (e.g., switch to safe state) — asserted.
|
||||
7. Report generated: verdicts, timing, Modbus trace.
|
||||
|
||||
---
|
||||
|
||||
## 5. Architecture
|
||||
|
||||
### 5.1 Package layout
|
||||
|
||||
```
|
||||
wago-modbustcp-simulator-py/
|
||||
├── docs/ # WAGO manuals + this spec
|
||||
│ └── spec/
|
||||
│ ├── software-spec.md # this document
|
||||
│ └── register-map.md # extracted WAGO tables
|
||||
├── src/
|
||||
│ └── wago_sim/
|
||||
│ ├── __init__.py
|
||||
│ ├── mbap.py # MBAP frame codec
|
||||
│ ├── pdu.py # PDU codec (request/response/exception)
|
||||
│ ├── server.py # asyncio Modbus TCP server
|
||||
│ ├── datastore.py # WAGO word/coil datastores
|
||||
│ │ # (holds process images + register bank)
|
||||
│ ├── registers.py # config/diagnostic/constant/firmware regs
|
||||
│ ├── modules.py # virtual WAGO I/O modules (module catalog)
|
||||
│ ├── node.py # node = modules → process image mapping
|
||||
│ ├── watchdog.py # Modbus watchdog simulation
|
||||
│ ├── clock.py # deterministic clock + scheduler
|
||||
│ ├── scenario/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── engine.py # interpreter for scenario DSL
|
||||
│ │ ├── dsl.py # YAML schema + validation
|
||||
│ │ │ ├── actions.py # set/pulse/ramp/... + plugin registry
|
||||
│ │ │ ├── assertions.py # condition matcher
|
||||
│ │ │ ├── statemachine.py
|
||||
│ │ └── report.py # verdicts, CSV/JSONL log
|
||||
│ ├── stats.py # Modbus TCP statistics counters
|
||||
│ ├── logging_setup.py
|
||||
│ └── cli.py # entry point (argparse)
|
||||
├── config/
|
||||
│ └── pump_node.yaml # example node configuration
|
||||
├── scenarios/
|
||||
│ └── pump_station_fat.yaml # example scenario
|
||||
├── tests/
|
||||
│ ├── test_mbap.py
|
||||
│ ├── test_pdu.py
|
||||
│ ├── test_server.py
|
||||
│ ├── test_datastore.py
|
||||
│ ├── test_registers.py
|
||||
│ � ├── test_watchdog.py
|
||||
│ ├── test_scenario_engine.py
|
||||
│ └── conftest.py
|
||||
├── pyproject.toml
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### 5.2 Design decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|---|---|
|
||||
| Pure-Python, asyncio TCP server | No external Modbus library needed; full control over WAGO-specific behavior (register-map exceptions, statistics, watchdog); testable |
|
||||
| Deterministic clock | Reproducible FAT runs; scenario times independent of host speed |
|
||||
| YAML DSL + plugin boundary | Test engineers write scenarios, not code; complex physics via Python plugins |
|
||||
| Datastore split: process image vs. register bank | Mirrors real device: process image vs. internal registers; the readback-mirror rule (+0x0200) is dataflow, not copy-on-read |
|
||||
| Statistics as first-class objects | FAT assertions can check communication health (`0x1029`) |
|
||||
|
||||
### 5.3 Threading & concurrency model
|
||||
|
||||
- asyncio event loop owns the Modbus server and the scheduler tick.
|
||||
- Scenario engine runs in the same loop (coroutines), so image updates and Modbus reads see consistent data without locks.
|
||||
- Response delay (`0x1037`) implemented as asyncio sleep before response send.
|
||||
|
||||
### 5.4 Data model
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
NodeConfig ||--|{ ModuleConfig : modules
|
||||
NodeConfig ||--|| ProcessImage : builds
|
||||
ModuleConfig ||--|{ Channel : channels
|
||||
ProcessImage ||--|| InputImage : inputs
|
||||
ProcessImage ||--|| OutputImage : outputs
|
||||
OutputImage ||--|| InputMirror : "read-back at +0x200/+0x1000"
|
||||
Scenario ||--|{ State : states
|
||||
Scenario ||--|{ SequenceStep : sequence
|
||||
State ||--|{ Action : entry
|
||||
State ||--|{ Transition : on
|
||||
SequenceStep ||--|{ Action : actions
|
||||
Scenario ||--|{ Alias : "inputs/outputs aliases"
|
||||
Scenario ||--|{ Macro : macros
|
||||
Macro ||--|{ Action : steps
|
||||
```
|
||||
|
||||
### 5.5 State chart — server session
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Listening
|
||||
Listening --> Connected: TCP accept (≤15)
|
||||
Connected --> RequestParsing: MBAP complete
|
||||
RequestParsing --> Dispatching: PDU decoded
|
||||
Dispatching --> Responding: result
|
||||
Dispatching --> ExceptionResponse: FC/addr/val error
|
||||
Responding --> Connected
|
||||
ExceptionResponse --> Connected
|
||||
RequestParsing --> Connected: malformed (count BadLength)
|
||||
Connected --> Closed: idle timeout / WD 0x1009=1 / client close
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Non-Functional Requirements
|
||||
|
||||
- NFR-6.1 Performance: sustain ≥ 500 requests/s with ≤ 10 ms added latency (typical FAT bench load).
|
||||
- NFR-6.2 Portability: Python 3.11+ on Windows/Linux.
|
||||
- NFR-6.3 Determinism: two runs of a scenario with identical inputs and timing produce identical verdict logs (except wall-clock stamps).
|
||||
- NFR-6.4 Observability: every Modbus transaction logged with timestamps (sim-time + wall-time), PDU hex, connection id.
|
||||
- NFR-6.5 Robustness: malformed frames never crash the server; counted in statistics.
|
||||
- NFR-6.6 Configurability: port, bind address, node config, firmware identity registers, MAC-ID configurable without code changes.
|
||||
|
||||
---
|
||||
|
||||
## 7. Acceptance Criteria (framework phase)
|
||||
|
||||
1. A Modbus TCP master (e.g., `pymodbus` client in tests) can connect, read constants `0x2000–0x2008`, and gets exactly `0x0000, 0xFFFF, 0x1234, 0xAAAA, 0x5555, 0x0166, ...` per spec — byte order verified.
|
||||
2. Reads/writes in all mapped/unmapped areas produce values/exceptions per §3.3 tables.
|
||||
3. Digital channel addressing: a configured 16-channel DI module + 8-channel DO module yields channel-number bit addressing (DI 47 → bit address 46).
|
||||
4. Output readback mirrors at +`0x0200` (word) and +`0x0200`/+`0x1000` (bit/word area 2) work.
|
||||
5. A YAML scenario can set a digital input, detect a PLC output rising edge, and produce a PASS/FAIL report file.
|
||||
6. Watchdog: configure `0x1000`, trigger via `0x1003`, let it expire, outputs clear, `0x1006` = 2, process access → exception `0x04`, restart via `0x1007`.
|
||||
7. Statistics counters increment correctly on bad frames/addresses; reset via `0x1029` write `0xAA55`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Feature Request — Long-Duration (Continuous) Simulation
|
||||
|
||||
> **Status: requested 2026-10-03, planned (roadmap phase 3), not yet implemented.**
|
||||
> Extends the scenario engine (§4) with a `continuous` mode for endurance
|
||||
> (“soak”) testing: the facility runs for hours, days or weeks of simulated
|
||||
> time against evolving inputs and an aging plant.
|
||||
|
||||
### 8.1 Motivation
|
||||
|
||||
FAT rounds (§4) validate functional behavior over minutes. A facility must
|
||||
additionally be proven **over long time**:
|
||||
|
||||
- **Endurance/soak**: memory leaks, counter rollovers, watchdog stability over days.
|
||||
- **Trend & historian verification**: SCADA trends, averaging, alarm shelving.
|
||||
- **Cycle behavior**: daily/weekly demand profiles, shift patterns, weekend idle.
|
||||
- **Maintenance logic**: wear-based alarms, degradation detection, service recovery.
|
||||
- **Clock edge cases**: midnight rollovers and (via absolute-timestamp datasets) DST transitions.
|
||||
|
||||
Long runs cannot be hand-scripted step by step. Two mechanisms replace the
|
||||
scripted `sequence`:
|
||||
|
||||
1. **Dataset playback** — designated inputs are driven from time-series files
|
||||
(e.g., CSV exported from a plant historian, or synthesized profiles).
|
||||
2. **Degradation models** — plant-model parameters (e.g., pump capacity)
|
||||
degrade as a function of runtime and cycles, exercising the PLC’s
|
||||
compensation and maintenance logic.
|
||||
|
||||
### 8.2 Requirements — continuous mode & CSV dataset playback
|
||||
|
||||
- FR-8.2.1: A scenario may declare `mode: continuous`. The `sequence` becomes
|
||||
optional; the run continues until the dataset schedule (plus any `until:`
|
||||
duration) is exhausted.
|
||||
- FR-8.2.2: Input aliases (di/ai) may be driven from **datasets**. Output
|
||||
aliases must not appear in datasets (validation error). An endogenous process
|
||||
variable (e.g., tank level) may instead be computed by a plant-model plugin
|
||||
(FR-4.3.8, closed loop). **Exactly one driver** may own an alias (dataset,
|
||||
plugin-declared variable, ramp, pulse, or step) — conflicts are validation errors.
|
||||
- FR-8.2.3: CSV format: a header row with the time column (default `time`,
|
||||
configurable via `time_column`) plus one column per alias. Time is elapsed
|
||||
seconds (default) or ISO-8601 absolute timestamps (`time_format: iso8601`).
|
||||
Analog values are numeric; digital values accept `ON/OFF`, `1/0`,
|
||||
`true/false`. Lines starting with `#` are comments.
|
||||
- FR-8.2.4: Values pass through the alias `scale`/`offset` (engineering
|
||||
units → raw counts), identical to `set:` semantics.
|
||||
- FR-8.2.5: Between samples, analog aliases use per-dataset
|
||||
`interpolation: linear | hold`; digital aliases always use `hold` (never
|
||||
interpolated).
|
||||
- FR-8.2.6: Datasets shall be **streamed with bounded memory** (one-row
|
||||
lookahead); files covering months of samples must be playable without
|
||||
loading them into RAM.
|
||||
- FR-8.2.7: `repeat: n` loops a dataset seamlessly and monotonically (no time
|
||||
jump at the seam); identical loops produce identical values (deterministic).
|
||||
`until: <duration>` limits total playback.
|
||||
- FR-8.2.8: `time_factor: k` (default 1.0) — dataset time advances k× faster
|
||||
than sim-time, enabling a 28-day soak in a fraction of the wall-clock time.
|
||||
- FR-8.2.9: Multiple datasets share one sim-time axis; each has an optional
|
||||
`offset:` start shift. The engine merges them; an alias’s value is that of
|
||||
its owning dataset.
|
||||
- FR-8.2.10: **Pacing**: in server mode (real PLC connected) sim-time follows
|
||||
wall-clock for real-time soak testing; headless runs are unpaced (as fast
|
||||
as deterministic processing allows).
|
||||
- FR-8.2.11: Malformed data (unknown column, unparsable time or value,
|
||||
non-monotonic timestamps) ends the run with verdict **ERROR**, citing file
|
||||
and line number.
|
||||
|
||||
### 8.3 Requirements — degradation models (plant logic over time)
|
||||
|
||||
- FR-8.3.1: The engine maintains **wear counters** per alias, updated every
|
||||
tick: runtime hours (integral of truth), edge counts (rising/falling), and
|
||||
load-hours (integral of the analog value). Counters are deterministic state.
|
||||
- FR-8.3.2: A **degradation model** computes a named plant parameter (e.g.
|
||||
`pump_capacity`, range 0…1) from wear counters and sim-time. Built-in
|
||||
shapes: `linear` (per runtime hour, per start cycle), `exponential`,
|
||||
`staircase` (threshold table); custom shapes via Python plugin.
|
||||
- FR-8.3.3: Plant-model plugins read degraded parameters via
|
||||
`engine.parameter("pump_capacity")` and shall use them in the loop physics
|
||||
(e.g., actual pump flow = nominal flow × capacity), so the PLC must
|
||||
compensate (e.g., speed creep) — the behavior under test.
|
||||
- FR-8.3.4: `maintenance_at:` threshold crossing fires configured actions:
|
||||
fault injection (module diagnosis, register `0x1050`), assertions
|
||||
(maintenance alarm within a time bound), and logging. A
|
||||
`maintenance_reset` restores the parameter to 1.0 and clears injected
|
||||
diagnoses — verifying the PLC’s alarm clearing and performance recovery.
|
||||
- FR-8.3.5: `floor:`/`ceiling:` clamp the parameter so the simulated plant
|
||||
stays physically plausible.
|
||||
- FR-8.3.6: Models must be pure functions of (sim-time, wear counters,
|
||||
configuration) — no wall-clock — keeping runs reproducible.
|
||||
- FR-8.3.7: Degradation state and wear counters are included in snapshots
|
||||
(§8.4) and the final report.
|
||||
- FR-8.3.8: New DSL actions introduced: `inject_diagnosis` / `clear_diagnosis`
|
||||
(module-diagnosis fault injection) and `maintenance_reset`.
|
||||
|
||||
### 8.4 Requirements — continuous assertions & monitoring
|
||||
|
||||
- FR-8.4.1: `invariants:` — conditions evaluated **every tick** (analog
|
||||
bounds, expected digital states). Violations are recorded as **episodes**
|
||||
(start, end, extreme value, tick count) so a 30-day run yields a bounded
|
||||
report, not a per-tick flood.
|
||||
- FR-8.4.2: `rate_limits:` — maximum rising/falling edges per sliding hour
|
||||
window per alias (e.g., pump starts ≤ 6/h), verifying anti-cycling logic.
|
||||
- FR-8.4.3: `report.snapshot_every: <duration>` — periodic dumps of all
|
||||
alias values, wear counters, and degradation parameters (CSV/JSONL) for
|
||||
trend/historian verification.
|
||||
- FR-8.4.4: Continuous-mode verdict: **PASS** iff the dataset schedule
|
||||
completed, no invariant episode occurred, no rate limit was exceeded, and
|
||||
no assertion failed; otherwise FAIL/ERROR accordingly.
|
||||
- FR-8.4.5: Report and log sizes shall be bounded independent of run length
|
||||
(episode aggregation + ring-buffered recent events).
|
||||
- FR-8.4.6: A limited `sequence:` may still be used in continuous mode;
|
||||
steps may be scheduled via `at: <sim duration>` (e.g., a service visit at
|
||||
day 12). Scheduled steps and datasets interleave on the same sim-time axis.
|
||||
|
||||
### 8.5 Example — 28-day pump-station soak
|
||||
|
||||
Scenario (YAML):
|
||||
|
||||
```yaml
|
||||
name: pump_station_soak_28d
|
||||
description: "28-day soak: demand/operator playback, closed-loop plant with pump degradation"
|
||||
mode: continuous
|
||||
timing: { tick: 100ms }
|
||||
|
||||
inputs:
|
||||
start_button: { type: di, channel: 1 } # exogenous (operator, CSV)
|
||||
em_stop_ok: { type: di, channel: 2 } # exogenous (CSV)
|
||||
inflow_valve: { type: di, channel: 3 } # exogenous (CSV)
|
||||
inflow_rate: { type: ai, channel: 2, scale: 0.01 } # exogenous (CSV), m³/h → counts
|
||||
level_sensor: { type: ai, channel: 1, scale: 0.1 } # endogenous (plant model computes)
|
||||
|
||||
outputs:
|
||||
pump_relay: { type: do, channel: 1 }
|
||||
valve_open: { type: do, channel: 2 }
|
||||
speed_setpoint: { type: ao, channel: 1, scale: 0.1 }
|
||||
maintenance_alarm: { type: do, channel: 3 }
|
||||
|
||||
datasets:
|
||||
demand_week: # exogenous demand profile
|
||||
file: data/demand_week.csv
|
||||
aliases: [inflow_rate]
|
||||
interpolation: linear
|
||||
repeat: 4 # 7-day profile × 4 = 28 sim-days
|
||||
operator_week: # operator actions
|
||||
file: data/operator_week.csv
|
||||
aliases: [start_button, em_stop_ok, inflow_valve]
|
||||
interpolation: hold
|
||||
repeat: 4
|
||||
|
||||
degradation:
|
||||
pump_wear:
|
||||
model: linear
|
||||
parameter: pump_capacity # consumed by the plant model
|
||||
per_runtime_hour: 0.0004 # 0.04 %/h → ~10 % after ~10.4 sim-days
|
||||
per_start_cycle: 0.0002 # 0.02 % per start
|
||||
floor: 0.80
|
||||
maintenance_at: 0.90
|
||||
on_threshold: # fires when capacity crosses 90 %
|
||||
- inject_diagnosis: { module: 4, channel: 1, bits: [user_overrange] }
|
||||
- assert: { label: "maintenance alarm raised",
|
||||
conditions: { maintenance_alarm: ON }, within: 60s }
|
||||
on_reset: # after maintenance_reset
|
||||
- clear_diagnosis: { module: 4 }
|
||||
|
||||
sequence: # scheduled events (sim-time)
|
||||
- at: 12d
|
||||
maintenance_reset: pump_wear # service visit: capacity restored
|
||||
|
||||
plugins: [plugins.pump_plant.pump_plant] # loop: d(level)/dt = inflow − pump_flow(speed, capacity)
|
||||
|
||||
invariants:
|
||||
- { alias: level_sensor, min: 200, max: 9000, label: "tank level bounds" }
|
||||
|
||||
rate_limits:
|
||||
- { alias: pump_relay, edge: rising, max_per_hour: 6, label: "pump start cycles" }
|
||||
|
||||
report:
|
||||
snapshot_every: 1h
|
||||
aggregate: episodes
|
||||
```
|
||||
|
||||
Datasets (CSV):
|
||||
|
||||
```csv
|
||||
# data/demand_week.csv (elapsed seconds, inflow in m³/h)
|
||||
time,inflow_rate
|
||||
0,12.0
|
||||
3600,12.4
|
||||
7200,13.1
|
||||
...
|
||||
604800,12.0 # week boundary — loops seamlessly with repeat: 4
|
||||
```
|
||||
|
||||
```csv
|
||||
# data/operator_week.csv
|
||||
time,start_button,em_stop_ok,inflow_valve
|
||||
0,OFF,ON,OFF
|
||||
28800,ON,ON,ON # Mon 08:00: shift start, valve opened
|
||||
28810,OFF,ON,ON
|
||||
432000,OFF,OFF,ON # Sat 00:00: weekend e-stop drill
|
||||
440000,OFF,ON,ON
|
||||
```
|
||||
|
||||
### 8.6 Architecture impact (planned modules)
|
||||
|
||||
```
|
||||
src/wago_sim/scenario/
|
||||
├── datasets.py # CsvDataset: streaming reader, interpolation, repeat, time_factor
|
||||
├── degradation.py # WearCounters, DegradationModel registry, maintenance actions
|
||||
├── invariants.py # invariant & rate-limit evaluation, episode aggregation
|
||||
└── engine.py # mode: continuous, parameter() accessor, snapshot scheduler
|
||||
```
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
ContinuousScenario ||--|{ Dataset : datasets
|
||||
Dataset ||--|{ ColumnMapping : "alias columns"
|
||||
ContinuousScenario ||--|{ DegradationModel : degradation
|
||||
DegradationModel ||--|{ WearCounter : counts
|
||||
WearCounter }o--|| Alias : observes
|
||||
ContinuousScenario ||--|{ Invariant : invariants
|
||||
Invariant ||--o{ Episode : violations
|
||||
ContinuousScenario ||--|{ RateLimit : rate_limits
|
||||
ContinuousScenario ||--o{ Snapshot : "periodic dumps"
|
||||
PlantPlugin }o--|| DegradationModel : "reads parameters"
|
||||
```
|
||||
|
||||
### 8.7 Non-functional requirements (long-duration)
|
||||
|
||||
- NFR-8.7.1: Memory O(1) in dataset length and run duration (streaming +
|
||||
bounded caches).
|
||||
- NFR-8.7.2: A 28-sim-day soak with two datasets completes headless in
|
||||
≤ 1 hour wall-clock on a standard PC (budget to be confirmed after
|
||||
implementation).
|
||||
- NFR-8.7.3: Determinism incl. degradation: identical config + datasets
|
||||
produce identical reports (except wall-clock stamps).
|
||||
- NFR-8.7.4: Robustness: host sleep/hibernate or GC pauses never distort
|
||||
sim-time or dataset position (sim-time accumulates by tick, never from
|
||||
wall-clock deltas — except when explicitly paced per FR-8.2.10).
|
||||
- NFR-8.7.5: Report and log artifacts stay bounded (FR-8.4.5 mechanism),
|
||||
enabling unattended multi-day runs.
|
||||
|
||||
### 8.8 Acceptance criteria (long-duration simulation)
|
||||
|
||||
1. A ≥ 100,000-row CSV streams with constant memory; alias values at
|
||||
arbitrary ticks match the expected interpolation exactly.
|
||||
2. `linear` interpolation for analog and `hold` for digital verified against
|
||||
hand-computed samples; digital aliases are never interpolated.
|
||||
3. `repeat: 4` of a 7-day dataset produces four identical per-loop value
|
||||
sequences (seamless seam, deterministic).
|
||||
4. `time_factor: 10` consumes a 7-day dataset in 16.8 h of sim-time.
|
||||
5. An alias-ownership conflict (dataset + ramp on one alias) is rejected at
|
||||
validation time.
|
||||
6. Degradation: `pump_capacity` declines per the configured formula; crossing
|
||||
`maintenance_at` injects the configured diagnosis and the maintenance-alarm
|
||||
assertion passes; `maintenance_reset` restores capacity to 1.0 and clears
|
||||
the diagnosis.
|
||||
7. An invariant breach is recorded as one episode (start, end, peak)
|
||||
regardless of duration; a rate-limit breach fails the run.
|
||||
8. A full 28-sim-day soak completes headless with a bounded report and a
|
||||
correct PASS/FAIL/ERROR verdict.
|
||||
|
||||
---
|
||||
|
||||
## 9. Feature Request — Bench Mode (Run Without a Scenario)
|
||||
|
||||
> **Status: requested 2026-10-03, planned (roadmap phase 2), partially
|
||||
> available today.** `wago-sim serve --node <config>` already runs the
|
||||
> Modbus TCP server with a static process image and no scenario engine;
|
||||
> this section formalizes the mode and its supporting tooling.
|
||||
|
||||
### 9.1 Motivation
|
||||
|
||||
Early commissioning and interactive troubleshooting do not follow a
|
||||
script: an engineer wants the virtual node up, connected to the PLC/SCADA,
|
||||
and then *manually* observe and tweak values — the way one would probe a
|
||||
real node with a screwdriver and a multimeter. Requiring a scenario file
|
||||
for this is friction; a syntax error in an unrelated YAML file should not
|
||||
block a bench session.
|
||||
|
||||
Bench mode is also the foundation for manual FAT exploration and for the
|
||||
web UI (§10), which needs a long-lived node whose I/O can be manipulated
|
||||
at runtime without a scenario driving it.
|
||||
|
||||
### 9.2 Requirements
|
||||
|
||||
- FR-9.2.1: `wago-sim serve --node <config>` shall start the Modbus TCP
|
||||
server with **no scenario** attached: the process image starts at
|
||||
defined defaults (all-zero), the watchdog/statistics behave normally,
|
||||
and the server runs until terminated. *(Available today.)*
|
||||
- FR-9.2.2: `--set alias=value` CLI options (repeatable) shall seed input
|
||||
values at startup, e.g. `--set DI0.1=ON --set AI4.0=1234`, addressing
|
||||
channels by the module notation of FR-4.3.11. Optional in v1: a small
|
||||
startup-commands file for larger value sets.
|
||||
- FR-9.2.3: An interactive **console monitor** (`--monitor`): periodically
|
||||
prints a compact table of all I/O channels (module, type, address,
|
||||
value) and highlights changes, so a bench session is observable in a
|
||||
terminal without any UI.
|
||||
- FR-9.2.4: Runtime manipulation via the console monitor: simple commands
|
||||
(`set DI0.1 ON`, `pulse DO2.0 500ms`, `watch 0x1029`, `reset stats`) —
|
||||
the same operations the web UI (§10) offers, over stdio.
|
||||
- FR-9.2.5: `serve` shall be combinable with a scenario later (`--scenario`
|
||||
becomes optional); running with neither scenario nor monitor prints a
|
||||
hint that the node is passive and values can only be changed by a master
|
||||
or via the web UI.
|
||||
- FR-9.2.6: The console monitor and web UI (§10) must never violate device
|
||||
semantics — manipulations go through the same datastore paths as Modbus
|
||||
writes, so read-back mirrors, watchdog kicks, and statistics stay
|
||||
consistent.
|
||||
|
||||
### 9.3 Acceptance criteria
|
||||
|
||||
1. `wago-sim serve --node config/pump_node.yaml` starts with no scenario
|
||||
file present and serves a static image; a pymodbus client can read and
|
||||
write process data.
|
||||
2. `--set DI0.1=ON` seeds the corresponding input bit; a master reading
|
||||
bit address 0 sees ON.
|
||||
3. The console monitor shows the full channel table and flags value
|
||||
changes within one refresh interval.
|
||||
4. Console `set`/`pulse`/`watch` commands affect the image immediately and
|
||||
are visible to a connected master in the same way Modbus writes are.
|
||||
|
||||
---
|
||||
|
||||
## 10. Feature Request — Web UI (I/O Overview & Manipulation)
|
||||
|
||||
> **Status: requested 2026-10-03, planned (roadmap phase 5), not yet
|
||||
> implemented.** A browser front-end overlooking and manipulating the
|
||||
> simulated node's I/O values and states at runtime.
|
||||
|
||||
### 10.1 Motivation
|
||||
|
||||
Terminal monitors (§9) suffice for single engineers, but FAT benches are
|
||||
collaborative: commissioning engineers, PLC programmers, and test
|
||||
witnesses need a **shared, visual** view of the virtual I/O — the
|
||||
equivalent of watching the LEDs on a real WAGO node, plus the ability to
|
||||
force signals the way WAGO's own WBM does on real devices. A web UI keeps
|
||||
the simulator usable from any machine on the bench network without
|
||||
installing tools.
|
||||
|
||||
### 10.2 Requirements
|
||||
|
||||
- FR-10.2.1: The simulator shall serve a web UI (default port 8080,
|
||||
configurable) alongside the Modbus TCP server, with no external backend
|
||||
dependencies beyond Python (FastAPI/Starlette + static files, or an
|
||||
equivalent embedded approach — implementation choice, to be decided).
|
||||
- FR-10.2.2: The UI shall present the **node overview**: module list with
|
||||
article numbers, per-module channel tables (type, module, channel,
|
||||
addressing `DI0.1` per FR-4.3.11, Modbus address, current value/state),
|
||||
derived from the same process image the Modbus server serves.
|
||||
- FR-10.2.3: Values shall update live (WebSocket push or short-polling),
|
||||
including changes made by the PLC/master over Modbus, by scenarios, by
|
||||
the console monitor, or by other UI clients — one shared source of truth.
|
||||
- FR-10.2.4: **Manipulation**: clicking a DI/DO toggles it; an AI/AO gets a
|
||||
numeric input (engineering units, using alias scale/offset when
|
||||
defined); `pulse` (with duration) and `ramp` (target + duration) actions
|
||||
on DI/DO/AI. All writes go through the datastore's Modbus-equivalent
|
||||
paths (FR-9.2.6) so mirrors, watchdog, and statistics stay consistent.
|
||||
- FR-10.2.5: **Diagnostics view**: watchdog status (0x1006), statistics
|
||||
block (0x1029, with reset button), module diagnosis (0x1050), LED error
|
||||
registers (0x1020/0x1021) — mirroring WBM pages of the real device —
|
||||
with controls to inject/clear faults for FAT fault cases.
|
||||
- FR-10.2.6: A scenario/run view: which scenario (if any) is loaded,
|
||||
current step, verdicts so far, and basic controls (start/abort). Headless
|
||||
result reports (JSONL/CSV) remain downloadable via the CLI.
|
||||
- FR-10.2.7: Multi-client consistency: several simultaneous browser
|
||||
clients see the same state; forced values propagate to all clients and
|
||||
to the Modbus side within one refresh interval.
|
||||
- FR-10.2.8: Access control for shared benches: optional read-only mode
|
||||
and (v2) token-based login, so witnesses can observe without accident
|
||||
rights.
|
||||
- FR-10.2.9: The UI must not disturb deterministic scenario runs: when a
|
||||
scenario owns an alias, UI writes to it are rejected with a clear
|
||||
message (alias-ownership rules of §8 FR-8.2.2 apply).
|
||||
|
||||
### 10.3 Architecture sketch (planned)
|
||||
|
||||
```
|
||||
src/wago_sim/
|
||||
├── webui/
|
||||
│ ├── __init__.py
|
||||
│ ├── app.py # HTTP + WebSocket server (asyncio, same loop as Modbus)
|
||||
│ ├── api.py # REST endpoints: node layout, values, writes, diagnostics
|
||||
│ └── static/ # single-page front-end (no build step)
|
||||
```
|
||||
|
||||
The web server runs **in the same asyncio event loop** as the Modbus TCP
|
||||
server, so a UI write and a Modbus read never race on the datastore.
|
||||
|
||||
### 10.4 Acceptance criteria
|
||||
|
||||
1. Opening the UI shows the node's module list and channel table exactly
|
||||
as configured, with `DI0.1`-style addresses and Modbus addresses.
|
||||
2. Toggling a DO in the browser is immediately visible to a connected
|
||||
Modbus master (and vice versa: a master write appears in the UI within
|
||||
one refresh interval).
|
||||
3. Forcing an AI value uses engineering units and the configured scale.
|
||||
4. The diagnostics page shows live watchdog status and statistics; the
|
||||
reset button clears the counters (register 0x1029 semantics).
|
||||
5. With a running scenario, writes to scenario-owned aliases are rejected
|
||||
with a visible message; the scenario continues unaffected.
|
||||
6. Two simultaneous browsers stay consistent with each other and with
|
||||
the Modbus image.
|
||||
|
||||
---
|
||||
|
||||
## 11. Roadmap (post-framework)
|
||||
|
||||
| Phase | Feature |
|
||||
|---|---|
|
||||
| 2 | Module catalog expansion (analog, counter, SSI, DALI/DSI, serial modules), mailbox parameters; **bench mode tooling (§9)**: `--set` seeding, console monitor, runtime manipulation commands |
|
||||
| 3 | **Long-duration simulation (§8)**: CSV dataset playback, degradation models, invariants, soak-test reporting |
|
||||
| 4 | FAT report generator (HTML/PDF), scenario comparison, template library per project type |
|
||||
| 5 | Multi-node orchestration (several couplers in one FAT bench), UDP variant, WBM simulation; **web UI (§10)** |
|
||||
| 6 | CI integration: run scenarios headless, export JUnit XML |
|
||||
|
||||
---
|
||||
|
||||
## 12. References
|
||||
|
||||
- WAGO 750-362 manual `m07500362_xxxxxxxx_0en.pdf`, sections 7.2–7.3 (process image), 11.2 (Modbus), 12.2 (module data architecture)
|
||||
- WAGO I/O System 750 system description `m075xxxxx_xxxxxxxx_0en.pdf`
|
||||
- MODBUS Application Protocol Specification V1.1b3 / MODBUS TCP specification (modbus.org)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)."""
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
"""Scenario package: YAML DSL model, engine, actions, assertions, reporting."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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"})
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user