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
+182 -19
View File
@@ -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__":
+10
View File
@@ -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:
+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)