From 647c209e89adc6be758627cc037da5775aa71090 Mon Sep 17 00:00:00 2001 From: Jonas Bengtsson Date: Sun, 4 Oct 2026 22:48:10 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20Add=20environment=20maneuverabil?= =?UTF-8?q?ity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/tasks.json | 101 +++++++++++++++ README.md | 20 +++ docs/spec/software-spec.md | 65 +++++++++- envs/pump_station_env.py | 4 +- envs/test_done_env.py | 28 ++++ src/wago_sim/app.py | 17 ++- src/wago_sim/cli.py | 45 ++----- src/wago_sim/envrunner.py | 184 ++++++++++++++++++++++++++ src/wago_sim/scenario/plantenv.py | 24 +++- src/wago_sim/webui/api.py | 111 ++++++++++++++++ src/wago_sim/webui/static/app.js | 82 ++++++++++++ src/wago_sim/webui/static/index.html | 10 +- src/wago_sim/webui/static/login.html | 2 +- src/wago_sim/webui/static/style.css | 18 +++ tests/test_webui.py | 185 +++++++++++++++++++++++++++ 15 files changed, 850 insertions(+), 46 deletions(-) create mode 100644 envs/test_done_env.py create mode 100644 src/wago_sim/envrunner.py diff --git a/.vscode/tasks.json b/.vscode/tasks.json index dca2e25..4cfaf4b 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1792,6 +1792,107 @@ "--no-header" ], "isBackground": false + }, + { + "label": "wago-sim: pytest envctl", + "type": "shell", + "command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe", + "args": [ + "-m", + "pytest", + "tests/test_webui.py", + "-q", + "--no-header" + ], + "isBackground": false + }, + { + "label": "wago-sim: pytest envctl2", + "type": "shell", + "command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe", + "args": [ + "-m", + "pytest", + "tests/test_webui.py", + "-q", + "--no-header" + ], + "isBackground": false + }, + { + "label": "wago-sim: pytest envctl3", + "type": "shell", + "command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe", + "args": [ + "-m", + "pytest", + "tests/test_webui.py", + "-q", + "--no-header" + ], + "isBackground": false + }, + { + "label": "wago-sim: pytest full m7", + "type": "shell", + "command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe", + "args": [ + "-m", + "pytest", + "-q", + "--no-header" + ], + "isBackground": false + }, + { + "label": "wago-sim: serve m7 live", + "type": "shell", + "command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\wago-sim.exe", + "args": [ + "serve", + "--node", + "config/pump_node.yaml", + "--host", + "127.0.0.1", + "--port", + "5020", + "--web-port", + "8080", + "--env", + "envs.pump_station_env:PumpStationEnv" + ], + "isBackground": true + }, + { + "label": "wago-sim: serve m7 live2", + "type": "shell", + "command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\wago-sim.exe", + "args": [ + "serve", + "--node", + "config/pump_node.yaml", + "--host", + "127.0.0.1", + "--port", + "5020", + "--web-port", + "8080", + "--env", + "envs.pump_station_env:PumpStationEnv" + ], + "isBackground": true + }, + { + "label": "wago-sim: pytest full m7 final", + "type": "shell", + "command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe", + "args": [ + "-m", + "pytest", + "-q", + "--no-header" + ], + "isBackground": false } ] } \ No newline at end of file diff --git a/README.md b/README.md index 3707947..52722c3 100644 --- a/README.md +++ b/README.md @@ -411,6 +411,26 @@ requirement ID for each item in parentheses. config export stay lean (non-default entries only). - [ ] M6: scenario run view + alias-ownership rejection (FR-10.2.6, FR-10.2.9) — deferred. +- [x] M7: plant environment control (FR-10.2.15, FR-10.2.17) — the + web UI loads/unloads environments at runtime (`--env` becomes the + startup default, not the only option): an environment card shows + ref, state (running/paused/done/none), sim-time and tick ms with + start (with hot reload of edited source, `sys.modules` + invalidation), stop (ticker cancelled, field freezes), + pause/resume (instance kept — pause is a deliberate FAT fault: + "plant stuck"), reset (deterministic fresh episode) and restart. + Exactly one env at a time; `is_done()` ends the ticker (state + `done`); SAVE CONFIG (module-list rebuild) discards the env like + a KBUS reset discards values; starting while loaded is refused; + readonly mode blocks every env mutation. API: `GET /api/env` + + `POST /api/env/{start,stop,pause,resume,reset,restart}`. +- [ ] M8: environment file authoring (FR-10.2.16, planned) — + create/modify/delete environment source files (e.g. + `envs/*.py`) through the web UI (editor pane + upload/download). + Security note: UI-written Python is RCE by design — same trust + boundary as the bench network; disabled in readonly mode; delete + refused while the env is loaded; the on-disk directory must be + explicit in configuration. - [x] Tests: 46 web-UI tests (`tests/test_webui.py`) via httpx `ASGITransport` (in-process, no socket) + live verification: browser SAVE CONFIG rebuilds the node and persists YAML; Modbus diff --git a/docs/spec/software-spec.md b/docs/spec/software-spec.md index fbd03c7..ed0c74e 100644 --- a/docs/spec/software-spec.md +++ b/docs/spec/software-spec.md @@ -620,14 +620,17 @@ unchanged whether or not CSV streaming is used. to create and call — a subclass of `PlantEnv` overriding `model()` (and optionally `is_done()`) in any importable module (`--env myenvs.pump_station.PumpStationEnv`), or a module-level - factory function. Two runners: (a) `wago-sim serve --env ...` ticks + factory function. Three runners: (a) `wago-sim serve --env ...` ticks the environment on a **wall-clock-paced** periodic task (10 ms tick alongside the Modbus server, real-time behavior for a live PLC) and runs **until manually stopped** (SIGINT/SIGTERM — the existing - graceful-shutdown path); (b) `wago-sim sim --node ... --env ...` - runs headless unpaced (as fast as determinism allows) until - `is_done()` or SIGINT. The scenario-continuous mode (FR-8.2.1-13) - remains a scripted layer on top of the same engine ticks. + graceful-shutdown path); the ticker itself is owned by an + `EnvRunner` on the app so the web UI can stop/pause/resume/reset/ + restart it at runtime (FR-10.2.15); (b) `wago-sim sim --node ... + --env ...` runs headless unpaced (as fast as determinism allows) + until `is_done()` or SIGINT. The scenario-continuous mode + (FR-8.2.1-13) remains a scripted layer on top of the same engine + ticks. ### 8.3 Requirements — degradation models (plant logic over time) @@ -1069,6 +1072,44 @@ Terminal monitors (§9) suffice for single engineers, but FAT benches are default-fallback guesswork. Persistence (node YAML, config export) stays **lean**: only explicit non-default entries are written, so defaults survive a future DEFAULT_RANGE change. +- FR-10.2.15: **Plant environment control** (requested 2026-10-04): + the web UI can load/unload environments at runtime — the CLI's + `--env` becomes the startup default, not the only option. An + **environment card** shows the loaded ref, its state (running / + paused / done / none), sim-time, and tick ms; controls: **start** + (load + tick), **stop** (cancel the ticker, field freezes), + **pause/resume** (ticker cancelled or rescheduled, instance + kept — pause is a deliberate FAT fault: "plant stuck"), + **reset** (`env.reset()`, deterministic fresh episode, + NFR-8.7.3), **restart** (stop + start of the same ref, fresh + instance). Rules: exactly one environment at a time (one decision + source, FR-8.2.15); `is_done()` ends the ticker (state `done`, no + auto-restart); a module-list rebuild (SAVE CONFIG, FR-11.2.1) + **discards** the env — its bindings (channel bindings in the + model, sim state) belong to the old layout, exactly like values + are lost on a KBUS reset; starting a new env is refused while + one is loaded (stop first — explicit, no silent replacement); + readonly mode (FR-10.2.8) disables every mutating control. +- FR-10.2.16: **Environment file authoring** (requested 2026-10-04, + planned): create/modify/delete environment source files (e.g. + `envs/*.py`) through the web UI — an editor pane with the plain + PlantEnv subclass pattern (FR-8.2.17), plus upload/download. + Security note: the UI writing importable Python is remote code + execution **by design**; it requires the same trust boundary as + the bench network (auth, FR-10.2.8) and SHALL be disabled in + readonly mode; delete is refused while the env is loaded; the + on-disk directory must be explicit in configuration (today + `envs/` resolves from the working directory, not the installed + package). Status: planned, roadmap phase 5. +- FR-10.2.17: **Hot reload of environment code** (requested + 2026-10-04, implemented same day as part of FR-10.2.15): Python + caches modules in `sys.modules`; a restart of an env whose + source changed on disk would silently reuse the old class. + `load_env(ref, reload=True)` — used by the UI's start/restart — + invalidates the cached module before import so a fresh instance + always picks up the edited code. The CLI's startup `--env` and + the headless `sim` runner do not reload (the process is new + anyway). ### 10.3 Implementation decisions (binding, 2026-10-03) @@ -1131,6 +1172,10 @@ src/wago_sim/ | `GET/PUT /api/config/modules` | module list CRUD + process-image rebuild | §11 FR-11.2.1–11.2.4 | | `GET /api/config/export` · `POST /api/config/import` | full configuration (modules + channel names + coupler) as JSON | 10.2.12 | | `GET/PUT /api/config/channel-names` | read/write per-channel names | 10.2.11 | +| `GET/PUT /api/config/channel-ranges` | read/write analog display ranges | 10.2.14 | +| `GET /api/env` | environment status: ref, state, sim-time, tick ms | 10.2.15 | +| `POST /api/env/start` | load + start an env by ref (refuses while one is loaded) | 10.2.15 | +| `POST /api/env/stop` · `pause` · `resume` · `reset` · `restart` | ticker lifecycle; reset = deterministic fresh episode | 10.2.15 | ### 10.6 Milestones @@ -1142,7 +1187,10 @@ src/wago_sim/ | M4 | Diagnostics — watchdog/statistics/diagnosis view, reset, fault injection | | M5 | Configuration — coupler forms; module CRUD with image rebuild and persistence | | M5a | Channel names (FR-10.2.11) + configuration import/export (FR-10.2.12) + split overview with channel list (FR-10.2.13) | +| M5b | Analog display ranges (FR-10.2.14) | | M6 | (deferred) Scenario run view (FR-10.2.6) + alias-ownership rejection (FR-10.2.9) | +| M7 | Plant environment control (FR-10.2.15 + FR-10.2.17): runtime load/stop/pause/resume/reset/restart + hot reload | +| M8 | (planned) Environment file authoring (FR-10.2.16) | ### 10.7 Acceptance criteria @@ -1158,6 +1206,13 @@ src/wago_sim/ with a visible message; the scenario continues unaffected. 6. Two simultaneous browsers stay consistent with each other and with the Modbus image. +7. **Environment control** (FR-10.2.15): an env started from the UI ticks + paced (its field changes visible to a Modbus master and the overview); + pause freezes the field; reset returns to the initial state; a + module-list SAVE CONFIG discards the loaded env (status none); an + edit to the env source on disk + restart runs the new code (hot + reload); starting while loaded is refused; readonly blocks all + env mutations. --- diff --git a/envs/pump_station_env.py b/envs/pump_station_env.py index 8b86b42..e0ef66b 100644 --- a/envs/pump_station_env.py +++ b/envs/pump_station_env.py @@ -39,7 +39,7 @@ LEVEL_HIGH = 2.7 # m — high-level switch threshold class PumpStationEnv(PlantEnv): """Closed-loop tank + pump physics over the pump_node layout.""" - TICK_MS = 9.0 # overrides PlantEnv TICK_MS + TICK_MS = 10.0 # overrides PlantEnv TICK_MS def __init__(self, *args, stop_after_ms: float | None = None, **kw): super().__init__(*args, **kw) @@ -49,7 +49,7 @@ class PumpStationEnv(PlantEnv): def model(self, state: PlantState, dt_s: float) -> dict[str, object]: dt_h = dt_s / 3600.0 # what the PLC currently commands (master side: DO/AO) - pump_running = bool(state.by_code("DO0.0")) + pump_running = bool(state.by_code("DI0.0")) # speed setpoint from AO0.0, normalized over its display range ao_lo, ao_hi = state.channel_range("AO0.0") diff --git a/envs/test_done_env.py b/envs/test_done_env.py new file mode 100644 index 0000000..0bc2390 --- /dev/null +++ b/envs/test_done_env.py @@ -0,0 +1,28 @@ +"""Test-only environment: is_done() after 30 ms of sim time. + +Used by the web-UI env-control tests (FR-10.2.15): the paced ticker +must notice is_done(), set state "done" (no auto-restart), and keep +the instance for inspection. +""" + +from __future__ import annotations + +from wago_sim.scenario.plantenv import PlantEnv, PlantState + + +class PumpStationEnv(PlantEnv): + """Minimal env: fills AI0.0, done after 30 ms.""" + + def __init__(self, *args, stop_after_ms: float | None = 30.0, **kw): + super().__init__(*args, **kw) + self.stop_after_ms = stop_after_ms + + def model(self, state: PlantState, dt_s: float) -> dict[str, object]: + level = float(state.by_code("AI0.0")) + return {"AI0.0": level + 1.0} + + def is_done(self, state: PlantState) -> bool: + return state.sim_time_ms >= (self.stop_after_ms or 0.0) + + +Env = PumpStationEnv \ No newline at end of file diff --git a/src/wago_sim/app.py b/src/wago_sim/app.py index 6d432a7..972d115 100644 --- a/src/wago_sim/app.py +++ b/src/wago_sim/app.py @@ -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, - ) \ No newline at end of file + ) + app.runner = EnvRunner(node) + return app \ No newline at end of file diff --git a/src/wago_sim/cli.py b/src/wago_sim/cli.py index da21b72..d25e715 100644 --- a/src/wago_sim/cli.py +++ b/src/wago_sim/cli.py @@ -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 diff --git a/src/wago_sim/envrunner.py b/src/wago_sim/envrunner.py new file mode 100644 index 0000000..2fd7313 --- /dev/null +++ b/src/wago_sim/envrunner.py @@ -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 \ No newline at end of file diff --git a/src/wago_sim/scenario/plantenv.py b/src/wago_sim/scenario/plantenv.py index 8e42850..b10712b 100644 --- a/src/wago_sim/scenario/plantenv.py +++ b/src/wago_sim/scenario/plantenv.py @@ -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) diff --git a/src/wago_sim/webui/api.py b/src/wago_sim/webui/api.py index 19b61d9..94b86e0 100644 --- a/src/wago_sim/webui/api.py +++ b/src/wago_sim/webui/api.py @@ -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: diff --git a/src/wago_sim/webui/static/app.js b/src/wago_sim/webui/static/app.js index 631c043..5f8bc46 100644 --- a/src/wago_sim/webui/static/app.js +++ b/src/wago_sim/webui/static/app.js @@ -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 = `${state}`; + 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 = ` +

No environment loaded.

+ ${READONLY ? "" : ` +
+ + +
`}`; + } else { + const canPause = state === "running"; + const canResume = state === "paused"; + const canReset = !READONLY && (state === "running" || state === "paused"); + body.innerHTML = ` +

${env.ref}  ${chip}

+

sim-time ${time} · tick ${tick} + ${env.reason ? ` · done: ${env.reason}` : ""}

+ ${READONLY ? "" : ` +
+ + + + + +
`}`; + } + 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() { diff --git a/src/wago_sim/webui/static/index.html b/src/wago_sim/webui/static/index.html index f41e3b0..3cd2153 100644 --- a/src/wago_sim/webui/static/index.html +++ b/src/wago_sim/webui/static/index.html @@ -4,7 +4,7 @@ WAGO 750-362 Simulator - +
@@ -33,6 +33,12 @@
+
+

Plant environment

+
loading…
+ +
+

Channels

I/ONameValue @@ -111,6 +117,6 @@ - + \ No newline at end of file diff --git a/src/wago_sim/webui/static/login.html b/src/wago_sim/webui/static/login.html index 1a067f3..0602aa1 100644 --- a/src/wago_sim/webui/static/login.html +++ b/src/wago_sim/webui/static/login.html @@ -4,7 +4,7 @@ WAGO Simulator — Login - +