🚧 Add environment maneuverability

This commit is contained in:
Jonas Bengtsson
2026-10-04 22:48:10 +02:00
parent 68d3c42d79
commit 647c209e89
15 changed files with 850 additions and 46 deletions
+14 -3
View File
@@ -3,9 +3,10 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from dataclasses import dataclass, field
from .datastore import WagoDatastore
from .envrunner import EnvRunner
from .modules import ModuleType, VirtualNode
from .registers import ConfigRegisterBank
from .server import WagoSimServer
@@ -36,6 +37,8 @@ class SimulatorApp:
server: WagoSimServer
web: WebUIConfig | None = None
node_config_path: str | None = None # where the module list was loaded from
runner: EnvRunner = field(init=False) # env lifecycle owner (FR-10.2.15);
# holds the loaded env + its ref
@property
def modules(self) -> list[ModuleType]:
@@ -60,9 +63,15 @@ def rebuild_node(app: SimulatorApp, modules: list[ModuleType],
datastore = app.datastore
names = channel_names if channel_names is not None else app.node.channel_names
ranges = channel_ranges if channel_ranges is not None else app.node.channel_ranges
# a loaded env binds to the OLD layout (its model() reads channels by
# code); the rebuild renumbers everything — discard it like the values
# are lost on a KBUS reset (FR-10.2.15 discard rule)
app.runner.dispose()
datastore.reset_images()
app.node = VirtualNode(datastore, modules,
channel_names=names, channel_ranges=ranges)
# re-point the runner at the new layout so the next start binds to it
app.runner = EnvRunner(app.node)
app.registers.set_process_image_bits(
analog_in=app.node.analog_in_bits,
analog_out=app.node.analog_out_bits,
@@ -145,7 +154,7 @@ def build_app(
idle_timeout_provider=lambda: registers.idle_timeout_ms,
)
server_ref: dict[str, WagoSimServer] = {"server": server}
return SimulatorApp(
app = SimulatorApp(
datastore=datastore,
node=node,
stats=stats,
@@ -154,4 +163,6 @@ def build_app(
server=server,
web=web,
node_config_path=node_config_path,
)
)
app.runner = EnvRunner(node)
return app
+13 -32
View File
@@ -196,12 +196,14 @@ async def _serve(args: argparse.Namespace) -> int:
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)
# optional plant environment (FR-8.2.17): ticked wall-clock-paced
# alongside the Modbus server, runs until manually stopped
env = None
# startup default (FR-10.2.15): load BEFORE the servers start so a
# bad --env fails cleanly with nothing half-started; the UI can
# stop/restart it at runtime
if getattr(args, "env", None):
from .scenario.plantenv import load_env
env = load_env(args.env, node=app.node)
try:
app.runner.start(args.env)
except ScenarioError as exc:
_fail(f"--env {args.env}: {exc}")
await app.server.start()
web_server = None
if web_cfg is not None:
@@ -210,27 +212,10 @@ async def _serve(args: argparse.Namespace) -> int:
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)")
env_task = None
if env is not None:
async def run_env():
"""Paced ticker (FR-8.2.17): wall-clock-aligned ticks for a
live PLC; runs until cancelled by the shutdown path."""
import time as _time
period = env.TICK_MS / 1000.0
next_at = _time.monotonic()
while True:
env.tick()
if env.is_done(env.observe()):
log.info("environment is_done() satisfied; env stopped")
return
next_at += period
delay = next_at - _time.monotonic()
if delay > 0:
await asyncio.sleep(delay)
else:
next_at = _time.monotonic() # catch up, no drift burst
env_task = asyncio.create_task(run_env())
print(f"Plant environment: {args.env} (10 ms paced tick, Ctrl+C to stop)")
if app.runner.env is not None:
env = app.runner.env
print(f"Plant environment: {app.runner.env_ref} "
f"({env.TICK_MS:.0f} ms paced tick, Ctrl+C to stop)")
# Graceful shutdown on SIGINT/SIGTERM (docker stop, systemctl stop).
# asyncio.run installs a SIGINT handler; SIGTERM arrives as a raw
@@ -247,12 +232,8 @@ async def _serve(args: argparse.Namespace) -> int:
except asyncio.CancelledError:
pass # SIGINT via asyncio.run's handler
finally:
if env_task is not None:
env_task.cancel()
try:
await env_task
except asyncio.CancelledError:
pass
if app.runner.env is not None:
await app.runner.stop()
if web_server is not None:
task = getattr(web_server, "_serve_task", None)
web_server.should_exit = True
+184
View File
@@ -0,0 +1,184 @@
"""EnvRunner: web-UI-controllable ticker for a plant environment.
Owns the wall-clock-paced tick task that used to live as a closure in
the CLI's ``_serve`` (FR-8.2.17 runner (a)). Moved here so the web UI
can control the lifecycle at runtime (spec FR-10.2.15):
start(ref) load (+ hot reload, FR-10.2.17) + tick
stop() cancel the ticker; the field freezes
pause() cancel the ticker, keep the instance
resume() reschedule the ticker
reset() env.reset() — deterministic fresh episode
restart() stop + start of the same ref, fresh instance
State machine: none -> running <-> paused -> done; stop/discard -> none.
Exactly one env at a time (one decision source, FR-8.2.15): start is
refused while one is loaded.
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import TYPE_CHECKING
from .scenario.engine import ScenarioError
from .scenario.plantenv import PlantEnv, load_env
if TYPE_CHECKING:
from .modules import VirtualNode
log = logging.getLogger(__name__)
class EnvRunner:
"""Paced ticker + lifecycle for one PlantEnv on a virtual node."""
def __init__(self, node: VirtualNode) -> None:
self.node = node
self.env: PlantEnv | None = None
self.env_ref: str | None = None
self._task: asyncio.Task | None = None
self._state: str = "none" # none | running | paused | done
self._done_reason: str | None = None
# -- introspection ---------------------------------------------------------
@property
def state(self) -> str:
return self._state
def status(self) -> dict[str, object]:
"""Status snapshot for the web UI (FR-10.2.15)."""
return {
"ref": self.env_ref,
"state": self._state,
"reason": self._done_reason,
"sim_time_ms": self.env.sim_time_ms if self.env else None,
"tick_ms": self.env.TICK_MS if self.env else None,
}
# -- lifecycle --------------------------------------------------------------
def start(self, ref: str) -> None:
"""Load + start an environment (refused while one is loaded).
``reload=True`` passes through to ``load_env`` (FR-10.2.17):
a cached module is invalidated first, so starting after an
edit of the source file runs the NEW code.
"""
if self.env is not None:
raise ScenarioError(
"an environment is already loaded "
f"({self.env_ref!r}); stop it first")
env = load_env(ref, node=self.node, reload=True)
self.env = env
self.env_ref = ref
self._state = "running"
self._done_reason = None
self._task = asyncio.create_task(self._run())
log.info("env started: %s", ref)
async def stop(self) -> None:
"""Cancel the ticker and drop the env (field freezes)."""
await self._cancel_task()
self.env = None
self.env_ref = None
self._state = "none"
self._done_reason = None
log.info("env stopped")
async def pause(self) -> None:
"""Cancel the ticker, keep the instance (plant stuck fault)."""
if self.env is None or self._state not in ("running", "done"):
raise ScenarioError(f"cannot pause: state is {self._state!r}")
if self._state == "done":
raise ScenarioError("environment already done; restart it")
await self._cancel_task()
self._state = "paused"
log.info("env paused (sim %.0f ms)", self.env.sim_time_ms)
async def resume(self) -> None:
"""Reschedule the ticker after a pause."""
if self.env is None or self._state != "paused":
raise ScenarioError(f"cannot resume: state is {self._state!r}")
self._state = "running"
self._task = asyncio.create_task(self._run())
log.info("env resumed (sim %.0f ms)", self.env.sim_time_ms)
def reset(self) -> None:
"""Deterministic fresh episode (NFR-8.7.3); ticker keeps running."""
if self.env is None:
raise ScenarioError("no environment loaded")
self.env.reset()
self._done_reason = None
if self._state == "done":
# the criterion may hold again on the fresh episode — but
# is_done() is re-checked every tick anyway, so just resume
self._state = "running"
self._task = asyncio.create_task(self._run())
log.info("env reset")
async def restart(self) -> None:
"""Fresh instance of the same ref (picks up edited source)."""
if self.env_ref is None:
raise ScenarioError("no environment to restart")
ref = self.env_ref
await self.stop()
self.start(ref)
async def discard(self) -> None:
"""Silent unload (module-list rebuild, shutdown path)."""
await self._cancel_task()
self.env = None
self.env_ref = None
self._state = "none"
self._done_reason = None
def dispose(self) -> None:
"""Synchronous unload without awaiting (rebuild path).
The API handlers ``await discard()`` BEFORE rebuilding; this
is the belt-and-braces guarantee for sync callers
(``rebuild_node``) that the ticker can never tick the new
image: ``cancel()`` takes effect before the loop resumes, and
a fully synchronous function yields no loop slots.
"""
if self._task is not None:
self._task.cancel()
self._task = None
self.env = None
self.env_ref = None
self._state = "none"
self._done_reason = None
# -- ticker -------------------------------------------------------------------
async def _run(self) -> None:
"""Monotonic-scheduled paced ticker (no drift), as in _serve."""
assert self.env is not None
period = self.env.TICK_MS / 1000.0
next_at = time.monotonic()
while True:
self.env.tick()
if self.env.is_done(self.env.observe()):
self._state = "done"
self._done_reason = "is_done()"
log.info("env is_done() satisfied; ticker stopped")
return
next_at += period
delay = next_at - time.monotonic()
if delay > 0:
await asyncio.sleep(delay)
else:
next_at = time.monotonic() # catch up, no drift burst
async def _cancel_task(self) -> None:
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
+23 -1
View File
@@ -23,6 +23,7 @@ constructed on an engine.
from __future__ import annotations
import os
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
@@ -312,14 +313,29 @@ class PlantEnv:
def load_env(ref: str, node: VirtualNode | None = None, *,
engine=None) -> PlantEnv:
engine=None, reload: bool = False) -> PlantEnv:
"""Load an environment from a module reference (FR-8.2.17).
Accepts ``pkg.mod.ClassName``, ``pkg.mod.factory_fn`` (returns a
PlantEnv), or a bare ``pkg.mod`` whose module defines either a
class named ``Env`` or a function ``make_env(...)``.
``reload=True`` (FR-10.2.17) invalidates the cached module first:
Python keeps modules in ``sys.modules``, so re-importing an
edited file would silently reuse the old class. Used by the web
UI's start/restart; the CLI runners start a fresh process anyway
and pass the default.
"""
import importlib
import sys
# environments resolve from the WORKING DIRECTORY by contract
# (``envs/`` is not part of the installed package). A console
# script puts its Scripts dir — not the cwd — on sys.path, so
# make the cwd importable before trying (idempotent).
cwd = os.getcwd()
if cwd not in sys.path:
sys.path.insert(0, cwd)
parts = ref.split(":")
attr_path = None
@@ -341,6 +357,12 @@ def load_env(ref: str, node: VirtualNode | None = None, *,
and leaf is None):
raise ScenarioError(f"--env {ref!r}: reference must include a module path")
if reload and mod_name in sys.modules:
# hot reload (FR-10.2.17): drop the cached module so the
# import below reads the file from disk again
sys.modules.pop(mod_name, None)
mod = None
if mod is None:
try:
mod = importlib.import_module(mod_name)
+111
View File
@@ -578,6 +578,114 @@ async def put_channel_ranges(
}
# -- plant environment control (FR-10.2.15 / FR-10.2.17) -----------------------
@router.get("/api/env")
async def get_env(
sim: SimulatorApp = Depends(get_sim),
_: None = Depends(require_session),
) -> dict:
"""Environment status: ref, state, sim-time, tick ms."""
return sim.runner.status()
@router.post("/api/env/start")
async def env_start(
payload: dict,
request: Request,
sim: SimulatorApp = Depends(get_sim),
sessions: SessionStore = Depends(get_sessions),
) -> dict:
"""Load + start an environment by module ref (FR-10.2.15).
Payload: {"ref": "envs.pump_station_env:PumpStationEnv"}.
Refused while an env is loaded (stop first); the module is
hot-reloaded (FR-10.2.17) so edited source runs.
"""
_require_write(request, sessions)
ref = str(payload.get("ref", "")).strip()
if not ref:
raise HTTPException(status_code=400, detail="'ref' is required")
try:
sim.runner.start(ref)
except ScenarioError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from None
return {"ok": True, **sim.runner.status()}
@router.post("/api/env/stop")
async def env_stop(
request: Request,
sim: SimulatorApp = Depends(get_sim),
sessions: SessionStore = Depends(get_sessions),
) -> dict:
"""Cancel the ticker and unload the env (field freezes)."""
_require_write(request, sessions)
await sim.runner.stop()
return {"ok": True, **sim.runner.status()}
@router.post("/api/env/pause")
async def env_pause(
request: Request,
sim: SimulatorApp = Depends(get_sim),
sessions: SessionStore = Depends(get_sessions),
) -> dict:
"""Pause: ticker cancelled, instance kept (plant-stuck fault)."""
_require_write(request, sessions)
try:
await sim.runner.pause()
except ScenarioError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from None
return {"ok": True, **sim.runner.status()}
@router.post("/api/env/resume")
async def env_resume(
request: Request,
sim: SimulatorApp = Depends(get_sim),
sessions: SessionStore = Depends(get_sessions),
) -> dict:
"""Resume a paused env (reschedules the ticker)."""
_require_write(request, sessions)
try:
await sim.runner.resume()
except ScenarioError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from None
return {"ok": True, **sim.runner.status()}
@router.post("/api/env/reset")
async def env_reset(
request: Request,
sim: SimulatorApp = Depends(get_sim),
sessions: SessionStore = Depends(get_sessions),
) -> dict:
"""Deterministic fresh episode (NFR-8.7.3); ticker keeps running."""
_require_write(request, sessions)
try:
sim.runner.reset()
except ScenarioError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from None
return {"ok": True, **sim.runner.status()}
@router.post("/api/env/restart")
async def env_restart(
request: Request,
sim: SimulatorApp = Depends(get_sim),
sessions: SessionStore = Depends(get_sessions),
) -> dict:
"""Fresh instance of the same ref (picks up edited source)."""
_require_write(request, sessions)
try:
await sim.runner.restart()
except ScenarioError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from None
return {"ok": True, **sim.runner.status()}
# -- configuration import/export (FR-10.2.12) ---------------------------------
@@ -679,6 +787,9 @@ async def import_config(
coupler = payload.get("coupler_settings") or {}
# -- everything validated: apply atomically -------------------------
# a loaded env binds to the old layout — discard before rebuilding
# (FR-10.2.15; same rule as SAVE CONFIG)
await sim.runner.discard()
rebuild_node(sim, modules, channel_names=names, channel_ranges=ranges)
stale = sim.node.validate_channel_names()
if stale:
+82
View File
@@ -28,9 +28,90 @@ async function init() {
renderRail();
renderChannelList();
if (diag) renderDiagnostics(diag);
refreshEnvCard();
openWS();
}
// ---------------- plant environment control (FR-10.2.15) ----------------
let ENV = null; // last /api/env status
async function refreshEnvCard() {
ENV = await api("/api/env").catch(() => null);
if (ENV) renderEnvCard(ENV);
}
function renderEnvCard(env) {
const body = document.getElementById("env-body");
if (!body) return;
const state = env.state || "none";
const chip = `<span class="env-state st-${state}">${state}</span>`;
const time = env.sim_time_ms != null
? `${(env.sim_time_ms / 1000).toFixed(1)} s` : "—";
const tick = env.tick_ms != null ? `${env.tick_ms} ms` : "—";
if (state === "none") {
body.innerHTML = `
<p class="cfg-note">No environment loaded.</p>
${READONLY ? "" : `
<div class="fault-buttons">
<input type="text" id="env-ref" class="env-ref"
placeholder="envs.pump_station_env:PumpStationEnv">
<button id="env-start" class="primary">Start</button>
</div>`}`;
} else {
const canPause = state === "running";
const canResume = state === "paused";
const canReset = !READONLY && (state === "running" || state === "paused");
body.innerHTML = `
<p><b>${env.ref}</b> &nbsp;${chip}</p>
<p class="cfg-note">sim-time ${time} · tick ${tick}
${env.reason ? ` · done: ${env.reason}` : ""}</p>
${READONLY ? "" : `
<div class="fault-buttons">
<button id="env-pause" ${canPause ? "" : "disabled"}>Pause</button>
<button id="env-resume" ${canResume ? "" : "disabled"}>Resume</button>
<button id="env-reset" ${canReset ? "" : "disabled"}>Reset</button>
<button id="env-restart">Restart</button>
<button id="env-stop">Stop</button>
</div>`}`;
}
const msg = document.getElementById("env-msg");
msg.classList.add("hidden");
bindEnvButtons();
}
function bindEnvButtons() {
const on = (id, fn) => {
const el = document.getElementById(id);
if (el) el.addEventListener("click", fn);
};
on("env-start", async () => {
const ref = document.getElementById("env-ref").value.trim();
await envAction("start", {ref});
});
on("env-stop", () => envAction("stop"));
on("env-pause", () => envAction("pause"));
on("env-resume", () => envAction("resume"));
on("env-reset", () => envAction("reset"));
on("env-restart", () => envAction("restart"));
}
async function envAction(name, payload) {
const msg = document.getElementById("env-msg");
msg.classList.add("hidden");
try {
await api(`/api/env/${name}`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload || {}),
});
await refreshEnvCard();
} catch (e) {
msg.textContent = e.message;
msg.classList.remove("hidden");
}
}
function openWS() {
const proto = location.protocol === "https:" ? "wss" : "ws";
WS = new WebSocket(`${proto}://${location.host}/api/ws`);
@@ -642,6 +723,7 @@ document.addEventListener("DOMContentLoaded", () => {
}
});
document.getElementById("module-save").addEventListener("click", saveModules);
setInterval(refreshEnvCard, 1000); // env status: 1 Hz
});
function renderModuleRows() {
+8 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<title>WAGO 750-362 Simulator</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/style.css?v=3">
<link rel="stylesheet" href="/static/style.css?v=4">
</head>
<body>
<header class="topbar">
@@ -33,6 +33,12 @@
<div id="rail" class="rail"></div>
<div id="rail-detail" class="rail-detail"></div>
<div class="diag-card env-card" id="env-card">
<h3>Plant environment</h3>
<div id="env-body" class="cfg-note">loading…</div>
<div id="env-msg" class="error hidden"></div>
</div>
<h3 class="list-title">Channels</h3>
<div class="chlist-head">
<span>I/O</span><span>Name</span><span>Value</span>
@@ -111,6 +117,6 @@
</section>
</main>
<script src="/static/app.js?v=3"></script>
<script src="/static/app.js?v=4"></script>
</body>
</html>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<title>WAGO Simulator — Login</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/style.css?v=3">
<link rel="stylesheet" href="/static/style.css?v=4">
</head>
<body class="login-body">
<div class="login-card">
+18
View File
@@ -238,6 +238,24 @@ h2 { margin: 4px 0 14px; }
}
.diag-card button:hover { background: #004a99; }
.fault-buttons { display: flex; gap: 8px; }
.fault-buttons button:disabled { background: #b6bfc9; cursor: not-allowed; }
/* ---------- plant environment card (FR-10.2.15) ---------- */
.env-card { margin-top: 16px; }
.env-card .fault-buttons { margin-top: 10px; flex-wrap: wrap; }
.env-card .fault-buttons button { margin-top: 0; }
.env-ref { flex: 1; padding: 7px 8px; border: 1px solid #c5ccd4; border-radius: 4px;
font-family: Consolas, monospace; font-size: 0.88rem; }
.env-state {
display: inline-block; padding: 2px 10px; border-radius: 10px;
font-size: 0.78rem; font-weight: 600; text-transform: uppercase;
vertical-align: middle;
}
.env-state.st-running { background: #e2f5e6; color: #1d7a33; }
.env-state.st-paused { background: #fdf3d7; color: #8a6d00; }
.env-state.st-done { background: #e8ecf1; color: #56606c; }
.env-state.st-none { background: #e8ecf1; color: #56606c; }
/* ---------- configuration ---------- */