From 1aa7aa87679231cb5caea4bc7c3fc304a5a34300 Mon Sep 17 00:00:00 2001 From: Jonas Bengtsson Date: Sat, 3 Oct 2026 18:54:37 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 26 +++++ .vscode/launch.json | 1 + .vscode/settings.json | 8 ++ Dockerfile | 52 +++++++++ README.md | 107 +++++++++++++++++- deploy/wago-sim.service | 52 +++++++++ docker-compose.yml | 48 ++++++++ docs/spec/software-spec.md | 76 ++++++++++++- src/wago_sim/cli.py | 201 +++++++++++++++++++++++++++++---- src/wago_sim/server.py | 10 ++ src/wago_sim/trace.py | 221 +++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 197 +++++++++++++++++++++++++++++++++ tests/test_trace.py | 198 +++++++++++++++++++++++++++++++++ 13 files changed, 1167 insertions(+), 30 deletions(-) create mode 100644 .dockerignore create mode 100644 .vscode/settings.json create mode 100644 Dockerfile create mode 100644 deploy/wago-sim.service create mode 100644 docker-compose.yml create mode 100644 src/wago_sim/trace.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_trace.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c1093fe --- /dev/null +++ b/.dockerignore @@ -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/ \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 1c9722b..129b525 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,6 +13,7 @@ "request": "launch", "module": "wago_sim.cli", "args": [ + "--verbose", "serve", "--node", "config/pump_node.yaml", "--host", "127.0.0.1", diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a810c4a --- /dev/null +++ b/.vscode/settings.json @@ -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"] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8cef948 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/README.md b/README.md index 1cc3592..f89ef24 100644 --- a/README.md +++ b/README.md @@ -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). \ No newline at end of file diff --git a/deploy/wago-sim.service b/deploy/wago-sim.service new file mode 100644 index 0000000..bd27276 --- /dev/null +++ b/deploy/wago-sim.service @@ -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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..525cc4f --- /dev/null +++ b/docker-compose.yml @@ -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" \ No newline at end of file diff --git a/docs/spec/software-spec.md b/docs/spec/software-spec.md index 94df265..10aac07 100644 --- a/docs/spec/software-spec.md +++ b/docs/spec/software-spec.md @@ -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 :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` diff --git a/src/wago_sim/cli.py b/src/wago_sim/cli.py index 6d2374c..3a7c6c7 100644 --- a/src/wago_sim/cli.py +++ b/src/wago_sim/cli.py @@ -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: + loop.add_signal_handler(sig, stop.set) + except NotImplementedError: # pragma: no cover - Windows + pass try: - await asyncio.Event().wait() + await stop.wait() except asyncio.CancelledError: - pass + 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) - 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() 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) - if inspect.iscoroutinefunction(args.func): - return asyncio.run(args.func(args)) - return int(args.func(args)) + # `--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__": diff --git a/src/wago_sim/server.py b/src/wago_sim/server.py index 28b7565..965d292 100644 --- a/src/wago_sim/server.py +++ b/src/wago_sim/server.py @@ -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: diff --git a/src/wago_sim/trace.py b/src/wago_sim/trace.py new file mode 100644 index 0000000..ab18576 --- /dev/null +++ b/src/wago_sim/trace.py @@ -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) \ No newline at end of file diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..3e7865d --- /dev/null +++ b/tests/test_cli.py @@ -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 \ No newline at end of file diff --git a/tests/test_trace.py b/tests/test_trace.py new file mode 100644 index 0000000..82a8de2 --- /dev/null +++ b/tests/test_trace.py @@ -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) \ No newline at end of file