From 87c1c8db7b6810b920f33fd84ab7fbcb5a94d401 Mon Sep 17 00:00:00 2001 From: Jonas Bengtsson Date: Sat, 3 Oct 2026 23:48:16 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20Web=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/tasks.json | 486 +++++++++++++++++++++++++++ README.md | 68 ++++ config/pump_node.yaml | 2 +- docs/spec/software-spec.md | 79 ++++- pyproject.toml | 4 + src/wago_sim/app.py | 40 +++ src/wago_sim/cli.py | 80 ++++- src/wago_sim/datastore.py | 13 + src/wago_sim/registers.py | 10 +- src/wago_sim/watchdog.py | 2 + src/wago_sim/webui/__init__.py | 6 + src/wago_sim/webui/api.py | 432 ++++++++++++++++++++++++ src/wago_sim/webui/api_models.py | 40 +++ src/wago_sim/webui/app.py | 89 +++++ src/wago_sim/webui/auth.py | 85 +++++ src/wago_sim/webui/persist.py | 32 ++ src/wago_sim/webui/state.py | 109 ++++++ src/wago_sim/webui/static/app.js | 353 +++++++++++++++++++ src/wago_sim/webui/static/index.html | 83 +++++ src/wago_sim/webui/static/login.html | 56 +++ src/wago_sim/webui/static/style.css | 188 +++++++++++ tests/test_webui.py | 316 +++++++++++++++++ 22 files changed, 2558 insertions(+), 15 deletions(-) create mode 100644 .vscode/tasks.json create mode 100644 src/wago_sim/webui/__init__.py create mode 100644 src/wago_sim/webui/api.py create mode 100644 src/wago_sim/webui/api_models.py create mode 100644 src/wago_sim/webui/app.py create mode 100644 src/wago_sim/webui/auth.py create mode 100644 src/wago_sim/webui/persist.py create mode 100644 src/wago_sim/webui/state.py create mode 100644 src/wago_sim/webui/static/app.js create mode 100644 src/wago_sim/webui/static/index.html create mode 100644 src/wago_sim/webui/static/login.html create mode 100644 src/wago_sim/webui/static/style.css create mode 100644 tests/test_webui.py diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..0d58b55 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,486 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "wago-sim: pytest suite", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "pytest", + "tests", + "-q", + "--tb=short" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "wago-sim: pytest full", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "pytest", + "tests", + "-q", + "--tb=short" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "wago-sim: pytest full2", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "pytest", + "tests", + "-q", + "--tb=short" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "wago-sim: pytest full3", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "pytest", + "tests", + "-q", + "--tb=short" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "wago-sim: serve with webui", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "wago_sim.cli", + "serve", + "--node", + "config/pump_node.yaml", + "--host", + "127.0.0.1", + "--port", + "5020", + "--web-port", + "8080" + ], + "isBackground": true, + "problemMatcher": [] + }, + { + "label": "wago-sim: serve help", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "wago_sim.cli", + "serve", + "--help" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "argparse probe", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-c", + "import argparse; p=argparse.ArgumentParser(); p.add_argument('--web/--no-web', default=True); p.add_argument('--web-port', type=int, default=8080); a=p.parse_args(['--web-port','8080']); print('web=', a.web, 'web_port=', a.web_port)" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "argparse probe2", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-c", + "import argparse; p=argparse.ArgumentParser(); p.add_argument('--web/--no-web', default=True); a=p.parse_args([]); print(a)" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "wago-sim: serve with webui v2", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "wago_sim.cli", + "serve", + "--node", + "config/pump_node.yaml", + "--host", + "127.0.0.1", + "--port", + "5020", + "--web-port", + "8080" + ], + "isBackground": true, + "problemMatcher": [] + }, + { + "label": "wago-sim: serve with webui v3", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "wago_sim.cli", + "serve", + "--node", + "config/pump_node.yaml", + "--host", + "127.0.0.1", + "--port", + "5020", + "--web-port", + "8080" + ], + "isBackground": true, + "problemMatcher": [] + }, + { + "label": "wago-sim: serve webui 8088", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "wago_sim.cli", + "serve", + "--node", + "config/pump_node.yaml", + "--host", + "127.0.0.1", + "--port", + "5020", + "--web-port", + "8088" + ], + "isBackground": true, + "problemMatcher": [] + }, + { + "label": "pip list", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "pip", + "list" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "kill old serve", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-c", + "import subprocess, sys; r = subprocess.run('powershell -Command \\\"Get-NetTCPConnection -LocalPort 5020,8088 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force }\\\"', shell=True, capture_output=True, text=True); print('killed:', r.stdout, r.stderr)" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "kill old serve v2", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-c", + "import subprocess; r = subprocess.run(['powershell', '-NoProfile', '-Command', 'Get-NetTCPConnection -LocalPort 5020,8088 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force }'], capture_output=True, text=True); print('out:', r.stdout); print('err:', r.stderr[:200] if r.stderr else '')" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "wago-sim: serve final", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "wago_sim.cli", + "serve", + "--node", + "config/pump_node.yaml", + "--host", + "127.0.0.1", + "--port", + "5020", + "--web-port", + "8088" + ], + "isBackground": true, + "problemMatcher": [] + }, + { + "label": "wago-sim: serve final2", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "wago_sim.cli", + "serve", + "--node", + "config/pump_node.yaml", + "--host", + "127.0.0.1", + "--port", + "5020", + "--web-port", + "8088" + ], + "isBackground": true, + "problemMatcher": [] + }, + { + "label": "modbus roundtrip vs UI", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-c", + "import asyncio; from pymodbus.client import AsyncModbusTcpClient\n\nasync def main():\n c = AsyncModbusTcpClient('127.0.0.1', port=5020)\n assert await c.connect()\n rr = await c.read_discrete_inputs(0, count=3, device_id=1)\n print('DI bits 0-2 via Modbus FC2:', rr.bits[:3])\n rr2 = await c.write_coil(0, True, device_id=1)\n print('FC5 write coil 0 (DO1) response ok:', not rr2.isError())\n c.close(); await c.wait_closed()\n\nasyncio.run(main())" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "noop probe", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "wago_sim.examples.modbus_probe" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "live probe run", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "scripts\\live_probe.py" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "wago-sim: pytest final", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "pytest", + "tests", + "-q", + "--tb=short" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "remove probe", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-c", + "import pathlib; p = pathlib.Path('scripts/live_probe.py'); p.unlink(); print('removed' if not p.exists() else 'still there')" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "stop serve", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-c", + "import subprocess; r = subprocess.run(['powershell', '-NoProfile', '-Command', 'Get-NetTCPConnection -LocalPort 5020,8088 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force }'], capture_output=True, text=True); print('stopped')" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "wago-sim: pytest m5", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "pytest", + "tests", + "-q", + "--tb=short" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "wago-sim: pytest m5b", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "pytest", + "tests", + "-q", + "--tb=short" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "debug desc", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-c", + "from wago_sim.app import build_app, rebuild_node; from wago_sim.modules import DI_16, DO_8; app = build_app(modules=[DI_16, DI_16, DO_8]); rebuild_node(app, [DI_16, DI_16, DO_8]); print('desc words:', app.registers.read_config(0x2030, 8)); print('description:', app.node.description())" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "debug string pack", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-c", + "from wago_sim.registers import _string_to_words; w = _string_to_words('0:750-1405, 1:750-1405, 2:750-530', 65); print(w[:8]); print([hex(x) for x in w[:4]])" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "debug rebuild desc", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-c", + "from wago_sim.app import build_app; from wago_sim.modules import DI_16, DO_8; app = build_app(modules=[DI_16, DO_8]); print('after build:', app.registers.read_config(0x2030, 4)); from wago_sim.app import rebuild_node; rebuild_node(app, [DI_16, DI_16, DO_8]); print('after rebuild:', app.registers.read_config(0x2030, 4)); print('desc attr len:', len(app.registers._module_desc))" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "manual loader lookup", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-c", + "import pymupdf, re; doc = pymupdf.open(r'docs/m07500362_xxxxxxxx_0en.pdf'); text = ''.join(p.get_text() for p in doc); hits = [m.start() for m in re.finditer('[Ll]oader', text)][:4]; [print('---'); print(text[max(0,i-150):i+250].replace(chr(10), ' | ')) for i in hits]" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "manual check run", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "scripts\\manual_check.py" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "manual check run2", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "scripts\\manual_check.py" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "manual check run3", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "scripts\\manual_check.py" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "wago-sim: pytest m5c", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "pytest", + "tests", + "-q", + "--tb=short" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "cleanup probe", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-c", + "import pathlib; pathlib.Path('scripts/manual_check.py').unlink(); print('ok')" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "wago-sim: serve m5", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "wago_sim.cli", + "serve", + "--node", + "config/pump_node.yaml", + "--host", + "127.0.0.1", + "--port", + "5020", + "--web-port", + "8088" + ], + "isBackground": true, + "problemMatcher": [] + }, + { + "label": "modbus desc probe", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "scripts\\live_probe.py" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "cleanup + stop", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-c", + "import pathlib, subprocess; pathlib.Path('scripts/live_probe.py').unlink(missing_ok=True); pathlib.Path('scripts').rmdir() if not list(pathlib.Path('scripts').iterdir()) else None; subprocess.run(['powershell','-NoProfile','-Command','Get-NetTCPConnection -LocalPort 5020,8088 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force }'], capture_output=True); print('cleaned')" + ], + "isBackground": false, + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 86c968a..78a21a4 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,33 @@ terminal, in `docker logs`, and in `journalctl` alike. Read vs write mappings are annotated correctly per the manual's Tables 119/120 (e.g. word address `0x0000` reads as `%IW0` but writes to `%QW0`). +## Web UI + +`serve` starts a browser UI alongside the Modbus server (spec §10): + +```bash +wago-sim serve --node config/pump_node.yaml --port 5020 +# Web UI on http://127.0.0.1:8080 — default login: admin / simu +``` + +- **Overview**: realistic DIN-rail view of the coupler + modules with + per-channel LEDs for digital I/O and bar/numeric indicators for + analog values; updates live via WebSocket — including changes made + by the PLC over Modbus. +- **Manipulation**: click a DI/DO LED to toggle it (field side), or + force DO/AO master-side; AI values can be set directly. All writes + go through the simulator's internal data paths — never via Modbus + TCP — while keeping read-back mirrors and statistics consistent. +- **Diagnostics**: watchdog status, statistics with reset, and + LED/module fault injection for FAT fault cases. + +Configuration options: `--no-web`, `--web-port ` or env +`WAGO_SIM_WEB_PORT` (default 8080); credentials via +`WAGO_SIM_WEB_USER` / `WAGO_SIM_WEB_PASSWORD` (defaults +`admin` / `simu`); `WAGO_SIM_WEB_READONLY=1` for observation-only +benches (all mutating endpoints answer 403). Plain HTTP — keep the +port inside the bench network. + ## Documentation - `docs/spec/software-spec.md` — the software specification @@ -261,6 +288,47 @@ requirement ID for each item in parentheses. protocol tracing, diagnostics and fault injection (FR-14.2.5– 14.2.6); scoping questions to answer before planning (§14.3). +### Web UI (spec §10) — in implementation + +- [x] Spec §10.3–10.6 updated with binding implementation decisions: + FastAPI/Uvicorn in-process on the Modbus server's asyncio loop; + vanilla single-page front-end (no build step) with realistic + DIN-rail graphics and per-channel LEDs; session-cookie auth with + default credentials `admin`/`simu` (env-overridable, + read-only mode); backend API uses **only internal memory + handlers** (`WagoDatastore`, `VirtualNode`, registers, + watchdog, statistics) — never Modbus TCP; WebSocket live + updates; milestones M1–M6. +- [x] M1: webui package (`src/wago_sim/webui/`: `app.py`, `auth.py`, + `state.py`, `api.py`, static SPA); session auth with scrypt + hashing, lockout after 5 failed logins, read-only mode + (`WAGO_SIM_WEB_READONLY`); `GET /api/node` with `DI0.1`-style + and Modbus addresses; CLI wiring `--web/--no-web`, + `--web-port`, env `WAGO_SIM_WEB_USER/PASSWORD/READONLY/ + NO_WEB/WEB_PORT`; login page + SPA shell. +- [x] M2: node overview page — DIN-rail strip with coupler block + (MS/NS/BF/SF LEDs), module bezels with article numbers, + per-channel LEDs for DI/DO, bar+numeric indicators for AI/AO, + live values via WebSocket (`/api/ws`, 10 Hz delta push), + `GET /api/values` snapshot. +- [x] M3: manipulation — DI/DO toggle (field side via + `set_digital_input_channel`, master side via internal + `write_bits`/`write_words` dispatch so mirrors/statistics stay + consistent), AI value entry (raw counts), AO force; + unknown-channel → 404, kind-mismatch → 400, read-only → 403. +- [x] M4: diagnostics — watchdog status card (0x1006/0x100A), + statistics table (0x1029 words) with reset, LED-error + (0x1020/0x1021) and module-diagnosis (0x1050) fault injection + and clear; SF LED on the coupler reflects injected LED errors. +- [x] M5: configuration — coupler settings forms (watchdog time/masks/type/close-on-timeout via 0x1000–0x100B, response delay 0x1037, idle timeout 0x1030) with the device's own validation surfaced (writes refused while the watchdog runs → field error); module list editor over the 13-article catalog with SAVE CONFIG → live process-image rebuild (`rebuild_node`: datastore survives, values clear like a KBUS reset, 0x1022–0x1025 and 0x2030+ updated) + persistence to the node-config YAML via a comment-preserving custom emitter. +- [ ] M6: scenario run view + alias-ownership rejection (FR-10.2.6, + FR-10.2.9) — deferred. +- [x] Tests: 28 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 + master reads the module description (0x2030) correctly after the + loader-overlap register fix. Full suite: 206 passing. + ### Framework gaps (spec §4 — Scenario & Macro Engine) - [ ] State machines: `states:` with entry actions, transitions on diff --git a/config/pump_node.yaml b/config/pump_node.yaml index ee01ea2..1e5e4cd 100644 --- a/config/pump_node.yaml +++ b/config/pump_node.yaml @@ -9,4 +9,4 @@ modules: - "750-530" # 8-channel digital output 24VDC - "750-1516" # 16-channel digital output 24VDC - "750-467" # 2-channel analog input 4-20mA - - "750-554" # 2-channel analog output 4-20mA \ No newline at end of file + - "750-554" # 2-channel analog output 4-20mA diff --git a/docs/spec/software-spec.md b/docs/spec/software-spec.md index 13a67d0..35fc41f 100644 --- a/docs/spec/software-spec.md +++ b/docs/spec/software-spec.md @@ -7,7 +7,7 @@ | Project | `wago-modbustcp-simulator-py` | | Version | 1.4 (Draft for review) | | Date | 2026-10-03 | -| Status | Framework phase; module addressing implemented (FR-4.3.11); service/container deployment implemented (§12); plain-text request tracing implemented (NFR-6.4); clean CLI error handling implemented (NFR-6.5 CLI part); long-duration simulation (§8), bench mode (§9), web UI (§10), runtime device configuration (§11), multi-vendor device profiles (§13) and additional fieldbus protocols PROFINET/COMLI (§14) requested | +| Status | Framework phase; module addressing implemented (FR-4.3.11); service/container deployment implemented (§12); plain-text request tracing implemented (NFR-6.4); clean CLI error handling implemented (NFR-6.5 CLI part); web UI §10 in implementation (M1–M5 done: auth, overview, manipulation, diagnostics, configuration; M6 scenario view open); long-duration simulation (§8), bench mode (§9), runtime device configuration (§11 — coupler/module subset now covered by web UI M5), multi-vendor device profiles (§13) and additional fieldbus protocols PROFINET/COMLI (§14) requested | | Target device simulated | WAGO 750-362 / 750-362/0000-0001 FC Modbus TCP (G4) bus coupler | | Reference documents | `docs/m07500362_xxxxxxxx_0en.pdf` (750-362 Modbus TCP manual), Modbus TCP specification (modbus.org) | @@ -865,9 +865,11 @@ at runtime without a scenario driving it. ## 10. Feature Request — Web UI (I/O Overview & Manipulation) -> **Status: requested 2026-10-03, planned (roadmap phase 5), not yet -> implemented.** A browser front-end overlooking and manipulating the -> simulated node's I/O values and states at runtime. +> **Status: in implementation (2026-10-03).** The implementation +> decisions below are binding; milestones M1–M4 are implemented and +> verified (M5 configuration and M6 scenario view remain). A browser +> front-end overlooking and manipulating the simulated node's I/O +> values and states at runtime. ### 10.1 Motivation @@ -918,21 +920,78 @@ Terminal monitors (§9) suffice for single engineers, but FAT benches are overview, address-selection DIP switch) are specified in §11; §10 covers value/state oversight and manipulation. -### 10.3 Architecture sketch (planned) +### 10.3 Implementation decisions (binding, 2026-10-03) + +- **Framework**: FastAPI (Starlette) + Uvicorn, embedded **in-process on + the same asyncio event loop** as the Modbus TCP server — a UI write and + a Modbus request never race on the datastore. +- **Front-end**: single-page vanilla HTML/CSS/JS (no build step) under + `src/wago_sim/webui/static/`; realistic node graphics (DIN-rail strip, + module bezels, glowing per-channel LEDs for DI/DO, bar/numeric + indicators for AI/AO) drawn with inline SVG/CSS. +- **Authentication** (user request 2026-10-03): session-cookie login with + in-memory token store; default credentials `admin` / `simu`, + overridable via env (`WAGO_SIM_WEB_USER` / `WAGO_SIM_WEB_PASSWORD`); + optional read-only mode via `WAGO_SIM_WEB_READONLY`. No third-party + auth/session dependencies (`hashlib.scrypt` hashing; sessions need not + survive restarts on a bench). +- **Backend data access** (user request 2026-10-03): the API **never uses + Modbus TCP**. It reads via the internal memory handler + (`WagoDatastore` getters, `VirtualNode` channel model, + `ConfigRegisterBank`, `ModbusWatchdog`, `ModbusStatistics`) and writes + via internal paths chosen by role: simulated-field writes go through + `set_digital_input_channel()` / `set_input_word()` (as the scenario + engine does); master-side forcing goes through the datastore's + internal `write_bits()` / `write_words()` dispatch (mirrors, watchdog + kicks, and statistics stay consistent, FR-9.2.6 semantics) — but + always as direct in-process calls, never via TCP. +- **Live updates**: WebSocket push (`/api/ws`) — datastore + `add_listener` for output changes plus a 10 Hz snapshot-difference + sweep for input-side changes. +- **Wiring**: `serve` starts the web UI by default (`--no-web` to + disable; `--web-port`, env `WAGO_SIM_WEB_PORT`; default port 8080). + +### 10.4 Architecture sketch (as-built) ``` src/wago_sim/ ├── webui/ │ ├── __init__.py -│ ├── app.py # HTTP + WebSocket server (asyncio, same loop as Modbus) -│ ├── api.py # REST endpoints: node layout, values, writes, diagnostics +│ ├── app.py # build_webui(app, ...) -> FastAPI; API + static +│ ├── auth.py # login/logout, scrypt hash, session dependency +│ ├── state.py # snapshot builder + change detection +│ ├── api.py # REST + WebSocket routes │ └── static/ # single-page front-end (no build step) ``` -The web server runs **in the same asyncio event loop** as the Modbus TCP -server, so a UI write and a Modbus read never race on the datastore. +### 10.5 API surface (as-built) -### 10.4 Acceptance criteria +| Method/Route | Purpose | FR | +|---|---|---| +| `POST /api/auth/login`, `POST /api/auth/logout` | session cookie (`HttpOnly`) | auth | +| `GET /api/node` | module list, channel tables with `DI0.1`-style and Modbus addresses | 10.2.2 | +| `GET /api/values` | full channel-value snapshot | 10.2.3 | +| `WS /api/ws` | live value/state deltas | 10.2.3, 10.2.7 | +| `POST /api/channels/{kind}/{module}/{channel}` | set/toggle/pulse/ramp a channel (field side) | 10.2.4 | +| `POST /api/force` | master-side force at a Modbus address (internal dispatch) | 10.2.4 | +| `GET /api/diagnostics` | watchdog status, statistics, module diagnosis, LED errors | 10.2.5 | +| `POST /api/diagnostics/reset-stats` | statistics reset (0x1029 semantics) | 10.2.5 | +| `POST /api/diagnostics/inject-fault`, `POST /api/diagnostics/clear-fault` | LED / module-diagnosis injection | 10.2.5 | +| `GET/PUT /api/config/coupler` | watchdog settings, response delay, idle timeout, MAC-ID | §11 subset | +| `GET/PUT /api/config/modules` | module list CRUD + process-image rebuild | §11 FR-11.2.1–11.2.4 | + +### 10.6 Milestones + +| # | Deliverable | +|---|---| +| M1 | WebUI skeleton, in-process FastAPI, CLI/env wiring, authentication (`admin`/`simu`), `GET /api/node` | +| M2 | Node overview page — DIN-rail graphics, LEDs, analog indicators, live values (WS) | +| M3 | Manipulation — toggle/pulse/ramp, AI engineering units, master-side force | +| M4 | Diagnostics — watchdog/statistics/diagnosis view, reset, fault injection | +| M5 | Configuration — coupler forms; module CRUD with image rebuild and persistence | +| M6 | (deferred) Scenario run view (FR-10.2.6) + alias-ownership rejection (FR-10.2.9) | + +### 10.7 Acceptance criteria 1. Opening the UI shows the node's module list and channel table exactly as configured, with `DI0.1`-style addresses and Modbus addresses. diff --git a/pyproject.toml b/pyproject.toml index 955c146..6807c5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,9 @@ license = { text = "Proprietary" } authors = [{ name = "Vivab" }] dependencies = [ "PyYAML>=6.0", + "fastapi>=0.110", + "uvicorn>=0.29", + "websockets>=12.0", ] [project.optional-dependencies] @@ -20,6 +23,7 @@ dev = [ "pytest-asyncio>=0.23", "pymodbus>=3.6", "debugpy>=1.8", + "httpx>=0.27", ] [project.scripts] diff --git a/src/wago_sim/app.py b/src/wago_sim/app.py index a8c0bad..2ecaae7 100644 --- a/src/wago_sim/app.py +++ b/src/wago_sim/app.py @@ -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, ) \ No newline at end of file diff --git a/src/wago_sim/cli.py b/src/wago_sim/cli.py index 3a7c6c7..ba0ce66 100644 --- a/src/wago_sim/cli.py +++ b/src/wago_sim/cli.py @@ -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)") diff --git a/src/wago_sim/datastore.py b/src/wago_sim/datastore.py index 7d8ebc1..9e5225b 100644 --- a/src/wago_sim/datastore.py +++ b/src/wago_sim/datastore.py @@ -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: diff --git a/src/wago_sim/registers.py b/src/wago_sim/registers.py index 3c7f0ba..feabb23 100644 --- a/src/wago_sim/registers.py +++ b/src/wago_sim/registers.py @@ -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] diff --git a/src/wago_sim/watchdog.py b/src/wago_sim/watchdog.py index 4e77899..095b728 100644 --- a/src/wago_sim/watchdog.py +++ b/src/wago_sim/watchdog.py @@ -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: diff --git a/src/wago_sim/webui/__init__.py b/src/wago_sim/webui/__init__.py new file mode 100644 index 0000000..af7fd0d --- /dev/null +++ b/src/wago_sim/webui/__init__.py @@ -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. +""" \ No newline at end of file diff --git a/src/wago_sim/webui/api.py b/src/wago_sim/webui/api.py new file mode 100644 index 0000000..dc12217 --- /dev/null +++ b/src/wago_sim/webui/api.py @@ -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 \ No newline at end of file diff --git a/src/wago_sim/webui/api_models.py b/src/wago_sim/webui/api_models.py new file mode 100644 index 0000000..e671235 --- /dev/null +++ b/src/wago_sim/webui/api_models.py @@ -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 \ No newline at end of file diff --git a/src/wago_sim/webui/app.py b/src/wago_sim/webui/app.py new file mode 100644 index 0000000..824ab70 --- /dev/null +++ b/src/wago_sim/webui/app.py @@ -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 \ No newline at end of file diff --git a/src/wago_sim/webui/auth.py b/src/wago_sim/webui/auth.py new file mode 100644 index 0000000..532bc29 --- /dev/null +++ b/src/wago_sim/webui/auth.py @@ -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 \ No newline at end of file diff --git a/src/wago_sim/webui/persist.py b/src/wago_sim/webui/persist.py new file mode 100644 index 0000000..eecd696 --- /dev/null +++ b/src/wago_sim/webui/persist.py @@ -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") \ No newline at end of file diff --git a/src/wago_sim/webui/state.py b/src/wago_sim/webui/state.py new file mode 100644 index 0000000..89ea4a0 --- /dev/null +++ b/src/wago_sim/webui/state.py @@ -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 \ No newline at end of file diff --git a/src/wago_sim/webui/static/app.js b/src/wago_sim/webui/static/app.js new file mode 100644 index 0000000..0793a1a --- /dev/null +++ b/src/wago_sim/webui/static/app.js @@ -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 = ` +
750-362
+
+ + + + +
`; + 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 = `
${mod.article}
`; + html += `
`; + for (const ch of mod.channels) { + const id = `ch-${ch.kind}-${mod.index}-${channelNo(ch.address)}`; + if (ch.kind === "di" || ch.kind === "do") { + html += ``; + } else { + html += ` + 0`; + } + } + html += `
`; + el.innerHTML = html; + rail.appendChild(el); + } + document.getElementById("rail-detail").innerHTML = + NODE.modules.map((m) => `${m.article} ${m.description}`).join(" · "); +} + +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 = ` +

Watchdog

+ + + + +
Status (0x1006)${wdStatus}
Type (0x100A)${diag.watchdog.type}
Process data locked${diag.watchdog.process_data_blocked ? "yes" : "no"}
`; + + const s = diag.statistics; + const rows = Object.entries(s).map(([k, v]) => + `${k}${v}`).join(""); + document.getElementById("diag-stats").innerHTML = ` +

Statistics (0x1029)

+ ${rows}
+ `; + + const led = diag.led_error; + document.getElementById("diag-faults").innerHTML = ` +

Fault injection

+ + + + +
LED error code (0x1020)${led.code}
LED error arg (0x1021)${led.argument}
Module diagnosis (0x1050)${diag.module_diagnosis.words.join(", ")}
+
+ + +
`; + // 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 ``; +} + +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\"") + + `` + + ``; + + const mc = await api("/api/config/modules"); + if (!mc) return; + const list = document.getElementById("module-list"); + list.innerHTML = mc.modules.map((m, i) => ` + `).join(""); + const pick = document.getElementById("catalog-pick"); + pick.innerHTML = mc.catalog.map((c) => + ``).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) => ` + `).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(); \ No newline at end of file diff --git a/src/wago_sim/webui/static/index.html b/src/wago_sim/webui/static/index.html new file mode 100644 index 0000000..9bfc500 --- /dev/null +++ b/src/wago_sim/webui/static/index.html @@ -0,0 +1,83 @@ + + + + +WAGO 750-362 Simulator + + + + +
+
+ + WAGO 750-362 Simulator +
+ +
+ + Logout +
+
+ +
+ +
+

Node overview

+
+
+
+ + +
+

Configuration

+
+
+

Bus coupler

+

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).

+
+ + +
+
+

I/O modules

+

SAVE CONFIG rebuilds the process image (values + clear like a KBUS reset; Modbus connections stay open) and writes + the node-config YAML.

+
+
+ + + +
+ +
+ + +
+
+
+
+ + +
+

Diagnostics

+
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/src/wago_sim/webui/static/login.html b/src/wago_sim/webui/static/login.html new file mode 100644 index 0000000..30bd0b0 --- /dev/null +++ b/src/wago_sim/webui/static/login.html @@ -0,0 +1,56 @@ + + + + +WAGO Simulator — Login + + + + + + + + \ No newline at end of file diff --git a/src/wago_sim/webui/static/style.css b/src/wago_sim/webui/static/style.css new file mode 100644 index 0000000..9163dfc --- /dev/null +++ b/src/wago_sim/webui/static/style.css @@ -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; } \ No newline at end of file diff --git a/tests/test_webui.py b/tests/test_webui.py new file mode 100644 index 0000000..26a1f82 --- /dev/null +++ b/tests/test_webui.py @@ -0,0 +1,316 @@ +"""Web UI API tests (spec §10): auth, node layout, values, manipulation, +diagnostics — exercised in-process via httpx ASGITransport (no socket).""" + +from __future__ import annotations + +import httpx +import pytest + +from wago_sim.webui.app import build_webui + + +@pytest.fixture +def webui(app): + """FastAPI app bound to the simulator app (default credentials).""" + return build_webui(app, user="admin", password="simu") + + +@pytest.fixture +async def client(webui): + """Async httpx client with a valid session (default credentials).""" + transport = httpx.ASGITransport(app=webui) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.post("/login", json={"user": "admin", "password": "simu"}) + assert r.status_code == 200, r.text + assert r.json()["ok"] is True + yield c + + +@pytest.fixture +async def anon_client(webui): + """Async httpx client WITHOUT login.""" + transport = httpx.ASGITransport(app=webui) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +class TestAuth: + async def test_login_page_served(self, anon_client): + r = await anon_client.get("/login") + assert r.status_code == 200 + assert "WAGO" in r.text + + async def test_root_redirects_when_anonymous(self, anon_client): + r = await anon_client.get("/", follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"] == "/login" + + async def test_api_requires_session(self, anon_client): + r = await anon_client.get("/api/node") + assert r.status_code == 401 + + async def test_login_wrong_password(self, anon_client): + r = await anon_client.post("/login", json={"user": "admin", "password": "wrong"}) + assert r.status_code == 401 + assert r.json()["ok"] is False + + async def test_logout_invalidates_session(self, client): + r = await client.post("/api/auth/logout") + assert r.status_code == 200 + r = await client.get("/api/node") + assert r.status_code == 401 + + async def test_login_lockout_after_failures(self, anon_client, monkeypatch): + import wago_sim.webui.auth as auth_mod + + # reset the module-level throttle so suite order does not matter + monkeypatch.setattr(auth_mod, "_LOGIN_ATTEMPTS", {}) + for _ in range(5): + r = await anon_client.post("/login", json={"user": "admin", "password": "nope"}) + assert r.status_code == 401 + # 6th attempt with CORRECT password is locked out too + r = await anon_client.post("/login", json={"user": "admin", "password": "simu"}) + assert r.status_code == 401 + + +class TestNodeAPI: + async def test_node_layout(self, client): + r = await client.get("/api/node") + assert r.status_code == 200 + data = r.json() + assert data["coupler"] == "750-362" + articles = [m["article"] for m in data["modules"]] + assert "750-430" in articles # DI_8 from pump_modules + # module-based addressing: channel 0 -> address like DI0.0 + di_mod = next(m for m in data["modules"] if m["article"] == "750-430") + assert di_mod["channels"][0]["address"] == "DI0.0" + assert "modbus" in di_mod["channels"][0] + # image sizes from the layout are reported + assert data["image_bits"]["digital_in"] == 24 # DI_8 + DI_16 + + async def test_values_snapshot(self, client): + r = await client.get("/api/values") + assert r.status_code == 200 + values = r.json()["values"] + kinds = {v["kind"] for v in values} + assert kinds == {"di", "do", "ai", "ao"} + + +class TestManipulation: + async def test_toggle_di_via_api(self, client, datastore): + r = await client.post("/api/channels/di/0/0", json={"action": "toggle"}) + assert r.status_code == 200 + assert r.json()["value"] is True + # the internal memory handler shows the same state (channel 1) + assert datastore.digital_input_channel(1) is True + + async def test_toggle_do_forces_output_image(self, client, datastore): + # DO_8 sits at module slot 2 in the pump fixture + r = await client.post("/api/channels/do/2/0", json={"action": "toggle"}) + assert r.status_code == 200 + # DO channel 1 is forced ON in the OUTPUT image (master-side) + assert datastore.digital_output_channel(1) is True + # ... and the +0x0200 read-back mirror reflects it + bits = datastore.read_bits(0x0200, 1) + assert bits == [True] + + async def test_ai_value_set(self, client, datastore): + # AI_2 sits at module slot 4 in the pump fixture (DI_8, DI_16, + # DO_8, DO_16, AI_2, AO_2); its first word is input word 0 + r = await client.post("/api/channels/ai/4/0", json={"value": 200}) + assert r.status_code == 200 + assert r.json()["value"] == 200 + assert datastore.get_input_word(0) == 200 + + async def test_ao_force_writes_output_image(self, client, datastore): + r = await client.post("/api/channels/ao/5/0", json={"value": 1234}) + assert r.status_code == 200 + # written at the output image word 0 -> read back at +0x0200 + assert datastore.read_words(0x0200, 1) == [1234] + + async def test_unknown_channel_404(self, client): + r = await client.post("/api/channels/di/99/0", json={"action": "toggle"}) + assert r.status_code == 404 + + async def test_kind_mismatch_400(self, client): + # DI_16 module (index 1) has no DO channels + r = await client.post("/api/channels/do/1/0", json={"action": "toggle"}) + assert r.status_code == 400 + + +class TestDiagnostics: + async def test_diagnostics_payload(self, client): + r = await client.get("/api/diagnostics") + assert r.status_code == 200 + data = r.json() + assert data["watchdog"]["status"] in (0, 1, 2) + assert "mod_tcp_message_counter" in data["statistics"] + assert "led_error" in data + + async def test_inject_and_clear_led_fault(self, client, app): + r = await client.post("/api/diagnostics/inject-fault", + json={"fault": "led", "code": 5, "argument": 2}) + assert r.status_code == 200 + assert app.registers.read_config(0x1020, 1) == [5] + assert app.registers.read_config(0x1021, 1) == [2] + r = await client.post("/api/diagnostics/clear-fault", json={"fault": "led"}) + assert r.status_code == 200 + assert app.registers.read_config(0x1020, 1) == [0] + + async def test_inject_module_diagnosis(self, client, app): + r = await client.post("/api/diagnostics/inject-fault", + json={"fault": "module_diagnosis", "module": 2, + "channel": 1, "diagnosis": 0x0001}) + assert r.status_code == 200 + assert app.registers.read_config(0x1050, 3) == [2, 1, 0x0001] + + async def test_reset_stats(self, client, app): + app.stats.bad_address += 3 + r = await client.post("/api/diagnostics/reset-stats") + assert r.status_code == 200 + assert app.stats.bad_address == 0 + + +class TestReadOnlyMode: + async def test_readonly_blocks_writes(self, app): + ro = build_webui(app, user="admin", password="simu", readonly=True) + transport = httpx.ASGITransport(app=ro) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.post("/login", json={"user": "admin", "password": "simu"}) + assert r.status_code == 200 + assert r.json()["readonly"] is True + r = await c.get("/api/node") + assert r.status_code == 200 + r = await c.post("/api/channels/di/0/0", json={"action": "toggle"}) + assert r.status_code == 403 + assert "read-only" in r.json()["detail"] + r = await c.post("/api/diagnostics/reset-stats") + assert r.status_code == 403 + + +class TestCouplerConfig: + async def test_get_coupler_config(self, client, app): + r = await client.get("/api/config/coupler") + assert r.status_code == 200 + cfg = r.json() + # defaults: 100 units = 10 s watchdog, full masks, no delay + assert cfg["watchdog"]["time_units"] == 100 + assert cfg["watchdog"]["mask_fc_1_16"] == 0xFFFF + assert cfg["watchdog"]["type"] == 0 + assert cfg["response_delay_ms"] == 0 + assert cfg["idle_timeout_units"] == 600 + assert len(cfg["mac_id"]) == 3 + # image sizes for the pump fixture (AO_2 out / AI_2 in analog, + # DO 24 bits out / DI 24 bits in — 0x1022..0x1025 per manual §11.2.5.5) + assert cfg["image_bits"]["analog_in"] == 32 + assert cfg["image_bits"]["analog_out"] == 32 + assert cfg["image_bits"]["digital_in"] == 24 + assert cfg["image_bits"]["digital_out"] == 24 + + async def test_put_coupler_config_roundtrip(self, client, app): + r = await client.put("/api/config/coupler", json={ + "watchdog": {"time_units": 200, "close_on_timeout": True}, + "response_delay_ms": 25, + "idle_timeout_units": 10, + }) + assert r.status_code == 200 + assert r.json()["applied"] == 4 + # written through the internal register bank + assert app.registers.read_config(0x1000, 1) == [200] + assert app.registers.read_config(0x1037, 1) == [25] + assert app.registers.read_config(0x1030, 1) == [10] + assert app.registers.read_config(0x1009, 1) == [1] + + async def test_watchdog_refusal_while_running(self, client, app): + # start the watchdog (trigger write, like the manual's flow) + app.watchdog.write(0x1003, 0x0001) + assert app.watchdog.status == 1 + r = await client.put("/api/config/coupler", json={ + "watchdog": {"time_units": 50}, + }) + assert r.status_code == 400 + assert "cannot change time while running" in r.json()["detail"] + + async def test_put_coupler_config_readonly_403(self, app): + ro = build_webui(app, user="admin", password="simu", readonly=True) + transport = httpx.ASGITransport(app=ro) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + await c.post("/login", json={"user": "admin", "password": "simu"}) + r = await c.put("/api/config/coupler", json={"response_delay_ms": 1}) + assert r.status_code == 403 + + +class TestModuleConfig: + async def test_get_module_config(self, client, app): + r = await client.get("/api/config/modules") + assert r.status_code == 200 + data = r.json() + assert [m["article"] for m in data["modules"]] == [ + "750-430", "750-1405", "750-530", "750-1516", "750-467", "750-554", + ] + articles = {c["article"] for c in data["catalog"]} + assert "750-461" in articles # catalog has all 13 articles + assert data["max_modules"] == 64 + assert data["node_config_path"] is None # fixture built without a path + + async def test_rebuild_updates_layout_and_registers(self, client, app): + # swap in a bigger DI module list + r = await client.put("/api/config/modules", json={ + "modules": ["750-1405", "750-1405", "750-530"], "persist": False, + }) + assert r.status_code == 200 + assert r.json()["image_bits"]["digital_in"] == 32 + # /api/node reflects the new layout + r = await client.get("/api/node") + assert [m["article"] for m in r.json()["modules"]] == [ + "750-1405", "750-1405", "750-530", + ] + # register bank updated: 0x1025 digital-IN bits = 32 (two DI_16) + assert app.registers.read_config(0x1025, 1) == [32] + # 0x1024 digital-OUT bits = 8 (one DO_8) + assert app.registers.read_config(0x1024, 1) == [8] + # module description 0x2030 contains the new articles + # (2 chars/word, high byte first — reverse of _string_to_words) + words = app.registers.read_config(0x2030, 65) + text = "".join( + chr(b) for w in words for b in ((w >> 8) & 0xFF, w & 0xFF) + ) + assert "750-1405" in text + # values were cleared by the rebuild (KBUS-reset semantics) + assert all(w == 0 for w in app.datastore.output_words) + + async def test_rebuild_rejected_invalid(self, client): + r = await client.put("/api/config/modules", json={"modules": ["750-9999"]}) + assert r.status_code == 400 + assert "750-9999" in r.json()["detail"] + r = await client.put("/api/config/modules", json={"modules": []}) + assert r.status_code == 400 + r = await client.put("/api/config/modules", json={"modules": ["750-430"] * 65}) + assert r.status_code == 400 + assert "max 64" in r.json()["detail"] + + async def test_rebuild_persists_yaml(self, client, app, tmp_path): + cfg_file = tmp_path / "node.yaml" + app.node_config_path = str(cfg_file) + r = await client.put("/api/config/modules", json={ + "modules": ["750-430", "750-530"], "persist": True, + }) + assert r.status_code == 200 + assert r.json()["saved_to"] == str(cfg_file) + text = cfg_file.read_text(encoding="utf-8") + assert 'coupler: 750-362' in text + assert '"750-430"' in text and '"750-530"' in text + # and it loads back through the normal loader + from wago_sim.scenario.dsl import load_yaml, load_node_modules + modules = load_node_modules(load_yaml(cfg_file)) + assert [m.article for m in modules] == ["750-430", "750-530"] + + async def test_modbus_still_serves_after_rebuild(self, client, app): + """Modbus connections survive a rebuild; new image answers.""" + r = await client.put("/api/config/modules", json={ + "modules": ["750-530", "750-554"], "persist": False, + }) + assert r.status_code == 200 + # write an output via the internal dispatch and read the mirror + app.datastore.write_words(0x0000, [0x1234]) + assert app.datastore.read_words(0x0200, 1) == [0x1234] \ No newline at end of file