🚧 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
+40
View File
@@ -13,6 +13,17 @@ from .stats import ModbusStatistics
from .watchdog import ModbusWatchdog
@dataclass
class WebUIConfig:
"""Serve-time web UI configuration (spec §10.3)."""
enabled: bool = True
port: int = 8080
user: str = "admin"
password: str = "simu"
readonly: bool = False
@dataclass
class SimulatorApp:
"""Fully wired simulator instance for one virtual node."""
@@ -23,12 +34,37 @@ class SimulatorApp:
watchdog: ModbusWatchdog
registers: ConfigRegisterBank
server: WagoSimServer
web: WebUIConfig | None = None
node_config_path: str | None = None # where the module list was loaded from
@property
def modules(self) -> list[ModuleType]:
return self.node.modules
def rebuild_node(app: SimulatorApp, modules: list[ModuleType]) -> None:
"""Hot-configure the module list: rebuild the process image in place.
Implements the §11 FR-11.2.1–11.2.3 semantics for the web UI: the
datastore instance (and with it all open Modbus connections)
survives; the images are re-sized and re-zeroed, the layout is
recomputed, and the register bank is updated so 0x1022-0x1025
(image sizes) and 0x2030+ (module description) reflect the new
configuration. Values written so far are lost — like pulling the
KBUS and re-configuring on a real node.
"""
datastore = app.datastore
datastore.reset_images()
app.node = VirtualNode(datastore, modules)
app.registers.set_process_image_bits(
analog_in=app.node.analog_in_bits,
analog_out=app.node.analog_out_bits,
digital_in=app.node.digital_in_bits,
digital_out=app.node.digital_out_bits,
)
app.registers.set_module_description(app.node.description())
def build_app(
*,
modules: list[ModuleType],
@@ -36,6 +72,8 @@ def build_app(
port: int = 502,
mac_id: tuple[int, int, int, int, int, int] = (0x00, 0x30, 0xDE, 0x11, 0x22, 0x33),
firmware_description: str = "WAGO 750-362 Simulator",
web: WebUIConfig | None = None,
node_config_path: str | None = None,
) -> SimulatorApp:
"""Assemble all simulator components for a node configuration."""
stats = ModbusStatistics()
@@ -104,4 +142,6 @@ def build_app(
watchdog=watchdog,
registers=registers,
server=server,
web=web,
node_config_path=node_config_path,
)
+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)")
+13
View File
@@ -176,6 +176,19 @@ class WagoDatastore:
self._out_words = [0] * len(self._out_words)
self._notify()
def reset_images(self) -> None:
"""Reconfiguration (§11): zero both images before a layout rebuild.
The arrays keep their full 750-362 geometry (256+764 words per
direction); the VirtualNode re-applies the digital word offsets
and the module layout on top.
"""
self._in_words = [0] * (AREA1_INPUT_WORDS + AREA2_INPUT_WORDS)
self._out_words = [0] * (AREA1_OUTPUT_WORDS + AREA2_OUTPUT_WORDS)
self._digital_in_word_offset = 0
self._digital_out_word_offset = 0
self._notify()
# -- word area dispatch ---------------------------------------------------
def read_words(self, address: int, count: int) -> list[int] | ExceptionResponse:
+8 -2
View File
@@ -49,8 +49,14 @@ REG_FW_MINOR = 0x2014
REG_FW_DESCRIPTION = 0x2020 # up to 16 words
REG_FW_COMPILE_TIME = 0x2021 # up to 8 words
REG_FW_COMPILE_DATE = 0x2022 # up to 8 words
REG_FW_LOADER = 0x2023 # up to 32 words
REG_MODULE_DESC_0_64 = 0x2030 # 65 words
REG_FW_LOADER = 0x2023 # manual says "up to 32 words", but that
# would overlap the module descriptions
# at 0x2030 — an inconsistency in the
# manual itself (Table 168 vs. §11.2.5.7
# register map). The loader effectively
# occupies 0x2023-0x202F (13 words).
REG_FW_LOADER_WORDS = REG_MODULE_DESC_0_64 - REG_FW_LOADER
REG_PROCESS_IMAGE_SETTINGS = 0x2035
REG_DIAGNOSTICS = 0x2036
REG_SOFTWARE_RESET = 0x2040 # write 0x55AA/0xAA55
@@ -211,7 +217,7 @@ class ConfigRegisterBank:
return self._compile_time[address - REG_FW_COMPILE_TIME]
if REG_FW_COMPILE_DATE <= address < REG_FW_COMPILE_DATE + 8:
return self._compile_date[address - REG_FW_COMPILE_DATE]
if REG_FW_LOADER <= address < REG_FW_LOADER + 32:
if REG_FW_LOADER <= address < REG_FW_LOADER + REG_FW_LOADER_WORDS:
return self._loader[address - REG_FW_LOADER]
if REG_MODULE_DESC_0_64 <= address < REG_MODULE_DESC_0_64 + 65:
return self._module_desc[address - REG_MODULE_DESC_0_64]
+2
View File
@@ -95,6 +95,8 @@ class ModbusWatchdog:
return self._last_trigger_value or 0
if address == WD_MIN_TRIGGER_TIME:
return 0xFFFF
if address == WD_STOP_MASK:
return 0 # write-only sequence register (0xAAAA then 0x5555)
if address == WD_STATUS:
status = self._status
if status == WD_STATUS_EXPIRED and self._type is WatchdogType.ALTERNATIVE:
+6
View File
@@ -0,0 +1,6 @@
"""Web UI for the WAGO simulator (spec §10).
In-process FastAPI/Uvicorn app on the same asyncio loop as the Modbus
TCP server. All data access goes through the simulator's internal
memory handlers — never via Modbus TCP.
"""
+432
View File
@@ -0,0 +1,432 @@
"""REST + WebSocket API for the web UI (spec §10.5).
All handlers touch the simulator's internal memory handlers only —
`WagoDatastore`, `VirtualNode`, `ConfigRegisterBank`, `ModbusWatchdog`,
`ModbusStatistics` — never a Modbus TCP socket.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
from ..app import SimulatorApp, rebuild_node
from ..modules import CATALOG, ChannelMismatch, ModuleUnknown, resolve_module
from ..scenario.engine import ScenarioError
from ..watchdog import RegisterWriteRefused
from . import state
from .auth import SESSION_COOKIE, SessionStore
from .api_models import NodeLayoutJSON
from .persist import save_node_config
router = APIRouter()
# -- shared app/session access ------------------------------------------------
def get_sim(request: Request) -> SimulatorApp:
return request.app.state.sim
def get_sessions(request: Request) -> SessionStore:
return request.app.state.sessions
def require_session(request: Request, sessions: SessionStore = Depends(get_sessions)) -> None:
if not sessions.is_valid(request.cookies.get(SESSION_COOKIE)):
raise HTTPException(status_code=401, detail="not logged in")
# -- auth ----------------------------------------------------------------------
@router.post("/api/auth/login")
async def login(
payload: dict, sessions: SessionStore = Depends(get_sessions)
) -> dict:
user = str(payload.get("user", ""))
password = str(payload.get("password", ""))
token = sessions.login(user, password)
if token is None:
raise HTTPException(status_code=401, detail="invalid credentials")
return {"ok": True, "user": sessions.username, "readonly": sessions.readonly}
@router.post("/api/auth/logout", dependencies=[Depends(require_session)])
async def logout(
request: Request, sessions: SessionStore = Depends(get_sessions)
) -> dict:
sessions.logout(request.cookies.get(SESSION_COOKIE, ""))
return {"ok": True}
# -- node layout & values --------------------------------------------------------
@router.get("/api/node", response_model=None)
async def get_node(
sim: SimulatorApp = Depends(get_sim),
_: None = Depends(require_session),
) -> NodeLayoutJSON:
return state.node_layout(sim.node)
@router.get("/api/values")
async def get_values(
sim: SimulatorApp = Depends(get_sim),
_: None = Depends(require_session),
) -> dict:
return {"values": state.channel_values(sim.node)}
@router.get("/api/diagnostics")
async def get_diagnostics(
sim: SimulatorApp = Depends(get_sim),
_: None = Depends(require_session),
) -> dict:
s = sim.stats
wd_type = sim.registers.read_config(0x100A, 1)[0]
return {
"watchdog": {
"status": sim.watchdog.status, # 0/1/2 (0x1006)
"type": "alternative" if wd_type else "standard",
"process_data_blocked": sim.watchdog.process_data_blocked,
},
"statistics": {
"slave_device_failure": s.slave_device_failure,
"bad_protocol": s.bad_protocol,
"bad_length": s.bad_length,
"bad_function": s.bad_function,
"bad_address": s.bad_address,
"bad_data": s.bad_data,
"too_many_registers": s.too_many_registers,
"too_many_bits": s.too_many_bits,
"mod_tcp_message_counter": s.mod_tcp_message_counter,
},
"led_error": {
"code": sim.registers.read_config(0x1020, 1)[0],
"argument": sim.registers.read_config(0x1021, 1)[0],
},
"module_diagnosis": {
"words": sim.registers.read_config(0x1050, 3),
},
}
@router.post("/api/diagnostics/reset-stats")
async def reset_stats(
request: Request,
sim: SimulatorApp = Depends(get_sim),
sessions: SessionStore = Depends(get_sessions),
) -> dict:
if not sessions.is_valid(request.cookies.get(SESSION_COOKIE)):
raise HTTPException(status_code=401, detail="not logged in")
if sessions.readonly:
raise HTTPException(status_code=403, detail="read-only mode")
sim.stats.reset()
return {"ok": True}
@router.post("/api/diagnostics/inject-fault")
async def inject_fault(
payload: dict,
request: Request,
sim: SimulatorApp = Depends(get_sim),
sessions: SessionStore = Depends(get_sessions),
) -> dict:
if not sessions.is_valid(request.cookies.get(SESSION_COOKIE)):
raise HTTPException(status_code=401, detail="not logged in")
if sessions.readonly:
raise HTTPException(status_code=403, detail="read-only mode")
fault = str(payload.get("fault", ""))
if fault == "led":
code = int(payload.get("code", 1)) & 0xFFFF
arg = int(payload.get("argument", 0)) & 0xFFFF
sim.registers.set_led_error(code, arg)
return {"ok": True}
if fault == "module_diagnosis":
module = int(payload.get("module", 0)) & 0xFFFF
channel = int(payload.get("channel", 0)) & 0xFFFF
diagnosis = int(payload.get("diagnosis", 1)) & 0xFFFF
sim.registers.set_module_diagnosis(module, channel, diagnosis)
return {"ok": True}
raise HTTPException(status_code=400, detail=f"unknown fault {fault!r}")
@router.post("/api/diagnostics/clear-fault")
async def clear_fault(
payload: dict,
request: Request,
sim: SimulatorApp = Depends(get_sim),
sessions: SessionStore = Depends(get_sessions),
) -> dict:
if not sessions.is_valid(request.cookies.get(SESSION_COOKIE)):
raise HTTPException(status_code=401, detail="not logged in")
if sessions.readonly:
raise HTTPException(status_code=403, detail="read-only mode")
fault = str(payload.get("fault", ""))
if fault == "led":
sim.registers.set_led_error(0, 0)
return {"ok": True}
if fault == "module_diagnosis":
sim.registers.set_module_diagnosis(0, 0, 0)
return {"ok": True}
raise HTTPException(status_code=400, detail=f"unknown fault {fault!r}")
# -- manipulation (field side) ---------------------------------------------------
@router.post("/api/channels/{kind}/{module}/{channel}")
async def set_channel(
kind: str, module: int, channel: int, payload: dict,
request: Request,
sim: SimulatorApp = Depends(get_sim),
sessions: SessionStore = Depends(get_sessions),
) -> dict:
if not sessions.is_valid(request.cookies.get(SESSION_COOKIE)):
raise HTTPException(status_code=401, detail="not logged in")
if sessions.readonly:
raise HTTPException(status_code=403, detail="read-only mode")
action = str(payload.get("action", "set"))
node = sim.node
try:
if kind == "di":
loc = node.channel_by_module("di", module, channel)
if action == "toggle":
value = not node.datastore.digital_input_channel(loc.bit + 1)
else:
value = _payload_bool(payload)
node.set_di(loc.bit + 1, value)
return {"ok": True, "value": value}
if kind == "do":
# master-side force through the internal Modbus-address
# dispatch: bit writes target the OUTPUT image (Table 122)
loc = node.channel_by_module("do", module, channel)
if action == "toggle":
value = not node.datastore.digital_output_channel(loc.bit + 1)
else:
value = _payload_bool(payload)
result = node.datastore.write_bits(loc.bit, [value])
if isinstance(result, Exception):
raise HTTPException(status_code=400, detail=str(result))
return {"ok": True, "value": value}
if kind == "ai":
loc = node.channel_by_module("ai", module, channel)
value = int(payload.get("value", 0)) & 0xFFFF
node.datastore.set_input_word(loc.word, value)
return {"ok": True, "value": value}
if kind == "ao":
# master-side force: word write at the OUTPUT image (Table 120)
loc = node.channel_by_module("ao", module, channel)
value = int(payload.get("value", 0)) & 0xFFFF
result = node.datastore.write_words(loc.word, [value])
if isinstance(result, Exception):
raise HTTPException(status_code=400, detail=str(result))
return {"ok": True, "value": value}
except IndexError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from None
except ChannelMismatch as exc:
raise HTTPException(status_code=400, detail=str(exc)) from None
raise HTTPException(status_code=400, detail=f"unknown channel kind {kind!r}")
def _payload_bool(payload: dict) -> bool:
value = payload.get("value")
if isinstance(value, bool):
return value
if isinstance(value, int):
return bool(value)
raise HTTPException(status_code=400, detail="value must be a boolean")
# -- WebSocket live values --------------------------------------------------------
# -- configuration (spec §11 subset: coupler + modules) ---------------------------
def _require_write(request: Request, sessions: SessionStore) -> None:
"""Authenticated AND not read-only — for all mutating config routes."""
if not sessions.is_valid(request.cookies.get(SESSION_COOKIE)):
raise HTTPException(status_code=401, detail="not logged in")
if sessions.readonly:
raise HTTPException(status_code=403, detail="read-only mode")
@router.get("/api/config/coupler")
async def get_coupler_config(
sim: SimulatorApp = Depends(get_sim),
_: None = Depends(require_session),
) -> dict:
"""Watchdog/response-delay/idle-timeout/MAC settings via the internal
register bank — never via Modbus TCP."""
r = sim.registers
wd = r.read_config(0x1000, 12) # watchdog block 0x1000-0x100B
mac = [r.read_config(0x1031 + i, 1)[0] for i in range(3)]
return {
"watchdog": {
"time_units": wd[0], # 0x1000, unit 100 ms
"mask_fc_1_16": wd[1], # 0x1001
"mask_fc_17_32": wd[2], # 0x1002
"status": wd[6], # 0x1006 (informational)
"close_on_timeout": wd[9], # 0x1009
"type": wd[10], # 0x100A: 0 standard, 1 alternative
},
"response_delay_ms": r.read_config(0x1037, 1)[0],
"idle_timeout_units": r.read_config(0x1030, 1)[0],
"mac_id": mac,
"image_bits": {
"analog_out": r.read_config(0x1022, 1)[0],
"analog_in": r.read_config(0x1023, 1)[0],
"digital_out": r.read_config(0x1024, 1)[0],
"digital_in": r.read_config(0x1025, 1)[0],
},
}
@router.put("/api/config/coupler")
async def put_coupler_config(
payload: dict,
request: Request,
sim: SimulatorApp = Depends(get_sim),
sessions: SessionStore = Depends(get_sessions),
) -> dict:
"""Write coupler settings through the internal register bank.
Register-write refusal (e.g. changing the watchdog time while it is
running) maps to HTTP 400 with the device's own message — the same
behavior a WBM page shows on a real node.
"""
_require_write(request, sessions)
writes: list[tuple[int, int]] = []
wd = payload.get("watchdog") or {}
if "time_units" in wd:
writes.append((0x1000, int(wd["time_units"])))
if "mask_fc_1_16" in wd:
writes.append((0x1001, int(wd["mask_fc_1_16"])))
if "mask_fc_17_32" in wd:
writes.append((0x1002, int(wd["mask_fc_17_32"])))
if "close_on_timeout" in wd:
writes.append((0x1009, 1 if wd["close_on_timeout"] else 0))
if "type" in wd:
writes.append((0x100A, 1 if wd["type"] in (1, "alternative") else 0))
if "response_delay_ms" in payload:
writes.append((0x1037, int(payload["response_delay_ms"])))
if "idle_timeout_units" in payload:
writes.append((0x1030, int(payload["idle_timeout_units"])))
errors = []
for address, value in writes:
try:
sim.registers.write_config(address, [value])
except RegisterWriteRefused as exc:
errors.append(f"{address:#06x}: {exc}")
if errors:
raise HTTPException(status_code=400, detail="; ".join(errors))
return {"ok": True, "applied": len(writes)}
@router.get("/api/config/modules")
async def get_module_config(
sim: SimulatorApp = Depends(get_sim),
_: None = Depends(require_session),
) -> dict:
"""Current module list + the catalog for the editor."""
return {
"modules": [
{
"index": i,
"article": m.article,
"description": m.description,
"input_bits": m.input_bits,
"output_bits": m.output_bits,
"input_words": m.input_words,
"output_words": m.output_words,
}
for i, m in enumerate(sim.node.modules)
],
"catalog": sorted(
(
{
"article": m.article,
"description": m.description,
"input_bits": m.input_bits,
"output_bits": m.output_bits,
"input_words": m.input_words,
"output_words": m.output_words,
}
for m in CATALOG.values()
),
key=lambda e: e["article"],
),
"max_modules": 64,
"node_config_path": sim.node_config_path,
}
@router.put("/api/config/modules")
async def put_module_config(
payload: dict,
request: Request,
sim: SimulatorApp = Depends(get_sim),
sessions: SessionStore = Depends(get_sessions),
) -> dict:
"""SAVE CONFIG: validate the new module list, rebuild the process
image live, and (unless `persist` is false) save the node config.
§11 FR-11.2.1–11.2.4: the datastore survives (Modbus connections
stay open); image sizes 0x1022-0x1025 and the module description
0x2030+ are updated; values are cleared like a KBUS reset.
"""
_require_write(request, sessions)
articles = payload.get("modules")
if not isinstance(articles, list) or not articles:
raise HTTPException(status_code=400, detail="'modules' must be a non-empty list")
if len(articles) > 64:
raise HTTPException(status_code=400, detail="too many modules (max 64)")
try:
modules = [resolve_module(str(a)) for a in articles]
except ModuleUnknown as exc:
raise HTTPException(status_code=400, detail=str(exc)) from None
rebuild_node(sim, modules)
applied = state.node_layout(sim.node)
if payload.get("persist", True) and sim.node_config_path:
try:
save_node_config(sim.node_config_path, modules,
name=Path(sim.node_config_path).stem)
except OSError as exc:
raise HTTPException(
status_code=500,
detail=f"rebuilt, but saving {sim.node_config_path} failed: {exc}",
) from None
return {
"ok": True,
"modules": [m.article for m in modules],
"image_bits": applied["image_bits"],
"saved_to": sim.node_config_path if payload.get("persist", True) else None,
}
@router.websocket("/api/ws")
async def ws_values(websocket: WebSocket) -> None:
sim: SimulatorApp = websocket.app.state.sim
sessions: SessionStore = websocket.app.state.sessions
token = websocket.cookies.get(SESSION_COOKIE)
if not sessions.is_valid(token):
await websocket.close(code=4401)
return
await websocket.accept()
last = None
try:
while True:
values = state.channel_values(sim.node)
if values != last:
await websocket.send_json({"values": values})
last = values
await asyncio.sleep(0.1)
except WebSocketDisconnect:
return
+40
View File
@@ -0,0 +1,40 @@
"""Typed API models (dict-based JSON schemas, no pydantic dependency on
the simulator's own types — FastAPI's pydantic handles the wire side)."""
from __future__ import annotations
from typing import TypedDict
class ChannelJSON(TypedDict):
name: str
kind: str
address: str
modbus: str
class ModuleJSON(TypedDict):
index: int
article: str
description: str
channels: list[ChannelJSON]
class ImageBitsJSON(TypedDict):
analog_in: int
analog_out: int
digital_in: int
digital_out: int
class NodeLayoutJSON(TypedDict):
coupler: str
modules: list[ModuleJSON]
image_bits: ImageBitsJSON
class ChannelValue(TypedDict):
kind: str
module: int
channel: int
value: bool | int
+89
View File
@@ -0,0 +1,89 @@
"""FastAPI application factory for the web UI.
Runs in-process on the same asyncio loop as the Modbus TCP server
(spec §10.3): a UI write and a Modbus request never race on the
datastore. Static single-page front-end served from `webui/static/`.
"""
from __future__ import annotations
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from ..app import SimulatorApp
from .api import router
from .auth import SESSION_COOKIE, SessionStore
STATIC_DIR = Path(__file__).parent / "static"
def build_webui(
sim: SimulatorApp,
*,
user: str,
password: str,
readonly: bool = False,
) -> FastAPI:
"""Assemble the FastAPI app: API router + static SPA + auth cookie."""
app = FastAPI(
title="WAGO 750-362 Simulator",
docs_url=None,
redoc_url=None,
openapi_url=None,
)
app.state.sim = sim
app.state.sessions = SessionStore(user=user, password=password, readonly=readonly)
app.include_router(router)
@app.exception_handler(HTTPException)
async def http_exc(request: Request, exc: HTTPException): # pragma: no cover
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
# login page + SPA: unauthenticated users land on /login; the SPA
# shell is served for "/" only when a valid session cookie exists.
@app.get("/login", include_in_schema=False)
async def login_page() -> FileResponse:
return FileResponse(STATIC_DIR / "login.html")
@app.get("/logout", include_in_schema=False)
async def logout(request: Request) -> RedirectResponse:
app.state.sessions.logout(request.cookies.get(SESSION_COOKIE, ""))
response = RedirectResponse("/login", status_code=303)
response.delete_cookie(SESSION_COOKIE)
return response
@app.get("/", include_in_schema=False)
async def index(request: Request):
if not app.state.sessions.is_valid(request.cookies.get(SESSION_COOKIE)):
return RedirectResponse("/login", status_code=303)
return FileResponse(STATIC_DIR / "index.html")
# static assets (css/js) are public; the SPA data comes via the
# authenticated /api routes only
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# the login POST sets the session cookie
from .api import login as _login_route # noqa: E402 (router above)
@app.post("/login", include_in_schema=False)
async def login_submit(request: Request) -> JSONResponse:
payload = await request.json()
token = app.state.sessions.login(
str(payload.get("user", "")), str(payload.get("password", ""))
)
if token is None:
return JSONResponse({"ok": False, "detail": "invalid credentials"}, 401)
response = JSONResponse(
{"ok": True, "user": app.state.sessions.username,
"readonly": app.state.sessions.readonly}
)
response.set_cookie(
SESSION_COOKIE, token, httponly=True, samesite="lax", max_age=43200
)
return response
return app
+85
View File
@@ -0,0 +1,85 @@
"""Session-cookie authentication for the web UI (spec §10, auth decision).
Design: in-memory session-token store keyed by a `secrets.token_urlsafe`
value; the browser holds it in an `HttpOnly` cookie. Password is hashed
with `hashlib.scrypt` (no third-party auth dependency). Sessions need
not survive a process restart on a FAT bench.
"""
from __future__ import annotations
import hashlib
import hmac
import secrets
import time
DEFAULT_USER = "admin"
DEFAULT_PASSWORD = "simu"
SESSION_COOKIE = "wago_sim_session"
SESSION_TTL_S = 12 * 3600 # one working day on the bench
# simple in-memory login throttle: {username: (fail_count, locked_until)}
_LOGIN_ATTEMPTS: dict[str, tuple[int, float]] = {}
MAX_ATTEMPTS = 5
LOCKOUT_S = 60.0
def _hash_password(password: str) -> bytes:
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(
password.encode("utf-8"), salt=salt, n=2**14, r=8, p=1, dklen=32
)
return salt + digest
def _verify_password(password: str, stored: bytes) -> bool:
salt, digest = stored[:16], stored[16:]
check = hashlib.scrypt(
password.encode("utf-8"), salt=salt, n=2**14, r=8, p=1, dklen=32
)
return hmac.compare_digest(check, digest)
class SessionStore:
"""In-memory session store + login throttle."""
def __init__(self, *, user: str, password: str, readonly: bool = False) -> None:
self._user = user
self._password_hash = _hash_password(password)
self.readonly = readonly
self._sessions: dict[str, float] = {} # token -> expires_at (monotonic-ish)
@property
def username(self) -> str:
return self._user
def login(self, user: str, password: str) -> str | None:
"""Return a session token, or None when credentials are wrong."""
now = time.monotonic()
fails, locked_until = _LOGIN_ATTEMPTS.get(user, (0, 0.0))
if locked_until > now:
return None
if user == self._user and _verify_password(password, self._password_hash):
_LOGIN_ATTEMPTS.pop(user, None)
token = secrets.token_urlsafe(32)
self._sessions[token] = now + SESSION_TTL_S
return token
_LOGIN_ATTEMPTS[user] = (fails + 1, 0.0)
if fails + 1 >= MAX_ATTEMPTS:
_LOGIN_ATTEMPTS[user] = (fails + 1, now + LOCKOUT_S)
return None
def logout(self, token: str) -> None:
self._sessions.pop(token, None)
def is_valid(self, token: str | None) -> bool:
if not token:
return False
expires = self._sessions.get(token)
if expires is None:
return False
if expires < time.monotonic():
self._sessions.pop(token, None)
return False
return True
+32
View File
@@ -0,0 +1,32 @@
"""Node-config persistence for the web UI (spec §11, FR-11.5).
Writes the module list back to the node-config YAML. A small custom
emitter is used instead of `yaml.dump` so the file stays human-readable
with a header comment — bench engineers edit this file by hand.
"""
from __future__ import annotations
from pathlib import Path
from ..modules import ModuleType
_HEADER = """\
# Virtual WAGO node configuration — saved from the web UI (SAVE CONFIG)
# Simulated device: WAGO 750-362 Modbus TCP coupler
# Edit by hand or via the UI; comments are not preserved on UI saves.
name: {name}
coupler: 750-362
modules:
"""
def save_node_config(
path: str | Path, modules: list[ModuleType], *, name: str = "node"
) -> None:
"""Write the module list to the node-config YAML (custom emitter)."""
lines = [_HEADER.format(name=name)]
for m in modules:
lines.append(f' - "{m.article}" # {m.description}\n')
Path(path).write_text("".join(lines), encoding="utf-8", newline="\n")
+109
View File
@@ -0,0 +1,109 @@
"""Snapshot builder: node layout + channel values via internal APIs only.
Reads the process images through `WagoDatastore` getters and the
`VirtualNode` channel model — never via Modbus TCP (spec §10.3,
backend-data-access decision).
"""
from __future__ import annotations
from ..modules import VirtualNode
from .api_models import ChannelValue, NodeLayoutJSON
def node_layout(node: VirtualNode) -> NodeLayoutJSON:
"""Static node description: modules, channels, addressing."""
modules_json = []
for index, mod in enumerate(node.modules):
channels = []
for ch in mod.channels:
kind_upper = ch.kind.upper()
# module-based addressing per FR-4.3.11: {KIND}{module}.{channel}
# `module` is the 0-based slot; `channel` is 0-based on module.
channel_no = int(ch.name.split()[-1]) - 1
channels.append(
{
"name": ch.name,
"kind": ch.kind,
"address": f"{kind_upper}{index}.{channel_no}",
"modbus": _modbus_address(node, ch.kind, index, channel_no),
}
)
modules_json.append(
{
"index": index,
"article": mod.article,
"description": mod.description,
"channels": channels,
}
)
return {
"coupler": "750-362",
"modules": modules_json,
"image_bits": {
"analog_in": node.analog_in_bits,
"analog_out": node.analog_out_bits,
"digital_in": node.digital_in_bits,
"digital_out": node.digital_out_bits,
},
}
def _modbus_address(node: VirtualNode, kind: str, module: int, channel: int) -> str:
"""Modbus address string for a channel (read direction for inputs,
write direction for outputs — matching the 750-362 maps)."""
try:
loc = node.channel_by_module(kind, module, channel)
except (IndexError, ValueError):
return ""
if kind in ("di", "do"):
# digital bit address == global channel number - 1 (Table 121/122)
return str(loc.bit)
# analog word address within the image
return str(loc.word)
def channel_values(node: VirtualNode) -> list[ChannelValue]:
"""Live values of every channel via the internal memory handler."""
ds = node.datastore
out: list[ChannelValue] = []
# Digital channels: global 1-based numbers, bit-packed after analog.
for i, loc in enumerate(node.di, start=1):
out.append(
{
"kind": "di",
"module": loc.module_index,
"channel": loc.channel - 1,
"value": ds.digital_input_channel(i),
}
)
for i, loc in enumerate(node.do, start=1):
out.append(
{
"kind": "do",
"module": loc.module_index,
"channel": loc.channel - 1,
"value": ds.digital_output_channel(i),
}
)
# Analog channels: word indices in the process image.
for loc in node.ai:
out.append(
{
"kind": "ai",
"module": loc.module_index,
"channel": loc.channel - 1,
"value": ds.get_input_word(loc.word),
}
)
for loc in node.ao:
out.append(
{
"kind": "ao",
"module": loc.module_index,
"channel": loc.channel - 1,
"value": ds.get_output_word(loc.word),
}
)
return out
+353
View File
@@ -0,0 +1,353 @@
/* WAGO simulator web UI — vanilla JS, no build step (spec §10.3). */
"use strict";
let NODE = null; // /api/node layout
let VALUES = {}; // "kind:module:channel" -> value
let WS = null;
let READONLY = false;
const kindColor = {di: "#3fbf4f", do: "#3fbf4f", ai: "#2d8cf0", ao: "#2d8cf0"};
function key(kind, module, channel) {
return `${kind}:${module}:${channel}`;
}
// ---------------- data ----------------
async function api(path, opts) {
const r = await fetch(path, opts);
if (r.status === 401) { window.location.href = "/login"; return null; }
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
return r.json();
}
async function init() {
NODE = await api("/api/node");
const diag = await api("/api/diagnostics").catch(() => null);
renderRail();
if (diag) renderDiagnostics(diag);
openWS();
}
function openWS() {
const proto = location.protocol === "https:" ? "wss" : "ws";
WS = new WebSocket(`${proto}://${location.host}/api/ws`);
WS.onopen = () => document.getElementById("conn-state").classList.add("on");
WS.onclose = () => {
document.getElementById("conn-state").classList.remove("on");
setTimeout(openWS, 1000);
};
WS.onmessage = (ev) => applyValues(JSON.parse(ev.data).values);
}
function applyValues(values) {
for (const v of values) {
VALUES[key(v.kind, v.module, v.channel)] = v.value;
updateChannel(v.kind, v.module, v.channel, v.value);
}
}
// ---------------- overview rendering ----------------
function renderRail() {
const rail = document.getElementById("rail");
rail.innerHTML = "";
// coupler block on the left
const coupler = document.createElement("div");
coupler.className = "coupler";
coupler.innerHTML = `
<div class="mod-head">750-362</div>
<div class="coupler-leds">
<span class="cled on" title="MS"></span>
<span class="cled on" title="NS"></span>
<span class="cled" title="BF" data-diag="bf"></span>
<span class="cled" title="SF" data-diag="sf"></span>
</div>`;
rail.appendChild(coupler);
for (const mod of NODE.modules) {
const el = document.createElement("div");
el.className = "module";
el.dataset.index = mod.index;
const width = 1 + Math.min(mod.channels.length, 16) * 0.42;
el.style.width = `${Math.max(90, 26 * Math.min(mod.channels.length, 16))}px`;
const kind = mod.channels[0] ? mod.channels[0].kind : "di";
el.classList.add(`mod-${kind.startsWith("d") ? kind : "analog"}`);
let html = `<div class="mod-head" title="${mod.description}">${mod.article}</div>`;
html += `<div class="mod-channels">`;
for (const ch of mod.channels) {
const id = `ch-${ch.kind}-${mod.index}-${channelNo(ch.address)}`;
if (ch.kind === "di" || ch.kind === "do") {
html += `<span class="led" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})"
onclick="clickChannel('${ch.kind}', ${mod.index}, ${channelNo(ch.address)})"></span>`;
} else {
html += `<span class="abar" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})">
<span class="abar-fill"></span><span class="abar-num">0</span></span>`;
}
}
html += `</div>`;
el.innerHTML = html;
rail.appendChild(el);
}
document.getElementById("rail-detail").innerHTML =
NODE.modules.map((m) => `<b>${m.article}</b> ${m.description}`).join(" &middot; ");
}
function channelNo(address) {
// address is like "DI2.1" — the channel part after the dot
const dot = address.indexOf(".");
return dot >= 0 ? Number(address.slice(dot + 1)) : 0;
}
function updateChannel(kind, module, channel, value) {
const id = `ch-${kind}-${module}-${channel}`;
const el = document.getElementById(id);
if (!el) return;
if (kind === "di" || kind === "do") {
el.classList.toggle("on", !!value);
} else {
const frac = Math.min(1, value / 4095);
el.querySelector(".abar-fill").style.width = `${(frac * 100).toFixed(1)}%`;
el.querySelector(".abar-num").textContent = value;
}
}
async function clickChannel(kind, module, channel) {
if (READONLY) return;
try {
await api(`/api/channels/${kind}/${module}/${channel}`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({action: "toggle"}),
});
const res = await api("/api/values");
applyValues(res.values);
} catch (e) {
console.warn("write rejected:", e.message);
}
}
// ---------------- diagnostics ----------------
async function renderDiagnostics(diag) {
const wdStatus = ["inactive", "active", "expired"][diag.watchdog.status] || "?";
document.getElementById("diag-watchdog").innerHTML = `
<h3>Watchdog</h3>
<table>
<tr><td>Status (0x1006)</td><td>${wdStatus}</td></tr>
<tr><td>Type (0x100A)</td><td>${diag.watchdog.type}</td></tr>
<tr><td>Process data locked</td><td>${diag.watchdog.process_data_blocked ? "yes" : "no"}</td></tr>
</table>`;
const s = diag.statistics;
const rows = Object.entries(s).map(([k, v]) =>
`<tr><td>${k}</td><td>${v}</td></tr>`).join("");
document.getElementById("diag-stats").innerHTML = `
<h3>Statistics (0x1029)</h3>
<table>${rows}</table>
<button onclick="resetStats()">Reset counters</button>`;
const led = diag.led_error;
document.getElementById("diag-faults").innerHTML = `
<h3>Fault injection</h3>
<table>
<tr><td>LED error code (0x1020)</td><td id="led-code">${led.code}</td></tr>
<tr><td>LED error arg (0x1021)</td><td id="led-arg">${led.argument}</td></tr>
<tr><td>Module diagnosis (0x1050)</td><td>${diag.module_diagnosis.words.join(", ")}</td></tr>
</table>
<div class="fault-buttons">
<button onclick="injectLed()">Inject LED error</button>
<button onclick="clearLed()">Clear LED error</button>
</div>`;
// reflect SF state on the coupler
const sf = document.querySelector('[data-diag="sf"]');
if (sf) sf.classList.toggle("on", !!led.code);
}
async function refreshDiagnostics() {
const diag = await api("/api/diagnostics").catch(() => null);
if (diag) renderDiagnostics(diag);
}
async function resetStats() {
await api("/api/diagnostics/reset-stats", {method: "POST"});
await refreshDiagnostics();
}
async function injectLed() {
await api("/api/diagnostics/inject-fault", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({fault: "led", code: 1, argument: 2}),
});
await refreshDiagnostics();
}
async function clearLed() {
await api("/api/diagnostics/clear-fault", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({fault: "led"}),
});
await refreshDiagnostics();
}
// ---------------- configuration (spec §11 subset) ----------------
const WD_TYPES = ["standard", "alternative"];
function field(label, id, value, attrs = "") {
return `<label class="cfg-field">${label}
<input id="${id}" value="${value}" ${attrs}></label>`;
}
async function renderConfig() {
const cfg = await api("/api/config/coupler");
if (!cfg) return;
const wd = cfg.watchdog;
const form = document.getElementById("coupler-form");
form.innerHTML =
field("Watchdog time (x100 ms, 0x1000)", "cfg-wd-time", wd.time_units,
"type=\"number\" min=\"1\" max=\"65535\"") +
field("Coding mask FC1-16 (0x1001, hex)", "cfg-wd-mask1",
"0x" + wd.mask_fc_1_16.toString(16).toUpperCase()) +
field("Coding mask FC17-32 (0x1002, hex)", "cfg-wd-mask2",
"0x" + wd.mask_fc_17_32.toString(16).toUpperCase()) +
field("Response delay ms (0x1037)", "cfg-delay", cfg.response_delay_ms,
"type=\"number\" min=\"0\" max=\"65535\"") +
field("Idle timeout (x100 ms, 0x1030)", "cfg-idle", cfg.idle_timeout_units,
"type=\"number\" min=\"0\" max=\"65535\"") +
`<label class="cfg-field">Watchdog type (0x100A)
<select id="cfg-wd-type">
${WD_TYPES.map((t, i) =>
`<option value="${i}" ${wd.type === i ? "selected" : ""}>${t}</option>`).join("")}
</select></label>` +
`<label class="cfg-check"><input type="checkbox" id="cfg-wd-close"
${wd.close_on_timeout ? "checked" : ""}> Close TCP on timeout (0x1009)</label>`;
const mc = await api("/api/config/modules");
if (!mc) return;
const list = document.getElementById("module-list");
list.innerHTML = mc.modules.map((m, i) => `
<label class="module-row">
<input type="radio" name="mod" value="${i}">
<b>${i}</b>&nbsp; ${m.article} — ${m.description}
<span class="mod-io">${m.input_bits ? m.input_bits + " DI" : ""}
${m.output_bits ? m.output_bits + " DO" : ""}
${m.input_words ? m.input_words + " AIw" : ""}
${m.output_words ? m.output_words + " AOW" : ""}</span>
</label>`).join("");
const pick = document.getElementById("catalog-pick");
pick.innerHTML = mc.catalog.map((c) =>
`<option value="${c.article}">${c.article} — ${c.description}</option>`).join("");
document.getElementById("module-path").textContent =
mc.node_config_path ? "→ " + mc.node_config_path : "";
MODULE_ARTICLES = mc.modules.map((m) => m.article);
}
let MODULE_ARTICLES = [];
async function saveCoupler() {
const msg = document.getElementById("coupler-msg");
msg.classList.add("hidden");
const payload = {
watchdog: {
time_units: Number(document.getElementById("cfg-wd-time").value),
mask_fc_1_16: parseIntDefault(document.getElementById("cfg-wd-mask1").value),
mask_fc_17_32: parseIntDefault(document.getElementById("cfg-wd-mask2").value),
close_on_timeout: document.getElementById("cfg-wd-close").checked,
type: Number(document.getElementById("cfg-wd-type").value),
},
response_delay_ms: Number(document.getElementById("cfg-delay").value),
idle_timeout_units: Number(document.getElementById("cfg-idle").value),
};
try {
await api("/api/config/coupler", {
method: "PUT", headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload),
});
msg.textContent = "Saved.";
msg.classList.remove("hidden");
msg.classList.remove("error");
msg.classList.add("ok");
} catch (e) {
msg.textContent = e.message;
msg.classList.remove("hidden");
msg.classList.remove("ok");
msg.classList.add("error");
}
}
function parseIntDefault(v) {
const n = Number(v);
return Number.isNaN(n) ? 0 : (v.trim().startsWith("0x") ? parseInt(v, 16) : n);
}
async function saveModules() {
const msg = document.getElementById("module-msg");
msg.classList.add("hidden");
try {
const result = await api("/api/config/modules", {
method: "PUT", headers: {"Content-Type": "application/json"},
body: JSON.stringify({modules: MODULE_ARTICLES, persist: true}),
});
msg.textContent = `Rebuilt (${result.image_bits.digital_in} DI / ` +
`${result.image_bits.analog_in} AI bits)${result.saved_to ?
" — saved to " + result.saved_to : ""}`;
msg.classList.remove("hidden");
msg.classList.remove("error");
msg.classList.add("ok");
NODE = null; // layout changed — refetch
await init();
await renderConfig();
} catch (e) {
msg.textContent = e.message;
msg.classList.remove("hidden");
msg.classList.remove("ok");
msg.classList.add("error");
}
}
document.addEventListener("DOMContentLoaded", () => {
document.getElementById("coupler-save").addEventListener("click", saveCoupler);
document.getElementById("module-add").addEventListener("click", () => {
MODULE_ARTICLES.push(document.getElementById("catalog-pick").value);
renderModuleRows();
});
document.getElementById("module-remove").addEventListener("click", () => {
const sel = document.querySelector("input[name=mod]:checked");
if (sel) {
MODULE_ARTICLES.splice(Number(sel.value), 1);
renderModuleRows();
}
});
document.getElementById("module-save").addEventListener("click", saveModules);
});
function renderModuleRows() {
// local edit view until SAVE CONFIG is pressed
const list = document.getElementById("module-list");
list.innerHTML = MODULE_ARTICLES.map((a, i) => `
<label class="module-row">
<input type="radio" name="mod" value="${i}">
<b>${i}</b>&nbsp; ${a}
</label>`).join("");
}
// ---------------- tabs ----------------
document.querySelectorAll(".tab").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach((b) => b.classList.remove("active"));
document.querySelectorAll(".tab-page").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
document.getElementById(`tab-${btn.dataset.tab}`).classList.add("active");
if (btn.dataset.tab === "diagnostics") refreshDiagnostics();
if (btn.dataset.tab === "config") renderConfig();
});
});
init();
+83
View File
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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">
</head>
<body>
<header class="topbar">
<div class="brand">
<svg width="26" height="26" viewBox="0 0 72 72" aria-hidden="true">
<rect x="8" y="14" width="56" height="44" rx="3" fill="#004a99"/>
<rect x="8" y="14" width="56" height="10" fill="#003a7a"/>
</svg>
<span>WAGO 750-362 Simulator</span>
</div>
<nav class="tabs">
<button data-tab="overview" class="tab active">Overview</button>
<button data-tab="config" class="tab">Configuration</button>
<button data-tab="diagnostics" class="tab">Diagnostics</button>
</nav>
<div class="topbar-right">
<span id="conn-state" class="conn-dot" title="WebSocket state"></span>
<a href="/logout" class="logout">Logout</a>
</div>
</header>
<main>
<!-- ============ Overview: DIN rail with modules ============ -->
<section id="tab-overview" class="tab-page active">
<h2>Node overview</h2>
<div id="rail" class="rail"></div>
<div id="rail-detail" class="rail-detail"></div>
</section>
<!-- ============ Configuration ============ -->
<section id="tab-config" class="tab-page">
<h2>Configuration</h2>
<div class="config-grid">
<div class="diag-card" id="cfg-coupler">
<h3>Bus coupler</h3>
<p class="cfg-note">Settings write through the internal register
bank with the device's own validation (writes refused while the
watchdog is running are rejected with its message).</p>
<form id="coupler-form"></form>
<div id="coupler-msg" class="error hidden"></div>
<button id="coupler-save">Save coupler settings</button>
</div>
<div class="diag-card" id="cfg-modules">
<h3>I/O modules</h3>
<p class="cfg-note">SAVE CONFIG rebuilds the process image (values
clear like a KBUS reset; Modbus connections stay open) and writes
the node-config YAML.</p>
<div id="module-list"></div>
<div class="fault-buttons">
<select id="catalog-pick"></select>
<button id="module-add">Add module</button>
<button id="module-remove">Remove selected</button>
</div>
<div id="module-msg" class="error hidden"></div>
<div class="fault-buttons">
<button id="module-save" class="primary">SAVE CONFIG</button>
<span id="module-path" class="cfg-path"></span>
</div>
</div>
</div>
</section>
<!-- ============ Diagnostics ============ -->
<section id="tab-diagnostics" class="tab-page">
<h2>Diagnostics</h2>
<div class="diag-grid">
<div class="diag-card" id="diag-watchdog"></div>
<div class="diag-card" id="diag-stats"></div>
<div class="diag-card" id="diag-faults"></div>
</div>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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">
</head>
<body class="login-body">
<div class="login-card">
<div class="login-logo">
<svg width="72" height="72" viewBox="0 0 72 72" aria-hidden="true">
<rect x="8" y="14" width="56" height="44" rx="3" fill="#004a99" />
<rect x="8" y="14" width="56" height="10" fill="#003a7a" />
<text x="36" y="45" text-anchor="middle" font-size="11" fill="#fff"
font-family="Arial, sans-serif" font-weight="bold">750-362</text>
<circle cx="14" cy="19" r="2" fill="#4caf50"><animate attributeName="opacity"
values="1;0.2;1" dur="2s" repeatCount="indefinite"/></circle>
</svg>
</div>
<h1>WAGO Simulator</h1>
<p class="login-sub">FAT bench &mdash; Modbus TCP simulator</p>
<form id="login-form">
<label>User
<input id="user" name="user" autocomplete="username" required>
</label>
<label>Password
<input id="password" name="password" type="password"
autocomplete="current-password" required>
</label>
<button type="submit">Sign in</button>
<p id="login-error" class="error hidden"></p>
</form>
</div>
<script>
document.getElementById("login-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
const err = document.getElementById("login-error");
err.classList.add("hidden");
const user = document.getElementById("user").value;
const password = document.getElementById("password").value;
const r = await fetch("/login", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({user, password}),
});
if (r.ok) {
window.location.href = "/";
} else {
err.textContent = "Invalid credentials";
err.classList.remove("hidden");
}
});
</script>
</body>
</html>
+188
View File
@@ -0,0 +1,188 @@
/* WAGO simulator web UI — DIN-rail node graphics (no build step). */
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", Arial, sans-serif;
background: #e9ecef;
color: #1c2733;
}
/* ---------- login ---------- */
.login-body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(160deg, #003a7a, #0062c4);
}
.login-card {
background: #fff;
border-radius: 8px;
padding: 34px 40px;
width: 320px;
text-align: center;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.35);
}
.login-card h1 { font-size: 1.25rem; margin: 10px 0 0; }
.login-sub { color: #6b7684; margin-top: 2px; font-size: 0.85rem; }
.login-card form { display: flex; flex-direction: column; gap: 14px; margin-top: 18px; text-align: left; }
.login-card label { font-size: 0.85rem; color: #3c4653; }
.login-card input {
width: 100%; padding: 9px 10px; border: 1px solid #c5ccd4;
border-radius: 4px; font-size: 1rem; margin-top: 4px;
}
.login-card button {
padding: 10px; border: 0; border-radius: 4px; background: #0062c4;
color: #fff; font-size: 1rem; cursor: pointer;
}
.login-card button:hover { background: #004a99; }
.error { color: #b3261e; font-size: 0.85rem; }
.hidden { display: none; }
/* ---------- top bar ---------- */
.topbar {
display: flex; align-items: center; gap: 22px;
background: #003a7a; color: #fff;
padding: 8px 18px;
}
.brand { display: flex; align-items: center; gap: 8px; font-weight: 600; }
.tabs { display: flex; gap: 4px; }
.tab {
border: 0; background: transparent; color: #cfe0f5;
padding: 7px 14px; border-radius: 4px; cursor: pointer; font-size: 0.95rem;
}
.tab.active { background: #0062c4; color: #fff; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.logout { color: #cfe0f5; text-decoration: none; font-size: 0.9rem; }
.conn-dot {
width: 10px; height: 10px; border-radius: 50%;
background: #d9534f; display: inline-block;
}
.conn-dot.on { background: #4caf50; box-shadow: 0 0 6px #4caf50; }
main { padding: 18px; max-width: 1280px; margin: 0 auto; }
h2 { margin: 4px 0 14px; }
.tab-page { display: none; }
.tab-page.active { display: block; }
/* ---------- DIN rail ---------- */
.rail {
display: flex; align-items: stretch; gap: 6px;
background: linear-gradient(#d7dde3, #b7c0c9);
border-radius: 4px;
padding: 26px 12px 12px;
min-height: 130px;
overflow-x: auto;
box-shadow: inset 0 -3px 6px rgba(0, 0, 0, 0.18);
}
.coupler, .module {
background: linear-gradient(#fdfdfd, #dfe3e8);
border: 1px solid #9aa4ae;
border-radius: 3px;
min-height: 92px;
position: relative;
padding-top: 16px;
}
.mod-head {
position: absolute; top: 2px; left: 5px;
font-size: 0.62rem; color: #33414f; font-weight: 600;
letter-spacing: 0.02em; white-space: nowrap;
}
.coupler { min-width: 96px; background: linear-gradient(#0d5db4, #003a7a); }
.coupler .mod-head { color: #fff; font-size: 0.75rem; }
.coupler-leds { display: flex; gap: 7px; padding: 42px 10px 0; }
.cled {
width: 8px; height: 8px; border-radius: 50%;
background: #123; box-shadow: none;
}
.cled.on {
background: #4caf50;
box-shadow: 0 0 7px 2px rgba(76, 175, 80, 0.85);
}
.module { display: flex; flex-direction: column; justify-content: flex-end; }
.mod-channels {
display: flex; gap: 5px; flex-wrap: wrap; align-items: flex-end;
padding: 8px 6px 8px;
}
/* channel LEDs */
.led {
width: 12px; height: 12px; border-radius: 50%;
background: #2c3742;
border: 1px solid #1b232c;
cursor: pointer;
transition: box-shadow 0.12s, background 0.12s;
}
.led.on {
background: #37e04d;
border-color: #2ab63a;
box-shadow: 0 0 9px 2px rgba(70, 230, 80, 0.8);
}
/* analog bars */
.abar {
position: relative;
width: 16px; height: 48px;
background: #232c36;
border-radius: 3px;
overflow: hidden;
cursor: pointer;
display: inline-block;
}
.abar-fill {
position: absolute; bottom: 0; left: 0; right: 0; height: 0;
background: linear-gradient(#4db6ff, #0d6eb4);
}
.abar-num {
position: absolute; top: 50px; left: 50%;
transform: translateX(-50%);
font-size: 0.55rem; color: #33414f;
}
.rail-detail { margin-top: 10px; font-size: 0.8rem; color: #55606c; }
/* ---------- diagnostics ---------- */
.diag-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; }
.diag-card {
background: #fff; border-radius: 6px; padding: 14px 18px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}
.diag-card h3 { margin: 0 0 10px; font-size: 1rem; }
.diag-card table { border-collapse: collapse; width: 100%; font-size: 0.88rem; }
.diag-card td { padding: 4px 6px; border-bottom: 1px solid #eef1f4; }
.diag-card td:last-child { text-align: right; font-family: Consolas, monospace; }
.diag-card button {
margin-top: 12px; padding: 7px 14px; border: 0; border-radius: 4px;
background: #0062c4; color: #fff; cursor: pointer;
}
.diag-card button:hover { background: #004a99; }
.fault-buttons { display: flex; gap: 8px; }
/* ---------- configuration ---------- */
.config-grid { display: grid; grid-template-columns: minmax(320px, 1fr) minmax(380px, 1.4fr); gap: 16px; align-items: start; }
.cfg-note { color: #6b7684; font-size: 0.8rem; margin: 0 0 12px; }
#coupler-form { display: flex; flex-direction: column; gap: 10px; }
.cfg-field { font-size: 0.85rem; color: #3c4653; display: flex; flex-direction: column; gap: 3px; }
.cfg-field input, .cfg-field select {
padding: 7px 8px; border: 1px solid #c5ccd4; border-radius: 4px;
font-size: 0.9rem; width: 100%;
}
.cfg-check { font-size: 0.85rem; display: flex; gap: 8px; align-items: center; }
.ok { color: #2e7d32; font-size: 0.85rem; }
#module-list { display: flex; flex-direction: column; gap: 4px; max-height: 340px; overflow-y: auto; margin-bottom: 10px; }
.module-row {
display: flex; align-items: center; gap: 8px;
padding: 5px 8px; border: 1px solid #d8dee5; border-radius: 4px;
font-size: 0.85rem; cursor: pointer; background: #f7f9fb;
}
.module-row:hover { background: #eaf1f8; }
.mod-io { color: #55606c; margin-left: auto; font-size: 0.78rem; white-space: nowrap; }
#catalog-pick { padding: 6px; border: 1px solid #c5ccd4; border-radius: 4px; flex: 1; }
.cfg-path { font-size: 0.75rem; color: #6b7684; align-self: center; }
button.primary { background: #2e7d32; }
button.primary:hover { background: #1b5e20; }