368 lines
13 KiB
Python
368 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_channel_names, load_channel_ranges,
|
|
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)
|
|
channel_names = load_channel_names(node_config)
|
|
channel_ranges = load_channel_ranges(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, channel_names=channel_names,
|
|
channel_ranges=channel_ranges)
|
|
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()) |