This commit is contained in:
Jonas Bengtsson
2026-10-03 18:54:37 +02:00
parent ba8df075c3
commit 1aa7aa8767
13 changed files with 1167 additions and 30 deletions
+26
View File
@@ -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/
+1
View File
@@ -13,6 +13,7 @@
"request": "launch", "request": "launch",
"module": "wago_sim.cli", "module": "wago_sim.cli",
"args": [ "args": [
"--verbose",
"serve", "serve",
"--node", "config/pump_node.yaml", "--node", "config/pump_node.yaml",
"--host", "127.0.0.1", "--host", "127.0.0.1",
+8
View File
@@ -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
View File
@@ -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"]
+102 -5
View File
@@ -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 Continuous `invariants` and `rate_limits` produce bounded, episode-based
reports for unattended runs. reports for unattended runs.
- ≤ 15 concurrent Modbus TCP connections, idle-timeout handling. - ≤ 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) ## Install (dev)
@@ -81,6 +86,76 @@ wago-sim run --node config/pump_node.yaml \
--report-jsonl out/report.jsonl --report-csv out/report.csv --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 ## Documentation
- `docs/spec/software-spec.md` — the software specification - `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 first; open items follow, grouped by spec phase, with the spec section or
requirement ID for each item in parentheses. 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, - [x] MBAP + PDU codecs: function codes FC1, FC2, FC3, FC4, FC5, FC6,
FC11, FC15, FC16, FC22, FC23 with the 750-362 device limits 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`. actions (FR-4.3.8), e.g. `plugins/pump_plant.py`.
- [x] Deterministic tick-driven engine: sim-time decoupled from - [x] Deterministic tick-driven engine: sim-time decoupled from
wall-clock (FR-4.3.9 core). 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] CLI: `wago-sim serve / run / validate`.
- [x] Reports: console verdict summary, JSONL and CSV export - [x] Reports: console verdict summary, JSONL and CSV export
(`--report-jsonl` / `--report-csv`). (`--report-jsonl` / `--report-csv`).
- [x] asyncio Modbus TCP server: ≤ 15 concurrent connections, idle - [x] asyncio Modbus TCP server: ≤ 15 concurrent connections, idle
connection reaper, response-delay task. connection reaper, response-delay task.
- [x] Test suite: 124 tests incl. real-socket server tests and - [x] Test suite: 178 tests incl. real-socket server tests, `pymodbus`
`pymodbus` interop; spec §7 acceptance criteria 1–7 covered. 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) ### Framework gaps (spec §4 — Scenario & Macro Engine)
@@ -271,7 +363,12 @@ streaming, or entirely without it.
### Operational hardening (spec §6 — NFRs) ### Operational hardening (spec §6 — NFRs)
- [ ] Per-transaction Modbus trace log with sim-time + wall-time stamps - [x] Plain-text request/response tracing in verbose mode (`-v` /
and PDU hex (NFR-6.4). `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 - [ ] Performance budget validation: ≥ 500 requests/s, ≤ 10 ms added
latency (NFR-6.1). latency (NFR-6.1).
+52
View File
@@ -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
+48
View File
@@ -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"
+70 -6
View File
@@ -5,9 +5,9 @@
| | | | | |
|---|---| |---|---|
| Project | `wago-modbustcp-simulator-py` | | Project | `wago-modbustcp-simulator-py` |
| Version | 1.2 (Draft for review) | | Version | 1.3 (Draft for review) |
| Date | 2026-10-03 | | 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 | | 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) | | 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.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.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.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.4 Observability: every Modbus transaction is observable in
- NFR-6.5 Robustness: malformed frames never crash the server; counted in statistics. 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. - 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 | | 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 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` - WAGO I/O System 750 system description `m075xxxxx_xxxxxxxx_0en.pdf`
+182 -19
View File
@@ -5,6 +5,24 @@ Examples::
wago-sim serve --node config/pump_node.yaml --port 502 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 run --node config/pump_node.yaml --scenario scenarios/pump_station_fat.yaml
wago-sim validate --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 from __future__ import annotations
@@ -13,42 +31,140 @@ import argparse
import asyncio import asyncio
import inspect import inspect
import logging import logging
import os
import signal
import sys import sys
from pathlib import Path from pathlib import Path
import yaml
from .app import build_app from .app import build_app
from .modules import ChannelMismatch, ModuleUnknown
from .scenario.dsl import load_node_modules, load_yaml, validate_scenario 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 from .scenario.report import result_summary, write_csv, write_jsonl
log = logging.getLogger("wago_sim.cli")
def _build_logger(verbose: bool) -> None: def _build_logger(verbose: bool) -> None:
# Stream logs unbuffered & single-line so they work in docker/systemd
logging.basicConfig( logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO, level=logging.DEBUG if verbose else logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", 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: 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) modules = load_node_modules(node_config)
app = build_app(modules=modules, host=args.host, port=args.port) app = build_app(modules=modules, host=args.host, port=args.port)
await app.server.start() await app.server.start()
print(f"Simulating WAGO 750-362 with {len(modules)} modules " print(f"Simulating WAGO 750-362 with {len(modules)} modules "
f"on {args.host}:{args.port} (Ctrl+C to stop)") 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:
loop.add_signal_handler(sig, stop.set)
except NotImplementedError: # pragma: no cover - Windows
pass
try: try:
await asyncio.Event().wait() await stop.wait()
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass # SIGINT via asyncio.run's handler
finally: finally:
await app.server.stop() await app.server.stop()
return 0 return 0
def _run_scenario(args: argparse.Namespace) -> int: 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) modules = load_node_modules(node_config)
scenario = load_yaml(args.scenario) scenario = _load_yaml_file(args.scenario, "scenario")
problems = validate_scenario(scenario) problems = validate_scenario(scenario)
if problems: if problems:
@@ -57,8 +173,18 @@ def _run_scenario(args: argparse.Namespace) -> int:
print(f" - {p}", file=sys.stderr) print(f" - {p}", file=sys.stderr)
return 2 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) app = build_app(modules=modules)
engine = ScenarioEngine(app.node, scenario) try:
engine = ScenarioEngine(app.node, scenario)
except (ScenarioError, ChannelMismatch, IndexError, ValueError) as exc:
_fail(f"scenario {args.scenario}: {exc}")
engine.start() engine.start()
result = None result = None
max_ticks = 2_000_000 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) print("Scenario did not terminate (tick limit reached)", file=sys.stderr)
return 3 return 3
print(result_summary(result)) print(result_summary(result))
if args.report_jsonl: if report_jsonl:
write_jsonl(result, args.report_jsonl) write_jsonl(result, report_jsonl)
if args.report_csv: if report_csv:
write_csv(result, args.report_csv) write_csv(result, report_csv)
return 0 if result.passed else 1 return 0 if result.passed else 1
def _validate(args: argparse.Namespace) -> int: def _validate(args: argparse.Namespace) -> int:
scenario = load_yaml(args.scenario) scenario = _load_yaml_file(args.scenario, "scenario")
problems = validate_scenario(scenario) problems = validate_scenario(scenario)
if problems: if problems:
print("Scenario validation problems:") print("Scenario validation problems:")
@@ -94,13 +220,28 @@ def main(argv: list[str] | None = None) -> int:
prog="wago-sim", prog="wago-sim",
description="WAGO 750-362 Modbus TCP simulator for PLC FAT testing", 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) sub = parser.add_subparsers(dest="command", required=True)
serve = sub.add_parser("serve", help="run the Modbus TCP server") serve = sub.add_parser("serve", help="run the Modbus TCP server")
serve.add_argument("--node", required=True, help="node config YAML") serve.add_argument(
serve.add_argument("--host", default="0.0.0.0") "--node", default=None,
serve.add_argument("--port", type=int, default=502) 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) serve.set_defaults(func=_serve)
runp = sub.add_parser("run", help="run a scenario headless (no server)") 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) args = parser.parse_args(argv)
_build_logger(args.verbose) _build_logger(args.verbose)
if inspect.iscoroutinefunction(args.func): # `--node` may come from the environment when omitted (service mode);
return asyncio.run(args.func(args)) # only `serve` has an optional --node (`run` requires it in argparse)
return int(args.func(args)) 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__": if __name__ == "__main__":
+10
View File
@@ -45,6 +45,7 @@ from .pdu import (
FC_WRITE_SINGLE_REGISTER, FC_WRITE_SINGLE_REGISTER,
) )
from .stats import ModbusStatistics from .stats import ModbusStatistics
from .trace import format_raw, format_request, format_response
from .watchdog import ModbusWatchdog, RegisterWriteRefused from .watchdog import ModbusWatchdog, RegisterWriteRefused
log = logging.getLogger("wago_sim.server") log = logging.getLogger("wago_sim.server")
@@ -201,12 +202,15 @@ class WagoSimServer:
except UnsupportedFunction as exc: except UnsupportedFunction as exc:
self.stats.bad_function += 1 self.stats.bad_function += 1
log.debug("FC%#04x unsupported: %s", exc.function_code, exc) log.debug("FC%#04x unsupported: %s", exc.function_code, exc)
log.debug("%s", format_raw(header, request_pdu))
response: pdu_mod.Response = ExceptionResponse( response: pdu_mod.Response = ExceptionResponse(
exc.function_code, EXC_ILLEGAL_FUNCTION exc.function_code, EXC_ILLEGAL_FUNCTION
) )
return self._finish(header, request_pdu, response, writer) return self._finish(header, request_pdu, response, writer)
except IllegalDataValue as exc: except IllegalDataValue as exc:
self.stats.bad_data += 1 self.stats.bad_data += 1
log.debug("malformed value: %s", exc)
log.debug("%s", format_raw(header, request_pdu))
response = ExceptionResponse( response = ExceptionResponse(
request_pdu[0] if request_pdu else 0, EXC_ILLEGAL_DATA_VALUE request_pdu[0] if request_pdu else 0, EXC_ILLEGAL_DATA_VALUE
) )
@@ -214,11 +218,15 @@ class WagoSimServer:
except PduError as exc: except PduError as exc:
self.stats.bad_length += 1 self.stats.bad_length += 1
log.warning("malformed PDU: %s", exc) log.warning("malformed PDU: %s", exc)
log.debug("%s", format_raw(header, request_pdu))
response = ExceptionResponse( response = ExceptionResponse(
request_pdu[0] if request_pdu else 0, EXC_ILLEGAL_DATA_VALUE request_pdu[0] if request_pdu else 0, EXC_ILLEGAL_DATA_VALUE
) )
return self._finish(header, request_pdu, response, writer) 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 # watchdog monitors configured function codes
self.watchdog.on_function_code(request.function_code) self.watchdog.on_function_code(request.function_code)
@@ -347,6 +355,8 @@ class WagoSimServer:
response: pdu_mod.Response, response: pdu_mod.Response,
writer: asyncio.StreamWriter, writer: asyncio.StreamWriter,
) -> bytes | None: ) -> bytes | None:
# verbose mode: plain-text response/exception trace
log.debug("%s", format_response(header, response))
encoded = response.encode() encoded = response.encode()
delay_ms = self._response_delay_provider() delay_ms = self._response_delay_provider()
if delay_ms: if delay_ms:
+221
View File
@@ -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)
+197
View File
@@ -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
+198
View File
@@ -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)