"""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())