🚧 Web UI

This commit is contained in:
Jonas Bengtsson
2026-10-03 23:48:16 +02:00
parent 43c4c287a0
commit 87c1c8db7b
22 changed files with 2558 additions and 15 deletions
+78 -2
View File
@@ -38,7 +38,7 @@ from pathlib import Path
import yaml
from .app import build_app
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
@@ -47,6 +47,49 @@ 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(
@@ -137,8 +180,23 @@ def _prepare_report_path(path: str, option: str) -> Path:
async def _serve(args: argparse.Namespace) -> int:
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)
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)")
@@ -157,6 +215,14 @@ async def _serve(args: argparse.Namespace) -> int:
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
@@ -242,6 +308,16 @@ def main(argv: list[str] | None = None) -> int:
"--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)")