🚧 Web UI
This commit is contained in:
Vendored
+486
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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)")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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(" · ");
|
||||
}
|
||||
|
||||
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> ${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> ${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();
|
||||
@@ -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>
|
||||
@@ -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 — 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>
|
||||
@@ -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; }
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user