🚧 Add environment maneuverability
This commit is contained in:
+14
-3
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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> ${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() {
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ---------- */
|
||||
|
||||
|
||||
Reference in New Issue
Block a user