Files
wago-modbustcp-simulator-py/src/wago_sim/cli.py
T
2026-10-03 23:48:16 +02:00

364 lines
13 KiB
Python

"""Command-line interface: run a node configuration and a scenario.
Examples::
wago-sim serve --node config/pump_node.yaml --port 502
wago-sim run --node config/pump_node.yaml --scenario scenarios/pump_station_fat.yaml
wago-sim validate --scenario scenarios/pump_station_fat.yaml
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
import argparse
import asyncio
import inspect
import logging
import os
import signal
import sys
from pathlib import Path
import yaml
from .app import WebUIConfig, build_app
from .modules import ChannelMismatch, ModuleUnknown
from .scenario.dsl import load_node_modules, load_yaml, validate_scenario
from .scenario.engine import ScenarioEngine, ScenarioError
from .scenario.report import result_summary, write_csv, write_jsonl
log = logging.getLogger("wago_sim.cli")
async def start_webui(app, cfg: WebUIConfig):
"""Start the web UI's Uvicorn server on the running event loop.
Runs on the same loop as the Modbus TCP server (spec §10.3) so UI
writes and Modbus requests never race on the datastore. The socket
is bound eagerly so 'port in use' surfaces as a clean CLI error.
"""
import uvicorn
from .webui.app import build_webui
fastapi_app = build_webui(
app, user=cfg.user, password=cfg.password, readonly=cfg.readonly
)
# Bind eagerly: a taken port raises OSError here (clean CLI error)
# instead of failing asynchronously inside the uvicorn task.
try:
prebound = await asyncio.get_running_loop().create_server(
lambda: None, "0.0.0.0", cfg.port
)
except OSError as exc:
_fail(f"cannot bind web UI port {cfg.port}: {exc.strerror or exc}")
prebound.close()
await prebound.wait_closed()
config = uvicorn.Config(
fastapi_app, host="0.0.0.0", port=cfg.port, log_level="warning"
)
server = uvicorn.Server(config)
# serve() blocks; run it as a task so _serve keeps its stop-event wait
task = asyncio.create_task(server.serve())
# poll until the server reports started (or fails) so startup errors
# surface here as a clean CLI error
for _ in range(100): # up to ~2 s
if server.started or task.done():
break
await asyncio.sleep(0.02)
if not server.started:
task.cancel()
_fail(f"web UI failed to start on port {cfg.port}")
server._serve_task = task # keep a ref for graceful shutdown
return server
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_file(args.node, "node config")
modules = load_node_modules(node_config)
web_cfg = None
if args.web:
web_cfg = WebUIConfig(
enabled=True,
port=args.web_port,
user=_env("WAGO_SIM_WEB_USER", "admin"),
password=_env("WAGO_SIM_WEB_PASSWORD", "simu"),
readonly=_env_flag("WAGO_SIM_WEB_READONLY"),
)
app = build_app(modules=modules, host=args.host, port=args.port, web=web_cfg,
node_config_path=args.node)
await app.server.start()
web_server = None
if web_cfg is not None:
web_server = await start_webui(app, web_cfg)
print(f"Web UI on http://{'127.0.0.1' if args.host == '0.0.0.0' else args.host}"
f":{web_cfg.port} (user: {web_cfg.user})")
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 stop.wait()
except asyncio.CancelledError:
pass # SIGINT via asyncio.run's handler
finally:
if web_server is not None:
task = getattr(web_server, "_serve_task", None)
web_server.should_exit = True
if task is not None:
try:
await asyncio.wait_for(task, timeout=5.0)
except (asyncio.TimeoutError, asyncio.CancelledError):
task.cancel()
await app.server.stop()
return 0
def _run_scenario(args: argparse.Namespace) -> int:
node_config = _load_yaml_file(args.node, "node config")
modules = load_node_modules(node_config)
scenario = _load_yaml_file(args.scenario, "scenario")
problems = validate_scenario(scenario)
if problems:
print("Scenario validation problems:", file=sys.stderr)
for p in problems:
print(f" - {p}", file=sys.stderr)
return 2
# validate report destinations BEFORE running: a bad path must not
# lose the verdict of an hours-long scenario
report_jsonl = (_prepare_report_path(args.report_jsonl, "--report-jsonl")
if args.report_jsonl else None)
report_csv = (_prepare_report_path(args.report_csv, "--report-csv")
if args.report_csv else None)
app = build_app(modules=modules)
try:
engine = ScenarioEngine(app.node, scenario)
except (ScenarioError, ChannelMismatch, IndexError, ValueError) as exc:
_fail(f"scenario {args.scenario}: {exc}")
engine.start()
result = None
max_ticks = 2_000_000
ticks = 0
while result is None and ticks < max_ticks:
result = engine.pump()
ticks += 1
if result is None:
print("Scenario did not terminate (tick limit reached)", file=sys.stderr)
return 3
print(result_summary(result))
if 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_file(args.scenario, "scenario")
problems = validate_scenario(scenario)
if problems:
print("Scenario validation problems:")
for p in problems:
print(f" - {p}")
return 2
print("OK")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="wago-sim",
description="WAGO 750-362 Modbus TCP simulator for PLC FAT testing",
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
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", 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.add_argument(
"--web", action=argparse.BooleanOptionalAction,
default=not _env_flag("WAGO_SIM_NO_WEB"),
help="serve the web UI alongside Modbus (default: on; --no-web disables; "
"env WAGO_SIM_NO_WEB=1)",
)
serve.add_argument(
"--web-port", type=_port_type, default=_env_port("WAGO_SIM_WEB_PORT", 8080),
help="web UI port (or env: WAGO_SIM_WEB_PORT; default 8080)",
)
serve.set_defaults(func=_serve)
runp = sub.add_parser("run", help="run a scenario headless (no server)")
runp.add_argument("--node", required=True, help="node config YAML")
runp.add_argument("--scenario", required=True, help="scenario YAML")
runp.add_argument("--report-jsonl", default=None)
runp.add_argument("--report-csv", default=None)
runp.set_defaults(func=_run_scenario)
val = sub.add_parser("validate", help="validate a scenario file")
val.add_argument("--scenario", required=True)
val.set_defaults(func=_validate)
args = parser.parse_args(argv)
_build_logger(args.verbose)
# `--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__":
raise SystemExit(main())