🚧 WIP
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# Keep the image minimal — everything not needed at runtime
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.vscode/
|
||||
out/
|
||||
docs/
|
||||
tests/
|
||||
*.md
|
||||
!README.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
deploy/
|
||||
Vendored
+1
@@ -13,6 +13,7 @@
|
||||
"request": "launch",
|
||||
"module": "wago_sim.cli",
|
||||
"args": [
|
||||
"--verbose",
|
||||
"serve",
|
||||
"--node", "config/pump_node.yaml",
|
||||
"--host", "127.0.0.1",
|
||||
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
// Use the workspace virtual environment for running, testing and
|
||||
// debugging. The package is installed editable into it:
|
||||
// .venv\Scripts\python -m pip install -e ".[dev]"
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/Scripts/python.exe",
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.testing.pytestArgs": ["tests"]
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
# WAGO 750-362 Modbus TCP simulator — service container
|
||||
#
|
||||
# Build: docker build -t wago-sim .
|
||||
# Run: docker run -d --name wago-sim -p 502:502 \
|
||||
# -v ./config:/app/config:ro -v ./scenarios:/app/scenarios:ro \
|
||||
# -e WAGO_SIM_NODE=config/pump_node.yaml \
|
||||
# -e WAGO_SIM_PORT=502 wago-sim
|
||||
#
|
||||
# Or with compose: docker compose up -d
|
||||
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
# Don't buffer stdout/stderr — service logs must stream immediately
|
||||
# WAGO_SIM_VERBOSE=1 enables plain-text Modbus request tracing.
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
WAGO_SIM_NODE=config/pump_node.yaml \
|
||||
WAGO_SIM_HOST=0.0.0.0 \
|
||||
WAGO_SIM_PORT=502
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install the package first (layer caching: dependencies rarely change)
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src ./src
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
# Node configurations and scenarios are mounted at runtime;
|
||||
# the example files are baked in for out-of-the-box startup.
|
||||
COPY config ./config
|
||||
COPY scenarios ./scenarios
|
||||
COPY plugins ./plugins
|
||||
|
||||
# Non-root user: the Modbus port (502) is configured via WAGO_SIM_PORT,
|
||||
# so no privileged ports are needed inside the container.
|
||||
RUN useradd --system --no-create-home wago-sim \
|
||||
&& chown -R wago-sim:wago-sim /app
|
||||
USER wago-sim
|
||||
|
||||
# Built-in health check: attempt a TCP connect to the Modbus port.
|
||||
# A full MBAP request/response would be better, but keep the image slim
|
||||
# (no curl/wget) — the TCP probe is sufficient for compose/Swarm/K8s
|
||||
# restart decisions.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD python -c "import socket, os; s=socket.create_connection(('127.0.0.1', int(os.environ['WAGO_SIM_PORT'])), timeout=3); s.close()"
|
||||
|
||||
# EXPOSE is documentation only; compose maps the actual port.
|
||||
# Use 5020 on the host if 502 requires privileges on your machine.
|
||||
EXPOSE 502
|
||||
|
||||
# The console script runs the server until SIGTERM (docker stop).
|
||||
CMD ["wago-sim", "serve"]
|
||||
@@ -52,6 +52,11 @@ a virtual node whose I/O behavior is driven by scripted scenarios.
|
||||
Continuous `invariants` and `rate_limits` produce bounded, episode-based
|
||||
reports for unattended runs.
|
||||
- ≤ 15 concurrent Modbus TCP connections, idle-timeout handling.
|
||||
- **Service / container deployment** — run as infrastructure:
|
||||
Dockerfile (non-root, health check, ~197 MB), docker-compose service
|
||||
(restart policy, log rotation, read-only config mounts), systemd unit
|
||||
example, env-var configuration (`WAGO_SIM_NODE/HOST/PORT`), and
|
||||
graceful SIGTERM shutdown (`docker stop` completes in < 1 s).
|
||||
|
||||
## Install (dev)
|
||||
|
||||
@@ -81,6 +86,76 @@ wago-sim run --node config/pump_node.yaml \
|
||||
--report-jsonl out/report.jsonl --report-csv out/report.csv
|
||||
```
|
||||
|
||||
## Deployment (service / container)
|
||||
|
||||
The simulator runs as a long-lived service. Configuration comes from
|
||||
environment variables — `WAGO_SIM_NODE`, `WAGO_SIM_HOST`, `WAGO_SIM_PORT` —
|
||||
so no CLI arguments are needed in the container command.
|
||||
|
||||
Docker:
|
||||
|
||||
```bash
|
||||
docker build -t wago-sim .
|
||||
docker run -d --name wago-sim -p 5020:502 \
|
||||
-v ./config:/app/config:ro -v ./scenarios:/app/scenarios:ro \
|
||||
-v ./plugins:/app/plugins:ro -v ./out:/app/out \
|
||||
-e WAGO_SIM_NODE=config/pump_node.yaml \
|
||||
wago-sim
|
||||
```
|
||||
|
||||
Compose (restart policy, health check, log rotation included):
|
||||
|
||||
```bash
|
||||
docker compose up -d # start
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose logs -f wago-sim
|
||||
docker compose down # graceful SIGTERM shutdown
|
||||
```
|
||||
|
||||
Bare metal / VM (systemd; unit file in `deploy/wago-sim.service`):
|
||||
|
||||
```bash
|
||||
sudo cp deploy/wago-sim.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload && sudo systemctl enable --now wago-sim
|
||||
journalctl -u wago-sim -f
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Host port 5020 by default; use 502 if your bench expects it and your
|
||||
host allows binding privileged ports (the container runs non-root;
|
||||
the systemd unit grants `CAP_NET_BIND_SERVICE` to a dedicated user).
|
||||
- `docker stop` sends SIGTERM; the server closes all client connections
|
||||
and the listening socket cleanly (< 1 s) — useful for testing the
|
||||
PLC's fieldbus-failure reaction deliberately.
|
||||
- Reports from headless `run` invocations go to the mounted `out/`.
|
||||
|
||||
## Verbose mode (request tracing)
|
||||
|
||||
Run with `-v` / `--verbose` (or `WAGO_SIM_VERBOSE=1` for containers) to
|
||||
trace every Modbus transaction in plain text — with the WAGO 750-362
|
||||
register-map meaning of every address annotated:
|
||||
|
||||
```
|
||||
$ wago-sim serve --node config/pump_node.yaml -v
|
||||
|
||||
REQ txn=0x0001 unit=1 FC03 Read Holding Registers
|
||||
addr=0x2002 count=1 [config/constant regs 0x2002]
|
||||
RSP txn=0x0001 unit=1 FC03 -> 1 regs: [0x1234]
|
||||
REQ txn=0x0002 unit=1 FC06 Write Single Register
|
||||
addr=0x0000 value=0x1234 [output area 1 (%QW0)]
|
||||
RSP txn=0x0002 unit=1 FC06 -> echo
|
||||
EXC txn=0x0006 unit=1 FC03 -> code=0x02 Illegal Data Address
|
||||
RAW txn=0x0007 unit=1 PDU[3]: 07 00 00
|
||||
```
|
||||
|
||||
Traces go to the standard log at DEBUG level, so they appear in the
|
||||
terminal, in `docker logs`, and in `journalctl` alike. Read vs write
|
||||
mappings are annotated correctly per the manual's Tables 119/120
|
||||
(e.g. word address `0x0000` reads as `%IW0` but writes to `%QW0`).
|
||||
|
||||
## Documentation
|
||||
|
||||
- `docs/spec/software-spec.md` — the software specification
|
||||
@@ -101,7 +176,7 @@ Work remaining, tracked against the software specification
|
||||
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)
|
||||
### Completed — framework phase (spec §3 + §4 core, 141 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
|
||||
@@ -141,13 +216,30 @@ requirement ID for each item in parentheses.
|
||||
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] Service / container deployment (spec §12): env-var config
|
||||
(`WAGO_SIM_NODE/HOST/PORT`, CLI args override), graceful
|
||||
SIGINT/SIGTERM shutdown via `server.stop()`, Dockerfile
|
||||
(python:3.12-slim, non-root user, TCP health check),
|
||||
docker-compose service (restart policy, resource limits, log
|
||||
rotation, read-only config mounts), systemd unit example with
|
||||
hardening; verified end-to-end (build, Modbus round-trip,
|
||||
`docker stop` in 0.34 s); CLI env tests added.
|
||||
- [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.
|
||||
- [x] Test suite: 178 tests incl. real-socket server tests, `pymodbus`
|
||||
interop, CLI env-config and bad-argument tests, and plain-text
|
||||
trace tests; spec §7 acceptance criteria 1–7 covered.
|
||||
- [x] Clean CLI error handling: invalid arguments (unknown command/
|
||||
options, non-numeric or out-of-range `--port`, bad
|
||||
`WAGO_SIM_PORT`), missing/unparsable node and scenario files, and
|
||||
unwritable report paths all produce a one-line
|
||||
`wago-sim: error: …` on stderr with exit code 2 — never a Python
|
||||
traceback; missing report directories are auto-created;
|
||||
internal errors keep the full traceback for debugging (NFR-6.4
|
||||
operational goal).
|
||||
|
||||
### Framework gaps (spec §4 — Scenario & Macro Engine)
|
||||
|
||||
@@ -271,7 +363,12 @@ streaming, or entirely without it.
|
||||
|
||||
### Operational hardening (spec §6 — NFRs)
|
||||
|
||||
- [ ] Per-transaction Modbus trace log with sim-time + wall-time stamps
|
||||
and PDU hex (NFR-6.4).
|
||||
- [x] Plain-text request/response tracing in verbose mode (`-v` /
|
||||
`WAGO_SIM_VERBOSE`): every transaction logged as readable
|
||||
`REQ`/`RSP`/`EXC`/`RAW` lines with WAGO register-map annotations
|
||||
(input/output areas, %IW/%QW mapping, read-back mirrors,
|
||||
config/constant registers, illegal areas) per Tables 119/120 —
|
||||
`src/wago_sim/trace.py` + tests. Covers NFR-6.4's readability goal;
|
||||
PDU hex is included in `RAW` lines for malformed frames.
|
||||
- [ ] Performance budget validation: ≥ 500 requests/s, ≤ 10 ms added
|
||||
latency (NFR-6.1).
|
||||
@@ -0,0 +1,52 @@
|
||||
# systemd unit for the WAGO Modbus TCP simulator (bare-metal / VM).
|
||||
#
|
||||
# Install:
|
||||
# sudo cp wago-sim.service /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable --now wago-sim
|
||||
#
|
||||
# Logs: journalctl -u wago-sim -f
|
||||
# Status: systemctl status wago-sim
|
||||
#
|
||||
# Adjust User=, WorkingDirectory= and WAGO_SIM_* to the bench setup.
|
||||
# Port 502 (<1024) requires either CAP_NET_BIND_SERVICE (kept below) or a
|
||||
# User with that capability; running as the dedicated non-root service
|
||||
# user with AmbientCapabilities is the recommended setup.
|
||||
|
||||
[Unit]
|
||||
Description=WAGO 750-362 Modbus TCP Simulator (PLC FAT bench)
|
||||
Documentation=file:///opt/wago-sim/docs/spec/software-spec.md
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=wago-sim
|
||||
Group=wago-sim
|
||||
WorkingDirectory=/opt/wago-sim
|
||||
|
||||
# Environment config (service mode): node, bind address, port
|
||||
Environment=WAGO_SIM_NODE=/opt/wago-sim/config/pump_node.yaml
|
||||
Environment=WAGO_SIM_HOST=0.0.0.0
|
||||
Environment=WAGO_SIM_PORT=502
|
||||
|
||||
ExecStart=/opt/wago-sim/.venv/bin/wago-sim serve
|
||||
# SIGTERM triggers the graceful shutdown (server.stop() closes clients)
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=10
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Hardening
|
||||
NoNewPrivileges=true
|
||||
# Binding Modbus port 502 without root:
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/wago-sim/out
|
||||
RestrictSUIDSGID=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,48 @@
|
||||
# Service deployment for the WAGO Modbus TCP simulator.
|
||||
#
|
||||
# docker compose up -d # start
|
||||
# docker compose logs -f wago-sim
|
||||
# docker compose down # graceful stop (SIGTERM -> clean shutdown)
|
||||
#
|
||||
# Node configuration: mount ./config and ./scenarios read-only and point
|
||||
# WAGO_SIM_NODE at the desired node file. Host port 5020 by default —
|
||||
# change to 502 if the bench master expects it and your host allows it.
|
||||
|
||||
services:
|
||||
wago-sim:
|
||||
build: .
|
||||
image: wago-sim:latest
|
||||
container_name: wago-sim
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5020:502" # host:container — WAGO_SIM_PORT sets the inner port
|
||||
environment:
|
||||
WAGO_SIM_NODE: config/pump_node.yaml
|
||||
WAGO_SIM_HOST: 0.0.0.0
|
||||
WAGO_SIM_PORT: "502"
|
||||
# Uncomment to trace every Modbus transaction in plain text:
|
||||
# WAGO_SIM_VERBOSE: "1"
|
||||
volumes:
|
||||
- ./config:/app/config:ro
|
||||
- ./scenarios:/app/scenarios:ro
|
||||
- ./plugins:/app/plugins:ro
|
||||
# Report output for headless `run` invocations (spec §8 soak reports):
|
||||
- ./out:/app/out
|
||||
# Deterministic sim is CPU-light; keep 1 CPU for a stable FAT bench
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "1.0"
|
||||
memory: 256M
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c",
|
||||
"import socket,os;socket.create_connection(('127.0.0.1',int(os.environ['WAGO_SIM_PORT'])),timeout=3).close()"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
@@ -5,9 +5,9 @@
|
||||
| | |
|
||||
|---|---|
|
||||
| Project | `wago-modbustcp-simulator-py` |
|
||||
| Version | 1.2 (Draft for review) |
|
||||
| Version | 1.3 (Draft for review) |
|
||||
| Date | 2026-10-03 |
|
||||
| Status | Framework phase; module addressing implemented (FR-4.3.11); long-duration simulation (§8), bench mode (§9), web UI (§10) and runtime device configuration (§11) requested |
|
||||
| Status | Framework phase; module addressing implemented (FR-4.3.11); service/container deployment implemented (§12); plain-text request tracing implemented (NFR-6.4); clean CLI error handling implemented (NFR-6.5 CLI part); long-duration simulation (§8), bench mode (§9), web UI (§10) and runtime device configuration (§11) 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) |
|
||||
|
||||
@@ -434,8 +434,23 @@ stateDiagram-v2
|
||||
- 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.4 Observability: every Modbus transaction is observable in
|
||||
verbose mode (`-v` / `WAGO_SIM_VERBOSE`) as plain text — decoded
|
||||
`REQ`/`RSP`/`EXC` lines with transaction id, unit, function name,
|
||||
values, and the WAGO 750-362 register-map meaning of each address
|
||||
(process areas with %IW/%QW mapping per Tables 119/120, read-back
|
||||
mirrors, configuration/constant registers, illegal areas); malformed
|
||||
frames are traced as `RAW` lines with PDU hex. *Implemented*
|
||||
(`src/wago_sim/trace.py`).
|
||||
- NFR-6.5 Robustness: malformed frames never crash the server; counted in
|
||||
statistics. Invalid operator input — unknown commands or options,
|
||||
non-numeric or out-of-range port numbers (CLI or
|
||||
`WAGO_SIM_PORT`), missing/unparsable node or scenario files,
|
||||
unwritable report paths — yields a one-line
|
||||
`wago-sim: error: …` on stderr and exit code 2, never a Python
|
||||
traceback; missing report directories are auto-created; unexpected
|
||||
internal errors keep the traceback (logged) for debugging.
|
||||
*CLI part implemented* (tested in `tests/test_cli.py`).
|
||||
- NFR-6.6 Configurability: port, bind address, node config, firmware identity registers, MAC-ID configurable without code changes.
|
||||
|
||||
---
|
||||
@@ -1065,7 +1080,56 @@ This feature request closes that gap.
|
||||
|
||||
---
|
||||
|
||||
## 12. Roadmap (post-framework)
|
||||
## 12. Deployment — Service / Containerized Operation
|
||||
|
||||
> **Status: implemented 2026-10-03 (v1.3).** The simulator can run as a
|
||||
> long-lived service: docker/compose container, or a systemd unit on bare
|
||||
> metal/VM. Verified: image builds, container answers Modbus requests,
|
||||
> and `docker stop` (SIGTERM) shuts down gracefully in < 1 s.
|
||||
|
||||
### 12.1 Motivation
|
||||
|
||||
A FAT bench simulator is infrastructure: it must start on boot, survive
|
||||
restarts, log to the platform's log pipeline, and stop cleanly so a
|
||||
connected PLC sees a controlled fieldbus-node failure rather than a
|
||||
> hung socket — the exact condition watchdog/FAT fault cases test.
|
||||
|
||||
### 12.2 Requirements (all implemented)
|
||||
|
||||
- FR-12.2.1: **Environment-variable configuration**: `WAGO_SIM_NODE`,
|
||||
`WAGO_SIM_HOST`, `WAGO_SIM_PORT` configure `serve` without CLI arguments
|
||||
(container `CMD ["wago-sim", "serve"]` needs zero arguments).
|
||||
Explicit CLI arguments override environment values.
|
||||
- FR-12.2.2: **Graceful shutdown**: on SIGINT/SIGTERM (docker stop,
|
||||
systemctl stop) the server closes all client connections and the
|
||||
listening socket via `server.stop()` before exiting — no forced kill,
|
||||
no lingering sockets. Verified: `docker stop` completes in 0.34 s.
|
||||
- FR-12.2.3: **Container image** (`Dockerfile`): `python:3.12-slim`,
|
||||
non-root `wago-sim` user, unbuffered stdout logging, baked-in example
|
||||
config for out-of-the-box startup, TCP health check, ~197 MB image.
|
||||
- FR-12.2.4: **Compose service** (`docker-compose.yml`): port mapping
|
||||
(5020→502 default; 502 needs privileges on some hosts), read-only
|
||||
mounts for config/scenarios/plugins, writable `out/` for reports,
|
||||
resource limits, log rotation, `restart: unless-stopped`.
|
||||
- FR-12.2.5: **systemd unit** (`deploy/wago-sim.service`): dedicated
|
||||
service user, env-var configuration, `Restart=on-failure`, hardened
|
||||
(NoNewPrivileges, ProtectSystem, CAP_NET_BIND_SERVICE for port 502
|
||||
without root).
|
||||
- FR-12.2.6: Logs to stdout/stderr only (docker json-file / journald
|
||||
collect them); single-line format, no ANSI colors.
|
||||
|
||||
### 12.3 Acceptance criteria (all verified)
|
||||
|
||||
1. `docker build -t wago-sim .` succeeds; image ~197 MB.
|
||||
2. `docker run -p <host>:502 -e WAGO_SIM_NODE=... wago-sim` serves
|
||||
Modbus (constants `0x2000–0x2008` read correctly from the container).
|
||||
3. `docker stop` completes in < 1 s with clean shutdown logs.
|
||||
4. `docker compose up -d` starts a healthy service; `down` tears it down.
|
||||
5. CLI tests cover env-var configuration and override precedence.
|
||||
|
||||
---
|
||||
|
||||
## 13. Roadmap (post-framework)
|
||||
|
||||
| Phase | Feature |
|
||||
|---|---|
|
||||
@@ -1077,7 +1141,7 @@ This feature request closes that gap.
|
||||
|
||||
---
|
||||
|
||||
## 13. References
|
||||
## 14. 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`
|
||||
|
||||
+178
-15
@@ -5,6 +5,24 @@ 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
|
||||
|
||||
Verbose mode (``-v`` / ``--verbose`` / env ``WAGO_SIM_VERBOSE=1``) enables
|
||||
DEBUG logging: every Modbus request and response is then traced in
|
||||
plain text, e.g.::
|
||||
|
||||
REQ txn=0x0001 unit=1 FC03 Read Holding Registers
|
||||
addr=0x2002 count=1 [config/constant regs 0x2002]
|
||||
RSP txn=0x0001 unit=1 FC03 -> 1 regs: [0x1234]
|
||||
|
||||
Service/container operation: every `serve` option can also come from
|
||||
environment variables (WAGO_SIM_NODE, WAGO_SIM_HOST, WAGO_SIM_PORT),
|
||||
and the server shuts down gracefully on SIGINT/SIGTERM (docker stop,
|
||||
systemctl stop/restart).
|
||||
|
||||
Error handling: invalid arguments and unreadable/invalid input files
|
||||
produce a clean one-line ``wago-sim: error: ...`` message on stderr and
|
||||
exit code 2 — never a Python traceback. (Unexpected internal errors keep
|
||||
their traceback so they can be reported as bugs.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,42 +31,140 @@ import argparse
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from .app import build_app
|
||||
from .modules import ChannelMismatch, ModuleUnknown
|
||||
from .scenario.dsl import load_node_modules, load_yaml, validate_scenario
|
||||
from .scenario.engine import ScenarioEngine
|
||||
from .scenario.engine import ScenarioEngine, ScenarioError
|
||||
from .scenario.report import result_summary, write_csv, write_jsonl
|
||||
|
||||
log = logging.getLogger("wago_sim.cli")
|
||||
|
||||
|
||||
def _build_logger(verbose: bool) -> None:
|
||||
# Stream logs unbuffered & single-line so they work in docker/systemd
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if verbose else logging.INFO,
|
||||
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||
stream=sys.stdout,
|
||||
force=True,
|
||||
)
|
||||
|
||||
|
||||
def _fail(message: str, code: int = 2):
|
||||
"""Report a bad-argument / bad-configuration error cleanly and exit.
|
||||
|
||||
Prints a one-line ``wago-sim: error: ...`` to stderr (never a
|
||||
traceback) and raises SystemExit with `code` (2 = usage error).
|
||||
"""
|
||||
print(f"wago-sim: error: {message}", file=sys.stderr)
|
||||
raise SystemExit(code)
|
||||
|
||||
|
||||
def _port_type(value: str) -> int:
|
||||
"""argparse type: TCP port 1..65535.
|
||||
|
||||
Rejected at parse time with a clean message instead of failing
|
||||
later with ``OverflowError: bind(): port must be 0-65535``.
|
||||
"""
|
||||
try:
|
||||
port = int(value)
|
||||
except ValueError:
|
||||
raise argparse.ArgumentTypeError(f"invalid port number: {value!r}") from None
|
||||
if not 1 <= port <= 65535:
|
||||
raise argparse.ArgumentTypeError(f"port must be 1-65535, got {port}")
|
||||
return port
|
||||
|
||||
|
||||
def _env(name: str, default: str) -> str:
|
||||
"""Environment override for CLI defaults (service operation)."""
|
||||
return os.environ.get(name, default)
|
||||
|
||||
|
||||
def _env_port(name: str, default: int) -> int:
|
||||
"""WAGO_SIM_PORT with the same validation as --port (argparse does
|
||||
not type-check defaults, so env values must be validated here)."""
|
||||
raw = os.environ.get(name, str(default))
|
||||
try:
|
||||
return _port_type(raw)
|
||||
except argparse.ArgumentTypeError as exc:
|
||||
_fail(f"{name}: {exc}")
|
||||
|
||||
|
||||
def _env_flag(name: str) -> bool:
|
||||
"""Truthy env flag for service/container operation ('1', 'true', 'yes')."""
|
||||
return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _load_yaml_file(path: str, what: str) -> dict:
|
||||
"""Load a YAML file with clean CLI errors (missing / unparsable).
|
||||
|
||||
`what` names the file kind in messages, e.g. "node config".
|
||||
"""
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
_fail(f"{what} file not found (or not a file): {path}")
|
||||
try:
|
||||
return load_yaml(p)
|
||||
except ScenarioError as exc:
|
||||
_fail(f"{what} file {path}: {exc}")
|
||||
except yaml.YAMLError as exc:
|
||||
_fail(f"{what} file {path} is not valid YAML:\n {exc}")
|
||||
|
||||
|
||||
def _prepare_report_path(path: str, option: str) -> Path:
|
||||
"""Validate a report output path upfront (before the run starts).
|
||||
|
||||
Creates missing parent directories so a fresh clone works without
|
||||
a manual `mkdir out/`; unwritable locations fail here with a clean
|
||||
error instead of losing the verdict after a long scenario run.
|
||||
"""
|
||||
p = Path(path)
|
||||
try:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
_fail(f"cannot create directory for {option} ({path}): "
|
||||
f"{exc.strerror or exc}")
|
||||
return p
|
||||
|
||||
|
||||
async def _serve(args: argparse.Namespace) -> int:
|
||||
node_config = load_yaml(args.node)
|
||||
node_config = _load_yaml_file(args.node, "node config")
|
||||
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)")
|
||||
|
||||
# Graceful shutdown on SIGINT/SIGTERM (docker stop, systemctl stop).
|
||||
# asyncio.run installs a SIGINT handler; SIGTERM arrives as a raw
|
||||
# signal on the main thread — route both to the stop event.
|
||||
loop = asyncio.get_running_loop()
|
||||
stop = asyncio.Event()
|
||||
for sig in (signal.SIGTERM,):
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError:
|
||||
loop.add_signal_handler(sig, stop.set)
|
||||
except NotImplementedError: # pragma: no cover - Windows
|
||||
pass
|
||||
try:
|
||||
await stop.wait()
|
||||
except asyncio.CancelledError:
|
||||
pass # SIGINT via asyncio.run's handler
|
||||
finally:
|
||||
await app.server.stop()
|
||||
return 0
|
||||
|
||||
|
||||
def _run_scenario(args: argparse.Namespace) -> int:
|
||||
node_config = load_yaml(args.node)
|
||||
node_config = _load_yaml_file(args.node, "node config")
|
||||
modules = load_node_modules(node_config)
|
||||
scenario = load_yaml(args.scenario)
|
||||
scenario = _load_yaml_file(args.scenario, "scenario")
|
||||
|
||||
problems = validate_scenario(scenario)
|
||||
if problems:
|
||||
@@ -57,8 +173,18 @@ def _run_scenario(args: argparse.Namespace) -> int:
|
||||
print(f" - {p}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# validate report destinations BEFORE running: a bad path must not
|
||||
# lose the verdict of an hours-long scenario
|
||||
report_jsonl = (_prepare_report_path(args.report_jsonl, "--report-jsonl")
|
||||
if args.report_jsonl else None)
|
||||
report_csv = (_prepare_report_path(args.report_csv, "--report-csv")
|
||||
if args.report_csv else None)
|
||||
|
||||
app = build_app(modules=modules)
|
||||
try:
|
||||
engine = ScenarioEngine(app.node, scenario)
|
||||
except (ScenarioError, ChannelMismatch, IndexError, ValueError) as exc:
|
||||
_fail(f"scenario {args.scenario}: {exc}")
|
||||
engine.start()
|
||||
result = None
|
||||
max_ticks = 2_000_000
|
||||
@@ -70,15 +196,15 @@ def _run_scenario(args: argparse.Namespace) -> int:
|
||||
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)
|
||||
if report_jsonl:
|
||||
write_jsonl(result, report_jsonl)
|
||||
if report_csv:
|
||||
write_csv(result, report_csv)
|
||||
return 0 if result.passed else 1
|
||||
|
||||
|
||||
def _validate(args: argparse.Namespace) -> int:
|
||||
scenario = load_yaml(args.scenario)
|
||||
scenario = _load_yaml_file(args.scenario, "scenario")
|
||||
problems = validate_scenario(scenario)
|
||||
if problems:
|
||||
print("Scenario validation problems:")
|
||||
@@ -94,13 +220,28 @@ def main(argv: list[str] | None = None) -> int:
|
||||
prog="wago-sim",
|
||||
description="WAGO 750-362 Modbus TCP simulator for PLC FAT testing",
|
||||
)
|
||||
parser.add_argument("-v", "--verbose", action="store_true")
|
||||
parser.add_argument(
|
||||
"-v", "--verbose",
|
||||
action="store_true",
|
||||
default=_env_flag("WAGO_SIM_VERBOSE"),
|
||||
help=("trace every Modbus request/response in plain text "
|
||||
"(env: WAGO_SIM_VERBOSE)"),
|
||||
)
|
||||
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.add_argument(
|
||||
"--node", default=None,
|
||||
help="node config YAML (or env: WAGO_SIM_NODE)",
|
||||
)
|
||||
serve.add_argument(
|
||||
"--host", default=_env("WAGO_SIM_HOST", "0.0.0.0"),
|
||||
help="bind address (or env: WAGO_SIM_HOST)",
|
||||
)
|
||||
serve.add_argument(
|
||||
"--port", type=_port_type, default=_env_port("WAGO_SIM_PORT", 502),
|
||||
help="listen port 1-65535 (or env: WAGO_SIM_PORT)",
|
||||
)
|
||||
serve.set_defaults(func=_serve)
|
||||
|
||||
runp = sub.add_parser("run", help="run a scenario headless (no server)")
|
||||
@@ -116,9 +257,31 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
_build_logger(args.verbose)
|
||||
# `--node` may come from the environment when omitted (service mode);
|
||||
# only `serve` has an optional --node (`run` requires it in argparse)
|
||||
if args.func is _serve and args.node is None:
|
||||
args.node = _env("WAGO_SIM_NODE", "")
|
||||
if not args.node:
|
||||
parser.error("serve: --node is required (or set WAGO_SIM_NODE)")
|
||||
try:
|
||||
if inspect.iscoroutinefunction(args.func):
|
||||
return asyncio.run(args.func(args))
|
||||
return int(args.func(args))
|
||||
except SystemExit:
|
||||
raise
|
||||
except KeyboardInterrupt:
|
||||
# second Ctrl+C while already shutting down (first one is
|
||||
# handled gracefully inside _serve)
|
||||
print("\nwago-sim: interrupted", file=sys.stderr)
|
||||
raise SystemExit(130) from None
|
||||
except (ScenarioError, ModuleUnknown, ChannelMismatch,
|
||||
yaml.YAMLError, OSError) as exc:
|
||||
# user-input / environment problems: clean message, no traceback
|
||||
_fail(str(exc))
|
||||
except Exception:
|
||||
# genuine internal bug: keep the traceback for bug reports
|
||||
log.exception("unexpected internal error")
|
||||
raise SystemExit(1) from None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -45,6 +45,7 @@ from .pdu import (
|
||||
FC_WRITE_SINGLE_REGISTER,
|
||||
)
|
||||
from .stats import ModbusStatistics
|
||||
from .trace import format_raw, format_request, format_response
|
||||
from .watchdog import ModbusWatchdog, RegisterWriteRefused
|
||||
|
||||
log = logging.getLogger("wago_sim.server")
|
||||
@@ -201,12 +202,15 @@ class WagoSimServer:
|
||||
except UnsupportedFunction as exc:
|
||||
self.stats.bad_function += 1
|
||||
log.debug("FC%#04x unsupported: %s", exc.function_code, exc)
|
||||
log.debug("%s", format_raw(header, request_pdu))
|
||||
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
|
||||
log.debug("malformed value: %s", exc)
|
||||
log.debug("%s", format_raw(header, request_pdu))
|
||||
response = ExceptionResponse(
|
||||
request_pdu[0] if request_pdu else 0, EXC_ILLEGAL_DATA_VALUE
|
||||
)
|
||||
@@ -214,11 +218,15 @@ class WagoSimServer:
|
||||
except PduError as exc:
|
||||
self.stats.bad_length += 1
|
||||
log.warning("malformed PDU: %s", exc)
|
||||
log.debug("%s", format_raw(header, request_pdu))
|
||||
response = ExceptionResponse(
|
||||
request_pdu[0] if request_pdu else 0, EXC_ILLEGAL_DATA_VALUE
|
||||
)
|
||||
return self._finish(header, request_pdu, response, writer)
|
||||
|
||||
# verbose mode: plain-text request trace
|
||||
log.debug("%s", format_request(header, request))
|
||||
|
||||
# watchdog monitors configured function codes
|
||||
self.watchdog.on_function_code(request.function_code)
|
||||
|
||||
@@ -347,6 +355,8 @@ class WagoSimServer:
|
||||
response: pdu_mod.Response,
|
||||
writer: asyncio.StreamWriter,
|
||||
) -> bytes | None:
|
||||
# verbose mode: plain-text response/exception trace
|
||||
log.debug("%s", format_response(header, response))
|
||||
encoded = response.encode()
|
||||
delay_ms = self._response_delay_provider()
|
||||
if delay_ms:
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Plain-text tracing of Modbus transactions (verbose/debug mode).
|
||||
|
||||
Formats decoded requests and responses in human-readable form, e.g.::
|
||||
|
||||
REQ txn=0x0001 unit=1 FC03 Read Holding Registers
|
||||
addr=0x2002 count=1
|
||||
RSP txn=0x0001 unit=1 FC03 -> 1 reg: [0x1234]
|
||||
EXC txn=0x0001 unit=1 FC03 -> 0x02 Illegal Data Address (addr=0x0100)
|
||||
|
||||
Register addresses are annotated with their WAGO 750-362 meaning
|
||||
(process image areas, read-back mirrors, configuration/constant
|
||||
registers) so traces are directly readable during FAT bench work.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import pdu as pdu_mod
|
||||
from .mbap import MbapHeader
|
||||
|
||||
# --- function code names -------------------------------------------------------
|
||||
|
||||
_FC_NAMES = {
|
||||
0x01: "Read Coils",
|
||||
0x02: "Read Discrete Inputs",
|
||||
0x03: "Read Holding Registers",
|
||||
0x04: "Read Input Registers",
|
||||
0x05: "Write Single Coil",
|
||||
0x06: "Write Single Register",
|
||||
0x0B: "Get Comm Event Count",
|
||||
0x0F: "Write Multiple Coils",
|
||||
0x10: "Write Multiple Registers",
|
||||
0x16: "Mask Write Register",
|
||||
0x17: "Read/Write Multiple Registers",
|
||||
}
|
||||
|
||||
_EXCEPTION_NAMES = {
|
||||
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: "Gateway Path Unavailable",
|
||||
0x0B: "Gateway Target Failed",
|
||||
}
|
||||
|
||||
# --- WAGO register-map annotations (750-362 manual §11.2.4/§11.2.5) -------------
|
||||
|
||||
|
||||
def describe_address(address: int, count: int = 1, *, write: bool = False) -> str:
|
||||
"""Annotate a word address with its WAGO meaning, if any.
|
||||
|
||||
`write` selects the mapping per the manual: Table 119 (reads) maps
|
||||
0x0000-0x00FF to the INPUT area (%IW), while Table 120 (writes) maps
|
||||
the same range to the OUTPUT area (%QW); mirrors swap accordingly.
|
||||
"""
|
||||
end = address + count - 1
|
||||
|
||||
def area(name: str, note: str) -> str:
|
||||
if count == 1:
|
||||
return f"{name} {note}"
|
||||
return f"{name} {note} [{address:#06x}..{end:#06x}]"
|
||||
|
||||
if write:
|
||||
if address <= 0x00FF:
|
||||
return area("output area 1", f"(%QW{address})")
|
||||
if address <= 0x01FF:
|
||||
return "illegal area 0x0100-0x01FF"
|
||||
if address <= 0x02FF:
|
||||
return area("output area 1 mirror", f"(%QW{address - 0x0200})")
|
||||
if address <= 0x0FFF:
|
||||
return "illegal area 0x0300-0x0FFF"
|
||||
if address <= 0x2FFF:
|
||||
return f"config regs {address:#06x}" if address < 0x2000 \
|
||||
else f"config/constant regs {address:#06x}"
|
||||
if address <= 0x5FFF:
|
||||
return "illegal area 0x3000-0x5FFF"
|
||||
if address <= 0x62FB:
|
||||
return area("output area 2", f"(%QW{256 + address - 0x6000})")
|
||||
if address <= 0x6FFF:
|
||||
return "illegal area 0x62FC-0x6FFF"
|
||||
if address <= 0x72FB:
|
||||
return area("output area 2 mirror", f"(%QW{256 + address - 0x7000})")
|
||||
return "illegal area 0x72FC-0xFFFF"
|
||||
|
||||
if address <= 0x00FF:
|
||||
return area("input area 1", f"(%IW{address})")
|
||||
if address <= 0x01FF:
|
||||
return "illegal area 0x0100-0x01FF"
|
||||
if address <= 0x02FF:
|
||||
return area("output area 1 read-back", f"(%QW{address - 0x0200})")
|
||||
if address <= 0x0FFF:
|
||||
return "illegal area 0x0300-0x0FFF"
|
||||
if address <= 0x2FFF:
|
||||
return f"config/constant regs {address:#06x}"
|
||||
if address <= 0x5FFF:
|
||||
return "illegal area 0x3000-0x5FFF"
|
||||
if address <= 0x62FB:
|
||||
return area("input area 2", f"(%IW{256 + address - 0x6000})")
|
||||
if address <= 0x6FFF:
|
||||
return "illegal area 0x62FC-0x6FFF"
|
||||
if address <= 0x72FB:
|
||||
return area("output area 2 read-back", f"(%QW{256 + address - 0x7000})")
|
||||
return "illegal area 0x72FC-0xFFFF"
|
||||
|
||||
|
||||
def describe_bit_address(address: int, count: int = 1) -> str:
|
||||
"""Annotate a bit address (digital channel number semantics)."""
|
||||
end = address + count - 1
|
||||
if address < 0x0200:
|
||||
base = f"D/I {address}..{end} area 1" if count > 1 else f"channel {address} area 1"
|
||||
elif address < 0x0400:
|
||||
ch = address - 0x0200
|
||||
base = (f"DO {ch}..{end - 0x0200} area 1 read-back"
|
||||
if count > 1 else f"DO {ch} area 1 read-back")
|
||||
elif address >= 0x9000 and address <= 0x95F7:
|
||||
ch = 513 + address - 0x9000
|
||||
base = (f"DO {ch}..{ch + count - 1} area 2 read-back"
|
||||
if count > 1 else f"DO {ch} area 2 read-back")
|
||||
elif address >= 0x8000 and address <= 0x85F7:
|
||||
ch = 513 + address - 0x8000
|
||||
base = (f"DI {ch}..{ch + count - 1} area 2"
|
||||
if count > 1 else f"DI {ch} area 2")
|
||||
else:
|
||||
base = "illegal bit area"
|
||||
return base
|
||||
|
||||
|
||||
def _fc_name(fc: int) -> str:
|
||||
return _FC_NAMES.get(fc, f"Unknown ({fc:#04x})")
|
||||
|
||||
|
||||
def _exc_name(code: int) -> str:
|
||||
return _EXCEPTION_NAMES.get(code, f"Unknown ({code:#04x})")
|
||||
|
||||
|
||||
def _prefix(header: MbapHeader) -> str:
|
||||
return f"txn=0x{header.transaction_id:04X} unit={header.unit_id}"
|
||||
|
||||
|
||||
# --- request formatting ---------------------------------------------------------
|
||||
|
||||
|
||||
def format_request(header: MbapHeader, request: pdu_mod.Request) -> str:
|
||||
"""One- or two-line plain-text description of a decoded request."""
|
||||
fc = request.function_code
|
||||
head = f"REQ {_prefix(header)} FC{fc:02X} {_fc_name(fc)}"
|
||||
r = request
|
||||
if isinstance(r, pdu_mod.ReadRequest):
|
||||
is_bits = fc in (pdu_mod.FC_READ_COILS, pdu_mod.FC_READ_DISCRETE_INPUTS)
|
||||
where = (describe_bit_address(r.address, r.count) if is_bits
|
||||
else describe_address(r.address, r.count))
|
||||
return f"{head}\n addr={r.address:#06x} count={r.count} [{where}]"
|
||||
if isinstance(r, pdu_mod.WriteSingleCoilRequest):
|
||||
return (f"{head}\n addr={r.address:#06x} "
|
||||
f"value={'ON' if r.value else 'OFF'} "
|
||||
f"[{describe_bit_address(r.address)}]")
|
||||
if isinstance(r, pdu_mod.WriteSingleRegisterRequest):
|
||||
return (f"{head}\n addr={r.address:#06x} value={r.value:#06x} "
|
||||
f"[{describe_address(r.address, write=True)}]")
|
||||
if isinstance(r, pdu_mod.WriteMultipleCoilsRequest):
|
||||
vals = "".join("1" if v else "0" for v in r.values)
|
||||
return (f"{head}\n addr={r.address:#06x} values={vals} "
|
||||
f"[{describe_bit_address(r.address, len(r.values))}]")
|
||||
if isinstance(r, pdu_mod.WriteMultipleRegistersRequest):
|
||||
vals = ", ".join(f"{v:#06x}" for v in r.values)
|
||||
return (f"{head}\n addr={r.address:#06x} values=[{vals}] "
|
||||
f"[{describe_address(r.address, len(r.values), write=True)}]")
|
||||
if isinstance(r, pdu_mod.MaskWriteRegisterRequest):
|
||||
return (f"{head}\n addr={r.address:#06x} and={r.and_mask:#06x} "
|
||||
f"or={r.or_mask:#06x} [{describe_address(r.address, write=True)}]")
|
||||
if isinstance(r, pdu_mod.ReadWriteMultipleRegistersRequest):
|
||||
return (f"{head}\n read addr={r.read_address:#06x} count={r.read_count} "
|
||||
f"[{describe_address(r.read_address, r.read_count)}] | "
|
||||
f"write addr={r.write_address:#06x} values={list(r.values)} "
|
||||
f"[{describe_address(r.write_address, write=True)}]")
|
||||
if isinstance(r, pdu_mod.GetCommEventCountRequest):
|
||||
return head
|
||||
return f"{head} (no formatter)" # pragma: no cover
|
||||
|
||||
|
||||
def format_response(header: MbapHeader, response: pdu_mod.Response) -> str:
|
||||
"""Plain-text description of a response or exception."""
|
||||
prefix = f"{_prefix(header)}"
|
||||
if isinstance(response, pdu_mod.ExceptionResponse):
|
||||
return (f"EXC {prefix} FC{response.function_code:02X} "
|
||||
f"-> code=0x{response.exception_code:02X} "
|
||||
f"{_exc_name(response.exception_code)}")
|
||||
resp = response
|
||||
if isinstance(resp, pdu_mod.ReadResponse):
|
||||
if resp.bits:
|
||||
return (f"RSP {prefix} FC{resp.function_code:02X} "
|
||||
f"-> {len(resp.bits)} bits: "
|
||||
f"{''.join('1' if b else '0' for b in resp.bits)}")
|
||||
return (f"RSP {prefix} FC{resp.function_code:02X} "
|
||||
f"-> {len(resp.registers)} regs: "
|
||||
f"[{', '.join(f'{v:#06x}' for v in resp.registers)}]")
|
||||
if isinstance(resp, pdu_mod.WriteSingleCoilResponse):
|
||||
return f"RSP {prefix} FC{resp.function_code:02X} -> echo"
|
||||
if isinstance(resp, pdu_mod.WriteSingleRegisterResponse):
|
||||
return f"RSP {prefix} FC{resp.function_code:02X} -> echo"
|
||||
if isinstance(resp, pdu_mod.WriteMultipleResponse):
|
||||
return (f"RSP {prefix} FC{resp.function_code:02X} "
|
||||
f"-> wrote {resp.count} items")
|
||||
if isinstance(resp, pdu_mod.MaskWriteRegisterResponse):
|
||||
return f"RSP {prefix} FC{resp.function_code:02X} -> echo"
|
||||
if isinstance(resp, pdu_mod.GetCommEventCountResponse):
|
||||
return (f"RSP {prefix} FC{resp.function_code:02X} "
|
||||
f"-> events={resp.event_count}")
|
||||
return f"RSP {prefix} -> (no formatter)" # pragma: no cover
|
||||
|
||||
|
||||
def format_raw(header: MbapHeader, pdu: bytes) -> str:
|
||||
"""Raw-frame line for structurally invalid PDUs (cannot be decoded)."""
|
||||
hexed = pdu.hex(" ") if pdu else "(empty)"
|
||||
return f"RAW {prefix_stub(header)} PDU[{len(pdu)}]: {hexed}"
|
||||
|
||||
|
||||
def prefix_stub(header: MbapHeader) -> str:
|
||||
return _prefix(header)
|
||||
@@ -0,0 +1,197 @@
|
||||
"""CLI tests: env-var configuration and bad-argument handling.
|
||||
|
||||
The serve coroutine blocks on a shutdown signal (by design — it is a
|
||||
service), so serve-args tests capture the parsed args instead of
|
||||
running the real server loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from wago_sim.cli import _env, _env_port, main
|
||||
|
||||
|
||||
class TestEnvHelpers:
|
||||
def test_port_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("WAGO_SIM_PORT", "1234")
|
||||
assert _env_port("WAGO_SIM_PORT", 502) == 1234
|
||||
monkeypatch.delenv("WAGO_SIM_PORT")
|
||||
assert _env_port("WAGO_SIM_PORT", 502) == 502
|
||||
|
||||
def test_host_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("WAGO_SIM_HOST", "10.0.0.5")
|
||||
assert _env("WAGO_SIM_HOST", "0.0.0.0") == "10.0.0.5"
|
||||
monkeypatch.delenv("WAGO_SIM_HOST")
|
||||
assert _env("WAGO_SIM_HOST", "0.0.0.0") == "0.0.0.0"
|
||||
|
||||
|
||||
class TestServeEnvConfig:
|
||||
"""WAGO_SIM_* env vars configure serve without CLI args (service mode)."""
|
||||
|
||||
@staticmethod
|
||||
def _capture_serve(monkeypatch):
|
||||
"""Replace the serve coroutine with an args recorder.
|
||||
|
||||
main() builds its parser on each call and resolves `_serve` from
|
||||
the module namespace, so monkeypatching before the call works.
|
||||
"""
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_serve(args) -> int:
|
||||
captured["node"] = args.node
|
||||
captured["host"] = args.host
|
||||
captured["port"] = args.port
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr("wago_sim.cli._serve", fake_serve)
|
||||
return captured
|
||||
|
||||
def test_node_and_port_from_env(self, monkeypatch):
|
||||
captured = self._capture_serve(monkeypatch)
|
||||
monkeypatch.setenv("WAGO_SIM_NODE", "config/pump_node.yaml")
|
||||
monkeypatch.setenv("WAGO_SIM_PORT", "15025")
|
||||
monkeypatch.setenv("WAGO_SIM_HOST", "127.0.0.1")
|
||||
assert main(["serve"]) == 0
|
||||
assert captured["node"] == "config/pump_node.yaml"
|
||||
assert captured["port"] == 15025
|
||||
assert captured["host"] == "127.0.0.1"
|
||||
|
||||
def test_cli_overrides_env(self, monkeypatch):
|
||||
captured = self._capture_serve(monkeypatch)
|
||||
monkeypatch.setenv("WAGO_SIM_NODE", "config/other.yaml")
|
||||
monkeypatch.setenv("WAGO_SIM_PORT", "1111")
|
||||
assert main(["serve", "--node", "config/pump_node.yaml",
|
||||
"--port", "15026"]) == 0
|
||||
assert captured["node"] == "config/pump_node.yaml"
|
||||
assert captured["port"] == 15026
|
||||
|
||||
def test_missing_node_is_an_error(self, monkeypatch):
|
||||
monkeypatch.delenv("WAGO_SIM_NODE", raising=False)
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["serve"])
|
||||
# argparse parser.error exit code
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
def test_node_env_only_no_cli_args(self, monkeypatch):
|
||||
"""The container CMD is plain `wago-sim serve` — everything env."""
|
||||
captured = self._capture_serve(monkeypatch)
|
||||
monkeypatch.setenv("WAGO_SIM_NODE", "config/pump_node.yaml")
|
||||
assert main(["serve"]) == 0
|
||||
assert captured["node"] == "config/pump_node.yaml"
|
||||
# host/port fall back to defaults
|
||||
assert captured["host"] == "0.0.0.0"
|
||||
assert captured["port"] == 502
|
||||
|
||||
|
||||
class TestBadArgumentHandling:
|
||||
"""Bad arguments produce clean errors, exit code 2, no tracebacks."""
|
||||
|
||||
def test_no_command_is_usage_error(self):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main([])
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
def test_unknown_command_is_usage_error(self):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["frobnicate"])
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
def test_unknown_option_is_usage_error(self):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["serve", "--frobnicate"])
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
def test_non_numeric_port_rejected_at_parse(self):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["serve", "--node", "config/pump_node.yaml", "--port", "abc"])
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
@pytest.mark.parametrize("port", ["0", "-1", "65536", "99999"])
|
||||
def test_port_out_of_range_rejected(self, port):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["serve", "--node", "config/pump_node.yaml", "--port", port])
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
def test_env_port_invalid_rejected(self, monkeypatch, capsys):
|
||||
monkeypatch.setenv("WAGO_SIM_PORT", "banana")
|
||||
monkeypatch.delenv("WAGO_SIM_NODE", raising=False)
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["serve"])
|
||||
assert excinfo.value.code == 2
|
||||
assert "WAGO_SIM_PORT" in capsys.readouterr().err
|
||||
|
||||
def test_env_port_out_of_range_rejected(self, monkeypatch):
|
||||
monkeypatch.setenv("WAGO_SIM_PORT", "70000")
|
||||
monkeypatch.delenv("WAGO_SIM_NODE", raising=False)
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["serve"])
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
def test_missing_node_file_is_clean_error(self, capsys):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["serve", "--node", "config/does_not_exist.yaml"])
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "wago-sim: error:" in err
|
||||
assert "node config file not found" in err
|
||||
assert "Traceback" not in err
|
||||
|
||||
def test_missing_scenario_file_is_clean_error(self, capsys):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["validate", "--scenario", "scenarios/nope.yaml"])
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "scenario file not found" in err
|
||||
assert "Traceback" not in err
|
||||
|
||||
def test_invalid_yaml_is_clean_error(self, capsys, tmp_path):
|
||||
bad = tmp_path / "bad.yaml"
|
||||
bad.write_text("key: [unclosed", encoding="utf-8")
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["validate", "--scenario", str(bad)])
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "not valid YAML" in err
|
||||
assert "Traceback" not in err
|
||||
|
||||
def test_directory_as_yaml_is_clean_error(self, capsys):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["validate", "--scenario", "scenarios"])
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "not a file" in err
|
||||
assert "Traceback" not in err
|
||||
|
||||
def test_run_missing_required_options_is_usage_error(self):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["run"])
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
|
||||
class TestReportPathHandling:
|
||||
def test_report_dirs_auto_created(self, tmp_path, capsys):
|
||||
"""Missing report parent directories are created before the run."""
|
||||
from wago_sim.cli import _prepare_report_path
|
||||
|
||||
target = tmp_path / "a" / "b" / "r.jsonl"
|
||||
result = _prepare_report_path(str(target), "--report-jsonl")
|
||||
assert result.parent.is_dir()
|
||||
|
||||
def test_unwritable_report_path_is_clean_error(self, tmp_path, capsys):
|
||||
from wago_sim.cli import _prepare_report_path
|
||||
|
||||
# a *file* used as parent directory -> cannot create
|
||||
blocker = tmp_path / "blocker"
|
||||
blocker.write_text("x", encoding="utf-8")
|
||||
target = blocker / "sub" / "r.jsonl"
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_prepare_report_path(str(target), "--report-jsonl")
|
||||
assert excinfo.value.code == 2
|
||||
assert "cannot create directory" in capsys.readouterr().err
|
||||
|
||||
def test_missing_node_is_an_error(self, monkeypatch):
|
||||
monkeypatch.delenv("WAGO_SIM_NODE", raising=False)
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["serve"])
|
||||
assert excinfo.value.code == 2 # argparse error exit code
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Plain-text request/response tracing tests (verbose mode)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from wago_sim import pdu as pdu_mod
|
||||
from wago_sim.mbap import MbapHeader
|
||||
from wago_sim.trace import (
|
||||
describe_address,
|
||||
describe_bit_address,
|
||||
format_raw,
|
||||
format_request,
|
||||
format_response,
|
||||
)
|
||||
|
||||
|
||||
def _header(txn: int = 1, unit: int = 1) -> MbapHeader:
|
||||
return MbapHeader(transaction_id=txn, protocol_id=0, length=6, unit_id=unit)
|
||||
|
||||
|
||||
class TestAddressAnnotations:
|
||||
def test_word_areas(self):
|
||||
assert "%IW0" in describe_address(0x0000)
|
||||
assert "%QW0" in describe_address(0x0200) # read-back mirror
|
||||
assert "%IW256" in describe_address(0x6000)
|
||||
assert "%QW256" in describe_address(0x7000)
|
||||
assert "config" in describe_address(0x1000).lower()
|
||||
|
||||
def test_write_mapping_differs_from_read(self):
|
||||
"""Table 120 (writes): 0x0000-0x00FF is the OUTPUT area %QW0-255;
|
||||
Table 119 (reads): the same range is the INPUT area %IW0-255."""
|
||||
assert "%QW0" in describe_address(0x0000, write=True)
|
||||
assert "%IW0" in describe_address(0x0000, write=False)
|
||||
assert "%QW5" in describe_address(0x0005, write=True)
|
||||
assert "mirror" in describe_address(0x0200, write=True)
|
||||
assert "read-back" in describe_address(0x0200, write=False)
|
||||
assert "%QW256" in describe_address(0x6000, write=True)
|
||||
|
||||
def test_illegal_word_areas(self):
|
||||
for addr in (0x0100, 0x0300, 0x3000, 0x62FC, 0x72FC):
|
||||
assert "illegal" in describe_address(addr)
|
||||
assert "illegal" in describe_address(addr, write=True)
|
||||
|
||||
def test_word_ranges(self):
|
||||
text = describe_address(0x0000, 4)
|
||||
assert "0x0000..0x0003" in text
|
||||
|
||||
def test_bit_areas(self):
|
||||
assert "channel" in describe_bit_address(0)
|
||||
assert "read-back" in describe_bit_address(0x0200)
|
||||
assert "area 2" in describe_bit_address(0x8000)
|
||||
assert "illegal" in describe_bit_address(0x0400)
|
||||
|
||||
|
||||
class TestRequestFormatting:
|
||||
def test_read_holding_registers(self):
|
||||
req = pdu_mod.ReadRequest(pdu_mod.FC_READ_HOLDING_REGISTERS, 0x2002, 1)
|
||||
text = format_request(_header(), req)
|
||||
assert "FC03" in text
|
||||
assert "Read Holding Registers" in text
|
||||
assert "addr=0x2002" in text
|
||||
assert "count=1" in text
|
||||
assert "txn=0x0001" in text
|
||||
assert "unit=1" in text
|
||||
|
||||
def test_read_coils_bit_annotation(self):
|
||||
req = pdu_mod.ReadRequest(pdu_mod.FC_READ_COILS, 0x0200, 8)
|
||||
text = format_request(_header(), req)
|
||||
assert "FC01" in text
|
||||
assert "read-back" in text
|
||||
|
||||
def test_write_single_coil(self):
|
||||
req = pdu_mod.WriteSingleCoilRequest(pdu_mod.FC_WRITE_SINGLE_COIL, 5, True)
|
||||
text = format_request(_header(), req)
|
||||
assert "FC05" in text
|
||||
assert "value=ON" in text
|
||||
|
||||
def test_write_single_register(self):
|
||||
req = pdu_mod.WriteSingleRegisterRequest(
|
||||
pdu_mod.FC_WRITE_SINGLE_REGISTER, 0x0000, 0x1234
|
||||
)
|
||||
text = format_request(_header(), req)
|
||||
assert "FC06" in text
|
||||
assert "value=0x1234" in text
|
||||
# write mapping: 0x0000 is %QW0 (output), not %IW0
|
||||
assert "%QW0" in text
|
||||
|
||||
def test_write_multiple_registers(self):
|
||||
req = pdu_mod.WriteMultipleRegistersRequest(
|
||||
pdu_mod.FC_WRITE_MULTIPLE_REGISTERS, 0x0000, (0x1234, 0x5678)
|
||||
)
|
||||
text = format_request(_header(), req)
|
||||
assert "FC10" in text
|
||||
assert "0x1234" in text and "0x5678" in text
|
||||
|
||||
def test_write_multiple_coils_bitstring(self):
|
||||
req = pdu_mod.WriteMultipleCoilsRequest(
|
||||
pdu_mod.FC_WRITE_MULTIPLE_COILS, 0x0000, (True, False, True)
|
||||
)
|
||||
text = format_request(_header(), req)
|
||||
assert "values=101" in text
|
||||
|
||||
def test_mask_write(self):
|
||||
req = pdu_mod.MaskWriteRegisterRequest(
|
||||
pdu_mod.FC_MASK_WRITE_REGISTER, 0x0000, 0x00F2, 0x0025
|
||||
)
|
||||
text = format_request(_header(), req)
|
||||
assert "FC16" in text
|
||||
assert "and=0x00f2" in text
|
||||
|
||||
def test_read_write_multiple(self):
|
||||
req = pdu_mod.ReadWriteMultipleRegistersRequest(
|
||||
pdu_mod.FC_READ_WRITE_MULTIPLE_REGISTERS, 0x2002, 1, 0x0000, (0xBEEF,)
|
||||
)
|
||||
text = format_request(_header(), req)
|
||||
assert "FC17" in text
|
||||
assert "read addr=0x2002" in text
|
||||
|
||||
def test_event_count(self):
|
||||
req = pdu_mod.GetCommEventCountRequest(pdu_mod.FC_GET_COMM_EVENT_COUNT)
|
||||
text = format_request(_header(), req)
|
||||
assert "FC0B" in text
|
||||
assert "Get Comm Event Count" in text
|
||||
|
||||
|
||||
class TestResponseFormatting:
|
||||
def test_read_registers(self):
|
||||
resp = pdu_mod.ReadResponse(pdu_mod.FC_READ_HOLDING_REGISTERS)
|
||||
resp.registers = [0x1234]
|
||||
text = format_response(_header(), resp)
|
||||
assert "RSP" in text
|
||||
assert "1 regs" in text
|
||||
assert "0x1234" in text
|
||||
|
||||
def test_read_bits(self):
|
||||
resp = pdu_mod.ReadResponse(pdu_mod.FC_READ_COILS)
|
||||
resp.bits = [True, False, True]
|
||||
text = format_response(_header(), resp)
|
||||
assert "3 bits" in text
|
||||
assert "101" in text
|
||||
|
||||
def test_exception(self):
|
||||
resp = pdu_mod.ExceptionResponse(
|
||||
pdu_mod.FC_READ_HOLDING_REGISTERS,
|
||||
pdu_mod.EXC_ILLEGAL_DATA_ADDRESS,
|
||||
)
|
||||
text = format_response(_header(), resp)
|
||||
assert "EXC" in text
|
||||
assert "0x02" in text
|
||||
assert "Illegal Data Address" in text
|
||||
|
||||
def test_write_echo(self):
|
||||
resp = pdu_mod.WriteSingleRegisterResponse(0x0000, 0x1234)
|
||||
text = format_response(_header(), resp)
|
||||
assert "echo" in text
|
||||
|
||||
def test_event_count_response(self):
|
||||
resp = pdu_mod.GetCommEventCountResponse(0, 42)
|
||||
text = format_response(_header(), resp)
|
||||
assert "events=42" in text
|
||||
|
||||
|
||||
class TestRawFormatting:
|
||||
def test_raw_pdu(self):
|
||||
text = format_raw(_header(), b"\x03\x20\x02\x00\x01")
|
||||
assert "RAW" in text
|
||||
assert "03 20 02 00 01" in text
|
||||
|
||||
|
||||
class TestServerVerboseIntegration:
|
||||
"""The server funnels every transaction through the trace formatter."""
|
||||
|
||||
def test_trace_lines_in_logs(self, app, caplog):
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
app.server.host, app.server.port = "127.0.0.1", 15030
|
||||
|
||||
async def scenario():
|
||||
await app.server.start()
|
||||
await asyncio.sleep(0.05)
|
||||
reader, writer = await asyncio.open_connection("127.0.0.1", 15030)
|
||||
pdu = bytes([pdu_mod.FC_READ_HOLDING_REGISTERS]) + (0x2002).to_bytes(2, "big") + (1).to_bytes(2, "big")
|
||||
from wago_sim.mbap import build_frame
|
||||
writer.write(build_frame(1, 1, pdu))
|
||||
await writer.drain()
|
||||
header = await reader.readexactly(7)
|
||||
length = int.from_bytes(header[4:6], "big")
|
||||
body = await reader.readexactly(length - 1)
|
||||
writer.close()
|
||||
await app.server.stop()
|
||||
return body
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="wago_sim.server"):
|
||||
body = asyncio.run(scenario())
|
||||
assert int.from_bytes(body[2:4], "big") == 0x1234
|
||||
assert any("REQ" in rec.message for rec in caplog.records)
|
||||
assert any("RSP" in rec.message for rec in caplog.records)
|
||||
assert any("Read Holding Registers" in rec.message for rec in caplog.records)
|
||||
Reference in New Issue
Block a user