🚧 Web UI

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