Compare commits
13
Commits
4452f75433
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f3272094e | ||
|
|
c8b655111e | ||
|
|
597b5bf73c | ||
|
|
647c209e89 | ||
|
|
68d3c42d79 | ||
|
|
f82e8d4be9 | ||
|
|
66b7e12f0f | ||
|
|
263afc9c1e | ||
|
|
eb36a2d947 | ||
|
|
c30b444ef1 | ||
|
|
59679b96ee | ||
|
|
e5df0b04ed | ||
|
|
7948b794f5 |
Vendored
+22
@@ -22,6 +22,28 @@
|
|||||||
],
|
],
|
||||||
"console": "integratedTerminal"
|
"console": "integratedTerminal"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Serve the pump node WITH the example plant environment
|
||||||
|
// attached (FR-8.2.17): closed-loop tank physics ticked
|
||||||
|
// wall-clock-paced (10 ms) alongside the Modbus server.
|
||||||
|
// A PLC (or pymodbus script) drives DO0.0/AO0.0 and reads
|
||||||
|
// back the simulated field (AI0.0 level, DI0.1 low-level
|
||||||
|
// switch); runs until stopped (Shift+F5 / Ctrl+C).
|
||||||
|
"name": "wago-sim: serve (pump node + plant env)",
|
||||||
|
"type": "debugpy",
|
||||||
|
"request": "launch",
|
||||||
|
"module": "wago_sim.cli",
|
||||||
|
"args": [
|
||||||
|
"--verbose",
|
||||||
|
"serve",
|
||||||
|
"--node", "config/pump_node.yaml",
|
||||||
|
"--host", "127.0.0.1",
|
||||||
|
"--web",
|
||||||
|
"--port", "5020",
|
||||||
|
"--env", "envs.pump_station_env:PumpStationEnv"
|
||||||
|
],
|
||||||
|
"console": "integratedTerminal"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// Run the example FAT scenario headless and write reports.
|
// Run the example FAT scenario headless and write reports.
|
||||||
"name": "wago-sim: run (pump_station_fat)",
|
"name": "wago-sim: run (pump_station_fat)",
|
||||||
|
|||||||
Vendored
+980
@@ -913,6 +913,986 @@
|
|||||||
],
|
],
|
||||||
"isBackground": false,
|
"isBackground": false,
|
||||||
"problemMatcher": []
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest barclick",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests",
|
||||||
|
"-q",
|
||||||
|
"--tb=short"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: serve barclick",
|
||||||
|
"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": "kill ports 4",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-c",
|
||||||
|
"import subprocess; 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('killed')"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: serve barclick2",
|
||||||
|
"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: pytest webui final",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests/test_webui.py",
|
||||||
|
"-q",
|
||||||
|
"--tb=short"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "stop barclick serve",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-c",
|
||||||
|
"import subprocess; 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('stopped')"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "rewrite webui test codes",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-c",
|
||||||
|
"import pathlib; p = pathlib.Path('tests/test_webui.py'); t = p.read_text(encoding='utf-8');\nt = t.replace('AI4.0', 'AI0.0').replace('AO5.0', 'AO0.0').replace('DO2.0', 'DO0.0');\nt = t.replace('/api/channels/do/2/', '/api/channels/do/0/');\nt = t.replace('/api/channels/ai/4/', '/api/channels/ai/0/');\nt = t.replace('/api/channels/ao/5/', '/api/channels/ao/0/');\nt = t.replace('channel_range(\"ao\", 5, 0)', 'channel_range(\"ao\", 0, 0)');\nt = t.replace('channel_range(\"ai\", 4, 0)', 'channel_range(\"ai\", 0, 0)');\nt = t.replace('(\\\"ai\\\", 4, 0)', '(\\\"ai\\\", 0, 0)');\np.write_text(t, encoding='utf-8'); print('rewritten')"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest per-type",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests",
|
||||||
|
"-q",
|
||||||
|
"--tb=short"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest per-type2",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests",
|
||||||
|
"-q",
|
||||||
|
"--tb=short"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "fix webui test urls",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-c",
|
||||||
|
"import pathlib; p = pathlib.Path('tests/test_webui.py'); t = p.read_text(encoding='utf-8'); t = t.replace('channels/do/2/', 'channels/do/0/').replace('channels/ai/4/', 'channels/ai/0/').replace('channels/ao/5/', 'channels/ao/0/'); t = t.replace('(\"ai\", 4, 0)', '(\"ai\", 0, 0)').replace('(\"ao\", 5, 0)', '(\"ao\", 0, 0)').replace('channel_range(\"ao\", 5, 0)', 'channel_range(\"ao\", 0, 0)').replace('channel_range(\"ai\", 4, 0)', 'channel_range(\"ai\", 0, 0)'); p.write_text(t, encoding='utf-8'); print('done')"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "run fix codes",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"scripts\\fix_codes.py"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest per-type3",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests",
|
||||||
|
"-q",
|
||||||
|
"--tb=short"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "run fix codes 2",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"scripts\\fix_codes.py"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest per-type4",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests",
|
||||||
|
"-q",
|
||||||
|
"--tb=short"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest per-type5",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests",
|
||||||
|
"-q",
|
||||||
|
"--tb=short"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "remove fix script",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-c",
|
||||||
|
"import pathlib; p = pathlib.Path('scripts/fix_codes.py'); p.unlink(); print('removed')"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: serve pertype",
|
||||||
|
"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": "kill ports 5",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-c",
|
||||||
|
"import subprocess; 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('killed')"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: serve pertype2",
|
||||||
|
"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: pytest pertype-final",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests",
|
||||||
|
"-q",
|
||||||
|
"--tb=short"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "stop pertype serve",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-c",
|
||||||
|
"import subprocess; 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('stopped')"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest dnd",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests",
|
||||||
|
"-q",
|
||||||
|
"--tb=short"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: serve dnd",
|
||||||
|
"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": "kill ports dnd",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-c",
|
||||||
|
"import subprocess; 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('killed')"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: serve dnd2",
|
||||||
|
"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: pytest dnd final",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests",
|
||||||
|
"-q",
|
||||||
|
"--tb=short"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "stop dnd serve",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-c",
|
||||||
|
"import subprocess; 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('stopped')"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest spec-only",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests",
|
||||||
|
"-q",
|
||||||
|
"--tb=short"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest continuous",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests/test_continuous.py",
|
||||||
|
"-q",
|
||||||
|
"--tb=short"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest continuous2",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests/test_continuous.py",
|
||||||
|
"-q",
|
||||||
|
"--tb=short"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest all continuous",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests",
|
||||||
|
"-q",
|
||||||
|
"--tb=short"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: validate soak",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"wago_sim.cli",
|
||||||
|
"validate",
|
||||||
|
"--scenario",
|
||||||
|
"scenarios/pump_soak.example.yaml"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "debug validate",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-c",
|
||||||
|
"import yaml; from wago_sim.scenario.dsl import validate_scenario; s = yaml.safe_load(open('scenarios/pump_soak.example.yaml', encoding='utf-8')); print('on value:', s['degradation']['pump_capacity'].get('on')); print('outputs:', list(s.get('outputs', {}))); print(validate_scenario(s))"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: validate soak2",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"wago_sim.cli",
|
||||||
|
"validate",
|
||||||
|
"--scenario",
|
||||||
|
"scenarios/pump_soak.example.yaml"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "gen inflow csv",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-c",
|
||||||
|
"import pathlib; d = pathlib.Path('data'); d.mkdir(exist_ok=True); (d / 'inflow_day.csv').write_text('time,tank_level\\n0.0,1000\\n' + '\\n'.join(f'{t},{1000 + t * 50}' for t in range(1, 65)) + '\\n', encoding='utf-8'); print('csv written:', len((d / 'inflow_day.csv').read_text()), 'bytes')"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: run soak smoke",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"wago_sim.cli",
|
||||||
|
"run",
|
||||||
|
"--node",
|
||||||
|
"config/pump_node.yaml",
|
||||||
|
"--scenario",
|
||||||
|
"scenarios/soak_smoke.yaml",
|
||||||
|
"--report-jsonl",
|
||||||
|
"out/soak_smoke.jsonl"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "clean soak artifacts",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-c",
|
||||||
|
"import pathlib; pathlib.Path('scenarios/soak_smoke.yaml').unlink(); pathlib.Path('data/inflow_day.csv').unlink(); pathlib.Path('data').rmdir(); pathlib.Path('out/soak_smoke.jsonl').unlink(); print('cleaned')"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest final continuous",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests",
|
||||||
|
"-q",
|
||||||
|
"--tb=short"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest final2",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests",
|
||||||
|
"-q",
|
||||||
|
"--tb=short"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "mark phase3 done",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"scripts\\mark_phase3.py"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "remove mark script",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-c",
|
||||||
|
"import pathlib; pathlib.Path('scripts/mark_phase3.py').unlink(); print('ok')"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "delete old plantenv",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-c",
|
||||||
|
"import pathlib; p = pathlib.Path('src/wago_sim/scenario/plantenv.py'); p.unlink(); print('deleted:', not p.exists())"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest env2",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests/test_continuous.py",
|
||||||
|
"-q",
|
||||||
|
"--tb=short"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "debug load_env",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-c",
|
||||||
|
"from wago_sim.scenario.plantenv import load_env, PlantEnv; import envs.pump_station_env as m; print('class:', m.PumpStationEnv); print('is subclass:', issubclass(m.PumpStationEnv, PlantEnv)); print('module plantenv file:', __import__('wago_sim.scenario.plantenv', fromlist=['x']).__file__)"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "pytest load env detail",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests/test_continuous.py::TestPlantEnv::test_load_env_class_and_factory",
|
||||||
|
"-q",
|
||||||
|
"--tb=long",
|
||||||
|
"-s"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest env3",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests/test_continuous.py",
|
||||||
|
"-q",
|
||||||
|
"--tb=short"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest env4",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests/test_continuous.py",
|
||||||
|
"-q",
|
||||||
|
"--tb=short"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "debug missing env",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-c",
|
||||||
|
"from wago_sim.scenario.plantenv import load_env; from wago_sim.modules import DI_8; from wago_sim.app import build_app; app = build_app(modules=[DI_8]);\ntry:\n e = load_env('envs.missing_module', node=app.node)\n print('NO RAISE; got:', type(e))\nexcept Exception as exc:\n print('raised:', exc)"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "run debug env",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"scripts\\debug_env.py"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "debug in pytest",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests/test_continuous.py::TestPlantEnv::test_load_env_class_and_factory",
|
||||||
|
"-q",
|
||||||
|
"--tb=long"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "rm debug script",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-c",
|
||||||
|
"import pathlib; pathlib.Path('scripts/debug_env.py').unlink(); print('ok')"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest all env",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests",
|
||||||
|
"-q",
|
||||||
|
"--tb=short"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: serve env",
|
||||||
|
"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",
|
||||||
|
"--env",
|
||||||
|
"envs.pump_station_env:PumpStationEnv"
|
||||||
|
],
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "env closed-loop probe",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"scripts\\env_probe.py"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "headless sim probe",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"scripts\\sim_probe.py"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "headless sim probe2",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"scripts\\sim_probe.py"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "rm probes",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-c",
|
||||||
|
"import pathlib; [pathlib.Path(p).unlink() for p in ('scripts/env_probe.py', 'scripts/sim_probe.py')]; print('ok')"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "stop env serve",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-c",
|
||||||
|
"import subprocess; 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('stopped')"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest final env",
|
||||||
|
"type": "shell",
|
||||||
|
"command": ".venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests",
|
||||||
|
"-q",
|
||||||
|
"--tb=short"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest dense ranges",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests/test_webui.py",
|
||||||
|
"tests/test_continuous.py",
|
||||||
|
"-q",
|
||||||
|
"--no-header"
|
||||||
|
],
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest full dense",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"-q",
|
||||||
|
"--no-header"
|
||||||
|
],
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest channel-by-name",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests/test_continuous.py",
|
||||||
|
"-q",
|
||||||
|
"--no-header"
|
||||||
|
],
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest example env isolated",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests/test_continuous.py::TestPlantEnv::test_pump_station_example_env",
|
||||||
|
"-q",
|
||||||
|
"--no-header"
|
||||||
|
],
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest env fix",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests/test_continuous.py",
|
||||||
|
"-q",
|
||||||
|
"--no-header"
|
||||||
|
],
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest full by-name",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"-q",
|
||||||
|
"--no-header"
|
||||||
|
],
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest full final",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"-q",
|
||||||
|
"--no-header"
|
||||||
|
],
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest envctl",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests/test_webui.py",
|
||||||
|
"-q",
|
||||||
|
"--no-header"
|
||||||
|
],
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest envctl2",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests/test_webui.py",
|
||||||
|
"-q",
|
||||||
|
"--no-header"
|
||||||
|
],
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest envctl3",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"tests/test_webui.py",
|
||||||
|
"-q",
|
||||||
|
"--no-header"
|
||||||
|
],
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest full m7",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"-q",
|
||||||
|
"--no-header"
|
||||||
|
],
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: serve m7 live",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\wago-sim.exe",
|
||||||
|
"args": [
|
||||||
|
"serve",
|
||||||
|
"--node",
|
||||||
|
"config/pump_node.yaml",
|
||||||
|
"--host",
|
||||||
|
"127.0.0.1",
|
||||||
|
"--port",
|
||||||
|
"5020",
|
||||||
|
"--web-port",
|
||||||
|
"8080",
|
||||||
|
"--env",
|
||||||
|
"envs.pump_station_env:PumpStationEnv"
|
||||||
|
],
|
||||||
|
"isBackground": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: serve m7 live2",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\wago-sim.exe",
|
||||||
|
"args": [
|
||||||
|
"serve",
|
||||||
|
"--node",
|
||||||
|
"config/pump_node.yaml",
|
||||||
|
"--host",
|
||||||
|
"127.0.0.1",
|
||||||
|
"--port",
|
||||||
|
"5020",
|
||||||
|
"--web-port",
|
||||||
|
"8080",
|
||||||
|
"--env",
|
||||||
|
"envs.pump_station_env:PumpStationEnv"
|
||||||
|
],
|
||||||
|
"isBackground": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "wago-sim: pytest full m7 final",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "c:\\Users\\jonas\\Documents\\Git\\vivab\\wago-modbustcp-simulator-py\\.venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"-q",
|
||||||
|
"--no-header"
|
||||||
|
],
|
||||||
|
"isBackground": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -22,9 +22,11 @@ a virtual node whose I/O behavior is driven by scripted scenarios.
|
|||||||
pulses, waits, and PASS/FAIL assertions with timeouts; state machines
|
pulses, waits, and PASS/FAIL assertions with timeouts; state machines
|
||||||
and Python action plugins for complex plant models.
|
and Python action plugins for complex plant models.
|
||||||
- **Two I/O addressing schemes** for aliases: *module-based* —
|
- **Two I/O addressing schemes** for aliases: *module-based* —
|
||||||
`{ type: di, module: 2, channel: 1 }` = `DI2.1` (module slot and
|
`{ type: di, module: 2, channel: 1 }` = `DI2.1` (module **numbered
|
||||||
channel both 0-based) — and *pool-based* — `{ type: di, channel: 3 }`
|
0-based within its type** — `DI0`, `DO0`, `AI0`, `AO0` start each
|
||||||
(1-based index across the node's DI pool). Both may be mixed.
|
kind — and channel 0-based; v1.5) — and *pool-based* —
|
||||||
|
`{ type: di, channel: 3 }` (1-based index across the node's DI
|
||||||
|
pool). Both may be mixed.
|
||||||
- **Bench mode** *(requested, planned — spec §9)*: run the simulator
|
- **Bench mode** *(requested, planned — spec §9)*: run the simulator
|
||||||
without a scenario (`wago-sim serve --node ...` already works);
|
without a scenario (`wago-sim serve --node ...` already works);
|
||||||
planned additions: `--set DI0.1=ON` startup seeding, an interactive
|
planned additions: `--set DI0.1=ON` startup seeding, an interactive
|
||||||
@@ -42,6 +44,15 @@ a virtual node whose I/O behavior is driven by scripted scenarios.
|
|||||||
config, 255 = DHCP), WBM-equivalent pages (Port/Ethernet, Modbus
|
config, 255 = DHCP), WBM-equivalent pages (Port/Ethernet, Modbus
|
||||||
settings, Modbus Mapping, I/O Config), and device-state persistence
|
settings, Modbus Mapping, I/O Config), and device-state persistence
|
||||||
with factory reset.
|
with factory reset.
|
||||||
|
- **Host network settings** *(implemented 2026-10-06 — spec §15)*: a
|
||||||
|
Docker-gated, opt-in settings page (`WAGO_SIM_HOSTNET=1` **and**
|
||||||
|
container detection) that lists the host OS's network interfaces
|
||||||
|
and adds/removes/changes their IPv4 addresses through a
|
||||||
|
whitelisted host-side agent — so simulated node re-addressing
|
||||||
|
(DIP dedicated-IP mode) and network-loss fault cases become real
|
||||||
|
on the bench network. Runtime-only changes, auto-snapshot with
|
||||||
|
restore, and an operator-tagged audit log; fully absent (404, tab
|
||||||
|
hidden) when the flag is off.
|
||||||
- **Long-duration (continuous) simulation** *(requested, planned — see
|
- **Long-duration (continuous) simulation** *(requested, planned — see
|
||||||
`docs/spec/software-spec.md` §8)*: endurance/soak testing over days or
|
`docs/spec/software-spec.md` §8)*: endurance/soak testing over days or
|
||||||
weeks of simulated time. Designated inputs are driven from streaming
|
weeks of simulated time. Designated inputs are driven from streaming
|
||||||
@@ -183,6 +194,95 @@ Configuration options: `--no-web`, `--web-port <n>` or env
|
|||||||
benches (all mutating endpoints answer 403). Plain HTTP — keep the
|
benches (all mutating endpoints answer 403). Plain HTTP — keep the
|
||||||
port inside the bench network.
|
port inside the bench network.
|
||||||
|
|
||||||
|
### Host network settings (spec §15, Docker only)
|
||||||
|
|
||||||
|
An additional **Host network** tab can list and manipulate the
|
||||||
|
**host OS's** IPv4 addresses from the browser — for FAT cases that
|
||||||
|
need network-level re-addressing or address-loss faults on the bench
|
||||||
|
network. It exists only when both gates pass:
|
||||||
|
|
||||||
|
1. `WAGO_SIM_HOSTNET=1` on the simulator container (opt-in flag), and
|
||||||
|
2. running inside Docker (compose sets `WAGO_SIM_IN_DOCKER=1`).
|
||||||
|
|
||||||
|
The container itself stays non-root and unprivileged: changes go
|
||||||
|
through a whitelisted **agent on the host**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# once, on the Linux host:
|
||||||
|
sudo cp deploy/wago-sim-hostnet-agent.service /etc/systemd/system/
|
||||||
|
sudo cp deploy/hostnet-agent.yaml /etc/wago-sim/hostnet.yaml
|
||||||
|
# edit the whitelist to your bench NIC(s), then:
|
||||||
|
sudo systemctl enable --now wago-sim-hostnet-agent
|
||||||
|
# journalctl -u wago-sim-hostnet-agent -f (audit trail lands here)
|
||||||
|
|
||||||
|
# compose: uncomment in docker-compose.yml —
|
||||||
|
# WAGO_SIM_HOSTNET: "1"
|
||||||
|
# - /run/wago-sim:/run/wago-sim
|
||||||
|
# and align the container user's group with the socket (0660).
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Safety rails: only whitelisted interfaces are mutable (refusals are
|
||||||
|
shown and audited); every change is preceded by an auto-snapshot with
|
||||||
|
a **Restore last change** button; changes are runtime-only — a host
|
||||||
|
reboot restores the deployed addressing; every mutation and refusal is
|
||||||
|
logged with the logged-in operator's name. If the agent is down the
|
||||||
|
tab shows a "host control unavailable" banner and mutations answer
|
||||||
|
503 — the simulator itself is unaffected.
|
||||||
|
|
||||||
|
## Plant environments (continuous simulation)
|
||||||
|
|
||||||
|
A **plant environment** simulates the physical world around the
|
||||||
|
distributed I/O: per deterministic tick it reads what the PLC
|
||||||
|
commanded (DO/AO), runs your plant physics, and writes what the field
|
||||||
|
now shows (DI/AI) — the sensors the PLC reads back. Environments run
|
||||||
|
**until manually stopped** (Ctrl+C / `docker stop`), optionally ending
|
||||||
|
sooner via an `is_done()` criterion.
|
||||||
|
|
||||||
|
Attach one to a live Modbus server (wall-clock-paced 10 ms ticks):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wago-sim serve --node config/pump_node.yaml --port 5020 \
|
||||||
|
--env envs.pump_station_env:PumpStationEnv
|
||||||
|
```
|
||||||
|
|
||||||
|
or run headless (unpaced, for plant-model development):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wago-sim sim --node config/pump_node.yaml \
|
||||||
|
--env envs.pump_station_env:PumpStationEnv
|
||||||
|
```
|
||||||
|
|
||||||
|
**Authoring one** is a small subclass — override `model()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# myenvs/tank.py
|
||||||
|
from wago_sim.scenario.plantenv import PlantEnv
|
||||||
|
|
||||||
|
class TankEnv(PlantEnv):
|
||||||
|
def model(self, state, dt_s):
|
||||||
|
pump_on = bool(state.by_code("DO0.0")) # PLC command
|
||||||
|
level = float(state.by_code("AI0.0")) # current field
|
||||||
|
return {"AI0.0": level + (-5.0 if pump_on else 1.0) * dt_s}
|
||||||
|
|
||||||
|
Env = TankEnv # `--env myenvs.tank` picks this up
|
||||||
|
```
|
||||||
|
|
||||||
|
API surface (spec FR-8.2.14–17): `observe()` returns every
|
||||||
|
DI/DO/AI/AO channel (I/O codes, names, values, analog display
|
||||||
|
ranges) plus aliases and degradation parameters; `apply()` writes DI/AI (the field);
|
||||||
|
`force()` writes DO/AO master-side (headless runs, fault setup);
|
||||||
|
`is_done()` optional end; `reset()` deterministic replay.
|
||||||
|
`state.channel(key)` returns the full channel record by I/O code
|
||||||
|
or configured name; `state.channel_range(key)` gives an analog
|
||||||
|
channel's (min, max) directly. Reference
|
||||||
|
forms: `pkg.mod:ClassName`, `pkg.mod:factory`, or `pkg.mod` with
|
||||||
|
`Env`/`make_env`. The shipped example is
|
||||||
|
`envs/pump_station_env.py` (tank + pump physics with a low-level
|
||||||
|
switch). Scenario-continuous mode (§8 YAML: datasets, degradation,
|
||||||
|
invariants) remains available for scripted soak runs on the same
|
||||||
|
engine ticks.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- `docs/spec/software-spec.md` — the software specification
|
- `docs/spec/software-spec.md` — the software specification
|
||||||
@@ -288,6 +388,40 @@ requirement ID for each item in parentheses.
|
|||||||
protocol tracing, diagnostics and fault injection (FR-14.2.5–
|
protocol tracing, diagnostics and fault injection (FR-14.2.5–
|
||||||
14.2.6); scoping questions to answer before planning (§14.3).
|
14.2.6); scoping questions to answer before planning (§14.3).
|
||||||
|
|
||||||
|
### Host network settings (spec §15) — IMPLEMENTED 2026-10-06
|
||||||
|
|
||||||
|
The §15 feature is implemented (v1 core operations): the
|
||||||
|
Docker-gated settings page with add/remove/change/restore, the
|
||||||
|
host-side agent, the safety rails, and the audit trail. 58 dedicated
|
||||||
|
tests (`tests/test_hostnet.py`); full suite 339 passing.
|
||||||
|
|
||||||
|
- [x] Host OS IP settings page (FR-15.2.1–15.2.3): exposed in the web
|
||||||
|
UI only when `WAGO_SIM_HOSTNET=1` **and** running inside Docker
|
||||||
|
(compose sets `WAGO_SIM_IN_DOCKER=1`; `/.dockerenv` as a
|
||||||
|
supplementary signal); authenticated page listing the host's
|
||||||
|
interfaces with link state and current IPv4 addresses, read
|
||||||
|
from live host state, with add/remove/change-address operations
|
||||||
|
validated client- and agent-side; routes answer 404 and the tab
|
||||||
|
stays hidden when disabled — no dormant surface.
|
||||||
|
- [x] Host-side agent + safety rails (FR-15.2.4–15.2.8):
|
||||||
|
`wago-sim hostnet-agent --config deploy/hostnet-agent.yaml`
|
||||||
|
runs on the host (systemd unit
|
||||||
|
`deploy/wago-sim-hostnet-agent.service`, root or `CAP_NET_ADMIN`,
|
||||||
|
hardened) — Unix socket mounted into the container
|
||||||
|
(`/run/wago-sim`), interface whitelist, runtime-only
|
||||||
|
(non-persistent) address changes so a host reboot restores
|
||||||
|
deployed addressing, auto-snapshot before every mutation with
|
||||||
|
restore, single-line audit log (operator-tagged, refusals
|
||||||
|
included; journald + optional file); degraded state (agent
|
||||||
|
unreachable) shows a banner and answers 503 without affecting
|
||||||
|
the rest of the UI; readonly mode keeps status readable and
|
||||||
|
403s every mutation.
|
||||||
|
- [ ] DIP dedicated-IP integration (FR-15.2.9): applying a DIP-switch
|
||||||
|
address change in dedicated-IP mode routes through the agent so
|
||||||
|
the derived IP lands on the host NIC — deferred until the §11
|
||||||
|
DIP-switch model ships; link up/down (cable pull) also deferred
|
||||||
|
(v2, FR-15.2.4).
|
||||||
|
|
||||||
### Web UI (spec §10) — in implementation
|
### Web UI (spec §10) — in implementation
|
||||||
|
|
||||||
- [x] Spec §10.3–10.6 updated with binding implementation decisions:
|
- [x] Spec §10.3–10.6 updated with binding implementation decisions:
|
||||||
@@ -323,7 +457,7 @@ requirement ID for each item in parentheses.
|
|||||||
statistics table (0x1029 words) with reset, LED-error
|
statistics table (0x1029 words) with reset, LED-error
|
||||||
(0x1020/0x1021) and module-diagnosis (0x1050) fault injection
|
(0x1020/0x1021) and module-diagnosis (0x1050) fault injection
|
||||||
and clear; SF LED on the coupler reflects injected LED errors.
|
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.
|
- [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. Modules re-orderable by drag-and-drop or ▲/▼ arrows (FR-11.2.6); with per-type I/O codes (FR-4.3.11 v1.5) cross-kind moves don't renumber other kinds' channels.
|
||||||
- [x] M5a: channel names for every module channel (FR-10.2.11) —
|
- [x] M5a: channel names for every module channel (FR-10.2.11) —
|
||||||
node-config persisted (`channel_names:` mapping, I/O code →
|
node-config persisted (`channel_names:` mapping, I/O code →
|
||||||
name), survive module saves and rebuilds, shown in the overview
|
name), survive module saves and rebuilds, shown in the overview
|
||||||
@@ -348,9 +482,34 @@ requirement ID for each item in parentheses.
|
|||||||
may be negative (-65534–65535; stored as 16-bit two's
|
may be negative (-65534–65535; stored as 16-bit two's
|
||||||
complement), for signed plant quantities. DI/DO channels render
|
complement), for signed plant quantities. DI/DO channels render
|
||||||
in vertical columns of eight (0–7 left, 8–15 next) on thin,
|
in vertical columns of eight (0–7 left, 8–15 next) on thin,
|
||||||
tall module bezels mirroring real DIN-rail housings.
|
tall module bezels mirroring real DIN-rail housings. Dense
|
||||||
|
store (2026-10-04): `node.channel_ranges` always carries every
|
||||||
|
analog channel of the current layout (unconfigured = the
|
||||||
|
materialized default, stale codes pruned on rebuild), so
|
||||||
|
environments read min/max without fallbacks; the YAML and the
|
||||||
|
config export stay lean (non-default entries only).
|
||||||
- [ ] M6: scenario run view + alias-ownership rejection (FR-10.2.6,
|
- [ ] M6: scenario run view + alias-ownership rejection (FR-10.2.6,
|
||||||
FR-10.2.9) — deferred.
|
FR-10.2.9) — deferred.
|
||||||
|
- [x] M7: plant environment control (FR-10.2.15, FR-10.2.17) — the
|
||||||
|
web UI loads/unloads environments at runtime (`--env` becomes the
|
||||||
|
startup default, not the only option): an environment card shows
|
||||||
|
ref, state (running/paused/done/none), sim-time and tick ms with
|
||||||
|
start (with hot reload of edited source, `sys.modules`
|
||||||
|
invalidation), stop (ticker cancelled, field freezes),
|
||||||
|
pause/resume (instance kept — pause is a deliberate FAT fault:
|
||||||
|
"plant stuck"), reset (deterministic fresh episode) and restart.
|
||||||
|
Exactly one env at a time; `is_done()` ends the ticker (state
|
||||||
|
`done`); SAVE CONFIG (module-list rebuild) discards the env like
|
||||||
|
a KBUS reset discards values; starting while loaded is refused;
|
||||||
|
readonly mode blocks every env mutation. API: `GET /api/env` +
|
||||||
|
`POST /api/env/{start,stop,pause,resume,reset,restart}`.
|
||||||
|
- [ ] M8: environment file authoring (FR-10.2.16, planned) —
|
||||||
|
create/modify/delete environment source files (e.g.
|
||||||
|
`envs/*.py`) through the web UI (editor pane + upload/download).
|
||||||
|
Security note: UI-written Python is RCE by design — same trust
|
||||||
|
boundary as the bench network; disabled in readonly mode; delete
|
||||||
|
refused while the env is loaded; the on-disk directory must be
|
||||||
|
explicit in configuration.
|
||||||
- [x] Tests: 46 web-UI tests (`tests/test_webui.py`) via httpx
|
- [x] Tests: 46 web-UI tests (`tests/test_webui.py`) via httpx
|
||||||
`ASGITransport` (in-process, no socket) + live verification:
|
`ASGITransport` (in-process, no socket) + live verification:
|
||||||
browser SAVE CONFIG rebuilds the node and persists YAML; Modbus
|
browser SAVE CONFIG rebuilds the node and persists YAML; Modbus
|
||||||
@@ -392,50 +551,66 @@ requirement ID for each item in parentheses.
|
|||||||
(FR-9.2.5). *(Plain `serve` without a scenario already works —
|
(FR-9.2.5). *(Plain `serve` without a scenario already works —
|
||||||
see Completed above.)*
|
see Completed above.)*
|
||||||
|
|
||||||
### Phase 3 — Long-duration (continuous) simulation (spec §8)
|
### Phase 3 — Long-duration (continuous) simulation (spec §8) — IMPLEMENTED 2026-10-04
|
||||||
The soak-testing feature requested 2026-10-03 — specified in §8,
|
The soak-testing feature specified in §8 is implemented: continuous
|
||||||
not yet implemented. Datasets are **optional**: signal logic
|
mode with the fixed tick pipeline, optional streaming CSV datasets,
|
||||||
(plugins, scheduled steps, degradation) runs in parallel with CSV
|
degradation models, invariants/rate limits with bounded episode
|
||||||
|
reporting, snapshots, and the gym-style `PlantEnv`. 30 new tests
|
||||||
|
(`tests/test_continuous.py`); the shipped example
|
||||||
|
`scenarios/pump_soak.example.yaml` demonstrates the full DSL.
|
||||||
|
Datasets are **optional**: signal logic runs in parallel with CSV
|
||||||
streaming, or entirely without it.
|
streaming, or entirely without it.
|
||||||
|
|
||||||
- [ ] `scenario/datasets.py` — streaming CSV playback for designated
|
- [x] Plant environment `scenario/plantenv.py` (FR-8.2.14–17, reworked
|
||||||
|
2026-10-04 to environment-first): standalone `PlantEnv` —
|
||||||
|
`observe()` returns ALL modules (DI/DO/AI/AO channels, per-type
|
||||||
|
codes, names, parameters); `apply()` writes the FIELD side
|
||||||
|
(DI/AI) by default; `force()` writes DO/AO master-side (mirrors/
|
||||||
|
statistics/watchdog stay consistent); `model()`/`is_done()`
|
||||||
|
override hooks (subclass authoring, FR-8.2.17); `reset()`
|
||||||
|
deterministic. Runners: `serve --env ...` wall-clock-paced
|
||||||
|
alongside Modbus, runs until stopped; `sim --env ...` headless
|
||||||
|
unpaced until is_done()/Ctrl+C. Example: `envs/pump_station_env.py`.
|
||||||
|
Verified live: PLC coil write → env physics → level registers
|
||||||
|
drain/refill over Modbus.
|
||||||
|
- [x] `scenario/datasets.py` — streaming CSV playback for designated
|
||||||
inputs (OPTIONAL driver): header-driven time column,
|
inputs (OPTIONAL driver): header-driven time column,
|
||||||
`interpolation: linear|hold`, `repeat` with seamless seams,
|
`interpolation: linear|hold`, `repeat` with seamless seams,
|
||||||
`time_factor` acceleration, `until` limits, bounded memory, ERROR
|
`time_factor` acceleration, `until` limits, bounded memory, ERROR
|
||||||
verdict with file/line on malformed data (FR-8.2.1–8.2.11).
|
verdict with file/line on malformed data (FR-8.2.1–8.2.11).
|
||||||
- [ ] Parallel logic pipeline — fixed per-tick order: datasets → edge
|
- [x] Parallel logic pipeline — fixed per-tick order: datasets → edge
|
||||||
detection → plant plugins → degradation → invariants/rate
|
detection → plant plugins → degradation → invariants/rate
|
||||||
limits → scheduled steps → snapshots; plugins may read
|
limits → scheduled steps → snapshots; plugins may read
|
||||||
dataset-driven and logic-driven aliases in the same tick
|
dataset-driven and logic-driven aliases in the same tick
|
||||||
(FR-8.2.12).
|
(FR-8.2.12).
|
||||||
- [ ] Logic-only continuous runs — no `datasets:` needed: demand
|
- [x] Logic-only continuous runs — no `datasets:` needed: demand
|
||||||
profiles generated inside plugins, top-level `until:` bounds the
|
profiles generated inside plugins, top-level `until:` bounds the
|
||||||
run; degradation/invariants/rate limits/snapshots apply
|
run; degradation/invariants/rate limits/snapshots apply
|
||||||
identically (FR-8.2.13).
|
identically (FR-8.2.13).
|
||||||
- [ ] Continuous-mode run-end + validation rules: run ends when all
|
- [x] Continuous-mode run-end + validation rules: run ends when all
|
||||||
drivers exhausted; scenario with no datasets, no scheduled steps
|
drivers exhausted; scenario with no datasets, no scheduled steps
|
||||||
and no `until:` rejected (FR-8.2.1).
|
and no `until:` rejected (FR-8.2.1).
|
||||||
- [ ] `scenario/degradation.py` — wear counters (runtime hours, start
|
- [x] `scenario/degradation.py` — wear counters (runtime hours, start
|
||||||
cycles, load-hours) and degradation models (`linear`,
|
cycles, load-hours) and degradation models (`linear`,
|
||||||
`exponential`, `staircase`, plugin shapes) computing plant
|
`exponential`, `staircase`, plugin shapes) computing plant
|
||||||
parameters such as `pump_capacity` (FR-8.3.1–8.3.2).
|
parameters such as `pump_capacity` (FR-8.3.1–8.3.2).
|
||||||
- [ ] Degradation hooks in plant plugins: `engine.parameter()` accessor
|
- [x] Degradation hooks in plant plugins: `engine.parameter()` accessor
|
||||||
used in loop physics, e.g. actual flow = nominal × capacity
|
used in loop physics, e.g. actual flow = nominal × capacity
|
||||||
(FR-8.3.3).
|
(FR-8.3.3).
|
||||||
- [ ] `maintenance_at` thresholds: diagnosis injection via register
|
- [x] `maintenance_at` thresholds: diagnosis injection via register
|
||||||
`0x1050`, maintenance-alarm assertions, `maintenance_reset`
|
`0x1050`, maintenance-alarm assertions, `maintenance_reset`
|
||||||
action restoring capacity and clearing diagnoses (FR-8.3.4–8.3.5,
|
action restoring capacity and clearing diagnoses (FR-8.3.4–8.3.5,
|
||||||
FR-8.3.8).
|
FR-8.3.8).
|
||||||
- [ ] `scenario/invariants.py` — per-tick `invariants` with bounded
|
- [x] `scenario/invariants.py` — per-tick `invariants` with bounded
|
||||||
episode aggregation and `rate_limits` over sliding one-hour
|
episode aggregation and `rate_limits` over sliding one-hour
|
||||||
windows (FR-8.4.1–8.4.2).
|
windows (FR-8.4.1–8.4.2).
|
||||||
- [ ] `snapshot_every` periodic dumps of aliases, wear counters, and
|
- [x] `snapshot_every` periodic dumps of aliases, wear counters, and
|
||||||
degradation parameters (FR-8.4.3).
|
degradation parameters (FR-8.4.3).
|
||||||
- [ ] Continuous-mode verdict rules and bounded reporting for unattended
|
- [x] Continuous-mode verdict rules and bounded reporting for unattended
|
||||||
multi-day runs (FR-8.4.4–8.4.5).
|
multi-day runs (FR-8.4.4–8.4.5).
|
||||||
- [ ] Scheduled sequence steps via `at: <duration>` interleaved with
|
- [x] Scheduled sequence steps via `at: <duration>` interleaved with
|
||||||
datasets on the sim-time axis (FR-8.4.6).
|
datasets on the sim-time axis (FR-8.4.6).
|
||||||
- [ ] Acceptance tests per spec §8.8 (streaming 100k-row CSV,
|
- [x] Acceptance tests per spec §8.8 (streaming 100k-row CSV,
|
||||||
interpolation checks, repeat determinism, time_factor math,
|
interpolation checks, repeat determinism, time_factor math,
|
||||||
alias-ownership validation, degradation/recovery round-trip,
|
alias-ownership validation, degradation/recovery round-trip,
|
||||||
episode recording, full 28-day soak, logic-only run, mixed
|
episode recording, full 28-day soak, logic-only run, mixed
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 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: pump_node
|
||||||
|
coupler: 750-362
|
||||||
|
|
||||||
|
modules:
|
||||||
|
- "750-430" # 8-channel DI 24VDC
|
||||||
|
- "750-1405" # 16-channel DI 24VDC
|
||||||
|
- "750-530" # 8-channel DO 24VDC
|
||||||
|
- "750-1516" # 16-channel DO 24VDC
|
||||||
|
- "750-467" # 2-channel AI 4-20mA
|
||||||
|
- "750-554" # 2-channel AO 4-20mA
|
||||||
|
|
||||||
|
# Application names for module channels (I/O code -> name)
|
||||||
|
channel_names:
|
||||||
|
DI0.0: P1_DI
|
||||||
|
DI0.1: P1_AUTO
|
||||||
|
DI0.2: P1_FEL
|
||||||
|
DI0.3: rsv1
|
||||||
|
DO0.1: P1_START
|
||||||
|
AO0.0: P1_BORV
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 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: pump_node
|
||||||
|
coupler: 750-362
|
||||||
|
|
||||||
|
modules:
|
||||||
|
- "750-1405" # 16-channel DI 24VDC
|
||||||
|
- "750-430" # 8-channel DI 24VDC
|
||||||
|
- "750-530" # 8-channel DO 24VDC
|
||||||
|
- "750-1516" # 16-channel DO 24VDC
|
||||||
|
- "750-467" # 2-channel AI 4-20mA
|
||||||
|
- "750-554" # 2-channel AO 4-20mA
|
||||||
|
|
||||||
|
# Application names for module channels (I/O code -> name)
|
||||||
|
channel_names:
|
||||||
|
AO0.0: speed_setpoint
|
||||||
|
DI0.0: start_button
|
||||||
@@ -14,5 +14,9 @@ modules:
|
|||||||
|
|
||||||
# Application names for module channels (I/O code -> name)
|
# Application names for module channels (I/O code -> name)
|
||||||
channel_names:
|
channel_names:
|
||||||
AO5.0: speed_setpoint
|
AI0.0: NG1
|
||||||
DI0.0: start_button
|
AO0.0: P!_BORV
|
||||||
|
DI0.0: P1_DI
|
||||||
|
DI0.1: P1_AUTO
|
||||||
|
DI0.2: P1_FEL
|
||||||
|
DO0.0: P!_START
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Host-side agent config for the web UI's host network settings page
|
||||||
|
# (spec §15, FR-15.2.7). Used by `wago-sim hostnet-agent --config ...`.
|
||||||
|
#
|
||||||
|
# This file lives on the DOCKER HOST (not inside the container), e.g.
|
||||||
|
# /etc/wago-sim/hostnet.yaml. The agent must run as root or with
|
||||||
|
# CAP_NET_ADMIN; the whitelist is the safety rail that keeps the
|
||||||
|
# privileged surface as small as the FAT bench needs.
|
||||||
|
#
|
||||||
|
# socket: the Unix domain socket the container connects through.
|
||||||
|
# docker-compose.yml mounts /run/wago-sim read-write into the
|
||||||
|
# simulator container, which is why this path is fixed by default.
|
||||||
|
# The agent refuses to start with an empty whitelist.
|
||||||
|
socket: /run/wago-sim/hostnet.sock
|
||||||
|
|
||||||
|
# whitelist: interfaces the settings page may manipulate. Operations
|
||||||
|
# on any other interface are refused by the agent and surfaced as
|
||||||
|
# errors on the page (FR-15.2.5). List the bench NIC(s) only.
|
||||||
|
whitelist:
|
||||||
|
- enp3s0
|
||||||
|
- eth0
|
||||||
|
|
||||||
|
# backend: how the agent performs changes on the host.
|
||||||
|
# iproute2 — the `ip` tool (+ pyroute2 for reads when installed);
|
||||||
|
# the production backend for Linux bench hosts (v1)
|
||||||
|
# fake — in-memory model: rehearses FAT cases and demos the page
|
||||||
|
# without touching any real interface
|
||||||
|
backend: iproute2
|
||||||
|
|
||||||
|
# audit_path: optional file for the single-line audit trail in
|
||||||
|
# addition to the service log (stdout/journald, FR-12.2.6). The
|
||||||
|
# directory is created if missing; leave out to log only.
|
||||||
|
audit_path: /var/log/wago-sim/hostnet-audit.log
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# systemd unit for the host-side hostnet agent (spec §15, FR-15.2.7).
|
||||||
|
#
|
||||||
|
# This runs ON THE DOCKER HOST — not in the container. It is the only
|
||||||
|
# component that holds the privileges to change host interface
|
||||||
|
# addresses (root or CAP_NET_ADMIN), scoped by the whitelist in its
|
||||||
|
# config file.
|
||||||
|
#
|
||||||
|
# Install:
|
||||||
|
# sudo cp wago-sim-hostnet-agent.service /etc/systemd/system/
|
||||||
|
# sudo cp hostnet-agent.yaml /etc/wago-sim/hostnet.yaml
|
||||||
|
# sudo systemctl daemon-reload
|
||||||
|
# sudo systemctl enable --now wago-sim-hostnet-agent
|
||||||
|
#
|
||||||
|
# Logs: journalctl -u wago-sim-hostnet-agent -f
|
||||||
|
# (the agent's audit lines appear here AND on the web page)
|
||||||
|
#
|
||||||
|
# The simulator container connects through /run/wago-sim/hostnet.sock —
|
||||||
|
# docker-compose.yml mounts /run/wago-sim into the container. The
|
||||||
|
# socket is created 0660 (root:wago-sim by the directives below); put
|
||||||
|
# the container's non-root user in the same group (compose passes the
|
||||||
|
# host group via group_add).
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=WAGO simulator hostnet agent (host IP settings page)
|
||||||
|
Documentation=file:///opt/wago-sim/docs/spec/software-spec.md
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# root keeps netlink + `ip` simple; hardening still applies below.
|
||||||
|
# Alternative: a dedicated user + AmbientCapabilities=CAP_NET_ADMIN.
|
||||||
|
User=root
|
||||||
|
Environment=WAGO_SIM_HOSTNET_AGENT_CONFIG=/etc/wago-sim/hostnet.yaml
|
||||||
|
ExecStart=/opt/wago-sim/.venv/bin/wago-sim hostnet-agent \
|
||||||
|
--config /etc/wago-sim/hostnet.yaml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
|
||||||
|
# Hardening: the agent needs network-admin on whitelisted interfaces
|
||||||
|
# and nothing else. The filesystem is locked down except for its
|
||||||
|
# config (read), socket directory and audit file (write). CAP_NET_RAW
|
||||||
|
# is included because netlink socket ops request it.
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ReadWritePaths=/run/wago-sim /var/log/wago-sim
|
||||||
|
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW
|
||||||
|
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -22,12 +22,26 @@ services:
|
|||||||
WAGO_SIM_PORT: "502"
|
WAGO_SIM_PORT: "502"
|
||||||
# Uncomment to trace every Modbus transaction in plain text:
|
# Uncomment to trace every Modbus transaction in plain text:
|
||||||
# WAGO_SIM_VERBOSE: "1"
|
# WAGO_SIM_VERBOSE: "1"
|
||||||
|
#
|
||||||
|
# Host network settings page (spec §15) — BOTH gates are needed:
|
||||||
|
# the flag plus this in-docker marker (the /.dockerenv file the
|
||||||
|
# runtime also creates is a supplementary signal only). The
|
||||||
|
# host-side agent (deploy/wago-sim-hostnet-agent.service) must be
|
||||||
|
# running on the host and its socket mounted below — otherwise
|
||||||
|
# the page shows "host control unavailable" and mutations 503.
|
||||||
|
# WAGO_SIM_HOSTNET: "1"
|
||||||
|
WAGO_SIM_IN_DOCKER: "1"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/app/config:ro
|
- ./config:/app/config:ro
|
||||||
- ./scenarios:/app/scenarios:ro
|
- ./scenarios:/app/scenarios:ro
|
||||||
- ./plugins:/app/plugins:ro
|
- ./plugins:/app/plugins:ro
|
||||||
# Report output for headless `run` invocations (spec §8 soak reports):
|
# Report output for headless `run` invocations (spec §8 soak reports):
|
||||||
- ./out:/app/out
|
- ./out:/app/out
|
||||||
|
# Host network settings (spec §15): the agent's Unix socket from
|
||||||
|
# the host, so the container never needs host privileges. Uncomment
|
||||||
|
# together with WAGO_SIM_HOSTNET above; group_add below must match
|
||||||
|
# the socket's group on the host.
|
||||||
|
# - /run/wago-sim:/run/wago-sim
|
||||||
# Deterministic sim is CPU-light; keep 1 CPU for a stable FAT bench
|
# Deterministic sim is CPU-light; keep 1 CPU for a stable FAT bench
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
+398
-33
@@ -7,7 +7,7 @@
|
|||||||
| Project | `wago-modbustcp-simulator-py` |
|
| Project | `wago-modbustcp-simulator-py` |
|
||||||
| Version | 1.4 (Draft for review) |
|
| Version | 1.4 (Draft for review) |
|
||||||
| Date | 2026-10-03 |
|
| 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); web UI §10 in implementation (M1–M5a done: auth, overview, manipulation, diagnostics, configuration, channel names, import/export, split overview; M6 scenario view open); long-duration simulation (§8), bench mode (§9), runtime device configuration (§11 — coupler/module/channel-name subset now covered by web UI M5/M5a), multi-vendor device profiles (§13) and additional fieldbus protocols PROFINET/COMLI (§14) requested |
|
| Status | Framework phase; module addressing implemented (FR-4.3.11 v1.5 per-type numbering); 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–M5a done: auth, overview, manipulation, diagnostics, configuration, channel names, import/export, split overview; M6 scenario view open); long-duration simulation (§8) implemented 2026-10-04 incl. PlantEnv (FR-8.2.14–16), datasets, degradation, invariants, snapshots; host network settings (§15) implemented 2026-10-06 (v1 ops; FR-15.2.9 DIP integration deferred); bench mode (§9), runtime device configuration (§11 — coupler/module/channel-name subset covered by web UI M5/M5a), 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 |
|
| 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) |
|
| Reference documents | `docs/m07500362_xxxxxxxx_0en.pdf` (750-362 Modbus TCP manual), Modbus TCP specification (modbus.org) |
|
||||||
|
|
||||||
@@ -303,14 +303,23 @@ sequence: # time-ordered script (or fully state-driven)
|
|||||||
- FR-4.3.11: **Module-based channel addressing** — aliases may be addressed
|
- FR-4.3.11: **Module-based channel addressing** — aliases may be addressed
|
||||||
as *module type + module number + channel number*, all components
|
as *module type + module number + channel number*, all components
|
||||||
explicitly configurable: `{ type: di, module: 2, channel: 1 }` selects
|
explicitly configurable: `{ type: di, module: 2, channel: 1 }` selects
|
||||||
digital input channel 1 (0-based) on module slot 2 (0-based position in
|
digital input channel 1 (0-based) on DI-module 2 (0-based **number
|
||||||
the node's module list), i.e. `DI2.1`. Both numbers are 0-based to match
|
among the modules of the same type**, i.e. the third DI module in the
|
||||||
physical slot/channel labeling. The channel kind must match the module
|
node), i.e. `DI2.1`. Channel numbers are 0-based. The channel kind must
|
||||||
(`DO` on a DI-only module is a validation error). This is an alternative
|
match the module (`DO` on a DI-only module is a validation error).
|
||||||
to pool addressing (`{ type: di, channel: 3 }` = 3rd DI channel across
|
This is an alternative to addressing by physical slot; both pool
|
||||||
the whole node); both schemes are supported and may be mixed, but an
|
addressing (`{ type: di, channel: 3 }` = 3rd DI channel across the
|
||||||
alias uses exactly one. **Implemented** in v1.1 (`channel_by_module`,
|
whole node) and module addressing are supported and may be mixed,
|
||||||
`Alias.module`, `alias_location`).
|
but an alias uses exactly one. **Implemented** in v1.1
|
||||||
|
(`channel_by_module`, `Alias.module`, `alias_location`).
|
||||||
|
**Changed in v1.5 (2026-10-04, breaking)**: module numbers previously
|
||||||
|
counted physical slots (all modules in one sequence); they now number
|
||||||
|
**within the module's type** — `DI0`/`DO0`/`AI0`/`AO0` start each kind's
|
||||||
|
sequence (e.g. a node `[DI_8, DI_16, DO_8, DO_16, AI_2, AO_2]` numbers
|
||||||
|
its DI modules `DI0`, `DI1` regardless of the DI/DO interleaving).
|
||||||
|
This matches how channels are named on real WAGO benches and makes
|
||||||
|
rearranging modules less disruptive to existing configs. Scenarios,
|
||||||
|
channel names, and analog ranges use the per-type codes.
|
||||||
|
|
||||||
### 4.4 Example FAT flow
|
### 4.4 Example FAT flow
|
||||||
|
|
||||||
@@ -469,8 +478,8 @@ stateDiagram-v2
|
|||||||
|
|
||||||
## 8. Feature Request — Long-Duration (Continuous) Simulation
|
## 8. Feature Request — Long-Duration (Continuous) Simulation
|
||||||
|
|
||||||
> **Status: requested 2026-10-03, planned (roadmap phase 3), not yet implemented.**
|
> **Status: implemented 2026-10-04** (roadmap phase 3 complete). Extends
|
||||||
> Extends the scenario engine (§4) with a `continuous` mode for endurance
|
> the scenario engine (§4) with a `continuous` mode for endurance
|
||||||
> (“soak”) testing: the facility runs for hours, days or weeks of simulated
|
> (“soak”) testing: the facility runs for hours, days or weeks of simulated
|
||||||
> time against evolving inputs and an aging plant.
|
> time against evolving inputs and an aging plant.
|
||||||
|
|
||||||
@@ -558,6 +567,12 @@ unchanged whether or not CSV streaming is used.
|
|||||||
(6) scheduled steps due at this tick execute,
|
(6) scheduled steps due at this tick execute,
|
||||||
(7) snapshots are taken if due.
|
(7) snapshots are taken if due.
|
||||||
This order is part of the specification and shall be covered by tests.
|
This order is part of the specification and shall be covered by tests.
|
||||||
|
**With the plant environment (FR-8.2.14)**, the decision source sees this
|
||||||
|
pipeline as one atomic `env` tick: `observe()` reads the state as of the
|
||||||
|
start of the tick, `apply()` queues the decided outputs, and the engine
|
||||||
|
then advances the pipeline once — outputs written by `apply()` land in
|
||||||
|
the output image before the next tick's `observe()`, so every state the
|
||||||
|
decision source sees is pipeline-consistent.
|
||||||
- FR-8.2.13: **Logic-only continuous runs**: with no `datasets:` block at
|
- FR-8.2.13: **Logic-only continuous runs**: with no `datasets:` block at
|
||||||
all, continuous mode shall work unchanged — demand profiles and other
|
all, continuous mode shall work unchanged — demand profiles and other
|
||||||
exogenous signals may be generated *inside* plant-model plugins (e.g., a
|
exogenous signals may be generated *inside* plant-model plugins (e.g., a
|
||||||
@@ -565,6 +580,57 @@ unchanged whether or not CSV streaming is used.
|
|||||||
top-level `until:` bounding the run. All §8.3–8.4 machinery (degradation,
|
top-level `until:` bounding the run. All §8.3–8.4 machinery (degradation,
|
||||||
invariants, rate limits, snapshots) applies identically, whether or not
|
invariants, rate limits, snapshots) applies identically, whether or not
|
||||||
any CSV streaming is configured.
|
any CSV streaming is configured.
|
||||||
|
- FR-8.2.14: **Plant environment abstraction** (requested 2026-10-04,
|
||||||
|
reworked same day to environment-first): `PlantEnv` is the **primary
|
||||||
|
interface** for continuous plant simulation, standalone-first — it
|
||||||
|
requires only a virtual node + datastore (no scenario), with the §8.3/8.4
|
||||||
|
machinery (degradation, invariants) as optional attachments. Per
|
||||||
|
deterministic tick (the engine or the runner advances the tick clock,
|
||||||
|
never wall-clock arithmetic): **`state = env.observe()`** returns the
|
||||||
|
state of ALL modules — DI/DO/AI/AO channels, engineering units, per-type
|
||||||
|
I/O codes, configured names, analog display ranges (FR-10.2.14),
|
||||||
|
plus active aliases and parameters — →
|
||||||
|
`env.model(state, dt)` (the caller-supplied plant physics) → **`apply()`
|
||||||
|
writes the new field state**. The environment owns no policy; plant
|
||||||
|
models are supplied by subclassing (FR-8.2.17).
|
||||||
|
- FR-8.2.15: **Action semantics**: `apply()` primarily writes the **input
|
||||||
|
side (DI/AI)** — the simulated field: what the sensors show, driven by
|
||||||
|
plant physics reacting to the PLC's output commands. Writing outputs
|
||||||
|
(DO/AO) is allowed as explicit **forcing** via a `force=True` keyword or
|
||||||
|
`env.force(...)` — for headless runs without a PLC and for bench
|
||||||
|
fault setup; forced outputs go through the datastore's internal write
|
||||||
|
paths so mirrors/statistics/watchdog stay consistent (FR-9.2.6).
|
||||||
|
Exactly one decision source may own an alias per tick (ownership
|
||||||
|
conflicts are validation errors, extending FR-8.2.2).
|
||||||
|
- FR-8.2.16: **Environment API surface** (concrete):
|
||||||
|
`env.observe() -> PlantState` (all channels with names and analog
|
||||||
|
display ranges, aliases, parameters, sim-time); `env.apply(values, force=False)` (keys: I/O code or alias
|
||||||
|
name; DI/AI default, DO/AO require force; unknown keys are errors);
|
||||||
|
`env.model(state, dt_s)` — override point for plant physics, returning
|
||||||
|
new field values; `env.is_done(state) -> bool` — optional end
|
||||||
|
criterion, default False (runs until manually stopped);
|
||||||
|
`env.reset()` — deterministic fresh episode; `env.tick_ms` /
|
||||||
|
`env.sim_time_ms` (read-only clock); `env.parameters` /
|
||||||
|
`env.invariants` attachments. `PlantState.channel(key)` returns
|
||||||
|
the full channel record by I/O code or by configured application
|
||||||
|
name (FR-10.2.11); `by_code()` returns values by I/O code;
|
||||||
|
`channel_range(key)` returns an analog channel's display range
|
||||||
|
(min, max) — always defined for AI/AO (dense store, FR-10.2.14).
|
||||||
|
- FR-8.2.17: **Environment authoring & runners**: environments are easy
|
||||||
|
to create and call — a subclass of `PlantEnv` overriding `model()`
|
||||||
|
(and optionally `is_done()`) in any importable module
|
||||||
|
(`--env myenvs.pump_station.PumpStationEnv`), or a module-level
|
||||||
|
factory function. Three runners: (a) `wago-sim serve --env ...` ticks
|
||||||
|
the environment on a **wall-clock-paced** periodic task (10 ms tick
|
||||||
|
alongside the Modbus server, real-time behavior for a live PLC) and
|
||||||
|
runs **until manually stopped** (SIGINT/SIGTERM — the existing
|
||||||
|
graceful-shutdown path); the ticker itself is owned by an
|
||||||
|
`EnvRunner` on the app so the web UI can stop/pause/resume/reset/
|
||||||
|
restart it at runtime (FR-10.2.15); (b) `wago-sim sim --node ...
|
||||||
|
--env ...` runs headless unpaced (as fast as determinism allows)
|
||||||
|
until `is_done()` or SIGINT. The scenario-continuous mode
|
||||||
|
(FR-8.2.1-13) remains a scripted layer on top of the same engine
|
||||||
|
ticks.
|
||||||
|
|
||||||
### 8.3 Requirements — degradation models (plant logic over time)
|
### 8.3 Requirements — degradation models (plant logic over time)
|
||||||
|
|
||||||
@@ -573,8 +639,11 @@ unchanged whether or not CSV streaming is used.
|
|||||||
load-hours (integral of the analog value). Counters are deterministic state.
|
load-hours (integral of the analog value). Counters are deterministic state.
|
||||||
- FR-8.3.2: A **degradation model** computes a named plant parameter (e.g.
|
- FR-8.3.2: A **degradation model** computes a named plant parameter (e.g.
|
||||||
`pump_capacity`, range 0…1) from wear counters and sim-time. Built-in
|
`pump_capacity`, range 0…1) from wear counters and sim-time. Built-in
|
||||||
shapes: `linear` (per runtime hour, per start cycle), `exponential`,
|
shapes: `linear` (per runtime hour via `rate:`, or per start cycle with
|
||||||
`staircase` (threshold table); custom shapes via Python plugin.
|
`per: cycles`), `exponential`, `staircase` (`staircase:` level table).
|
||||||
|
A model is configured with `observes:` naming the alias whose wear
|
||||||
|
counts feed it (NB: the key is `observes` — a YAML `on` key would be
|
||||||
|
parsed as boolean True by YAML 1.1).
|
||||||
- FR-8.3.3: Plant-model plugins read degraded parameters via
|
- FR-8.3.3: Plant-model plugins read degraded parameters via
|
||||||
`engine.parameter("pump_capacity")` and shall use them in the loop physics
|
`engine.parameter("pump_capacity")` and shall use them in the loop physics
|
||||||
(e.g., actual pump flow = nominal flow × capacity), so the PLC must
|
(e.g., actual pump flow = nominal flow × capacity), so the PLC must
|
||||||
@@ -649,31 +718,30 @@ datasets:
|
|||||||
repeat: 4
|
repeat: 4
|
||||||
|
|
||||||
degradation:
|
degradation:
|
||||||
pump_wear:
|
pump_capacity:
|
||||||
|
observes: pump_relay # wear counts from this alias
|
||||||
model: linear
|
model: linear
|
||||||
parameter: pump_capacity # consumed by the plant model
|
rate: 0.0004 # -0.04 %/h runtime → ~10 % after ~10.4 sim-days
|
||||||
per_runtime_hour: 0.0004 # 0.04 %/h → ~10 % after ~10.4 sim-days
|
from: 1.0
|
||||||
per_start_cycle: 0.0002 # 0.02 % per start
|
maintenance_at: 0.90 # threshold crossing injects diagnosis
|
||||||
floor: 0.80
|
diagnosis: { module: 4, channel: 1, value: 0x0001 }
|
||||||
maintenance_at: 0.90
|
# the PLC raises maintenance_alarm; the scenario asserts it:
|
||||||
on_threshold: # fires when capacity crosses 90 %
|
|
||||||
- inject_diagnosis: { module: 4, channel: 1, bits: [user_overrange] }
|
|
||||||
- assert: { label: "maintenance alarm raised",
|
|
||||||
conditions: { maintenance_alarm: ON }, within: 60s }
|
|
||||||
on_reset: # after maintenance_reset
|
|
||||||
- clear_diagnosis: { module: 4 }
|
|
||||||
|
|
||||||
sequence: # scheduled events (sim-time)
|
sequence: # scheduled events (sim-time)
|
||||||
- at: 12d
|
- at: 12d
|
||||||
maintenance_reset: pump_wear # service visit: capacity restored
|
maintenance_reset: true # service visit: capacity restored
|
||||||
|
- at: 12d
|
||||||
|
assert: { label: "maintenance alarm raised",
|
||||||
|
conditions: { maintenance_alarm: ON }, within: 60s }
|
||||||
|
|
||||||
plugins: [plugins.pump_plant.pump_plant] # loop: d(level)/dt = inflow − pump_flow(speed, capacity)
|
plugins: [plugins.pump_plant.pump_plant] # loop: d(level)/dt = inflow − pump_flow(speed, capacity)
|
||||||
|
|
||||||
invariants:
|
invariants:
|
||||||
- { alias: level_sensor, min: 200, max: 9000, label: "tank level bounds" }
|
- { alias: level_sensor, condition: '>= 200', label: "tank level floor" }
|
||||||
|
- { alias: level_sensor, condition: '<= 9000', label: "tank level brim" }
|
||||||
|
|
||||||
rate_limits:
|
rate_limits:
|
||||||
- { alias: pump_relay, edge: rising, max_per_hour: 6, label: "pump start cycles" }
|
- { alias: speed_setpoint, max_per_hour: 3000 } # ramp-rate bound
|
||||||
|
|
||||||
report:
|
report:
|
||||||
snapshot_every: 1h
|
snapshot_every: 1h
|
||||||
@@ -735,15 +803,20 @@ src/wago_sim/scenario/
|
|||||||
│ # time_factor — OPTIONAL driver in continuous mode
|
│ # time_factor — OPTIONAL driver in continuous mode
|
||||||
├── degradation.py # WearCounters, DegradationModel registry, maintenance actions
|
├── degradation.py # WearCounters, DegradationModel registry, maintenance actions
|
||||||
├── invariants.py # invariant & rate-limit evaluation, episode aggregation
|
├── invariants.py # invariant & rate-limit evaluation, episode aggregation
|
||||||
|
├── plantenv.py # PlantEnv: gym-style observe/apply/reset cycle over the
|
||||||
|
│ # process images — the decision-source interface
|
||||||
|
│ # (FR-8.2.14–16); plugins become thin adapters
|
||||||
└── engine.py # mode: continuous, parameter() accessor, snapshot
|
└── engine.py # mode: continuous, parameter() accessor, snapshot
|
||||||
# scheduler; fixed tick pipeline per FR-8.2.12:
|
# scheduler; fixed tick pipeline per FR-8.2.12:
|
||||||
# datasets -> edges -> plugins -> degradation ->
|
# datasets -> edges -> plugins/env -> degradation ->
|
||||||
# invariants -> scheduled steps -> snapshots
|
# invariants -> scheduled steps -> snapshots
|
||||||
```
|
```
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
erDiagram
|
erDiagram
|
||||||
ContinuousScenario ||--o{ Dataset : "datasets (optional)"
|
ContinuousScenario ||--o{ Dataset : "datasets (optional)"
|
||||||
|
ContinuousScenario ||--|| PlantEnv : "environment (FR-8.2.14)"
|
||||||
|
PlantEnv ||--o{ DecisionSource : "plugins / external agents"
|
||||||
Dataset ||--|{ ColumnMapping : "alias columns"
|
Dataset ||--|{ ColumnMapping : "alias columns"
|
||||||
ContinuousScenario ||--|{ PlantPlugin : "signal logic (always runs)"
|
ContinuousScenario ||--|{ PlantPlugin : "signal logic (always runs)"
|
||||||
ContinuousScenario ||--|{ DegradationModel : degradation
|
ContinuousScenario ||--|{ DegradationModel : degradation
|
||||||
@@ -755,6 +828,7 @@ erDiagram
|
|||||||
ContinuousScenario ||--o{ Snapshot : "periodic dumps"
|
ContinuousScenario ||--o{ Snapshot : "periodic dumps"
|
||||||
PlantPlugin }o--|| DegradationModel : "reads parameters"
|
PlantPlugin }o--|| DegradationModel : "reads parameters"
|
||||||
PlantPlugin }o--o{ Alias : "reads dataset-driven + computes logic-driven"
|
PlantPlugin }o--o{ Alias : "reads dataset-driven + computes logic-driven"
|
||||||
|
PlantEnv }o--|| ProcessImage : "observe/apply via datastore"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8.7 Non-functional requirements (long-duration)
|
### 8.7 Non-functional requirements (long-duration)
|
||||||
@@ -800,6 +874,18 @@ erDiagram
|
|||||||
tick-order test.
|
tick-order test.
|
||||||
11. A continuous scenario with no datasets, no scheduled steps, and no
|
11. A continuous scenario with no datasets, no scheduled steps, and no
|
||||||
`until:` is rejected at validation time (FR-8.2.1).
|
`until:` is rejected at validation time (FR-8.2.1).
|
||||||
|
12. **PlantEnv cycle** (FR-8.2.14): `observe()` returns every input channel
|
||||||
|
(values, names, codes, ranges, sim-time); `apply()` lands the decided
|
||||||
|
outputs in the output image before the next `observe()` — verified by a
|
||||||
|
deterministic two-tick test where the decision source copies an input
|
||||||
|
to an output and the second observation contains it.
|
||||||
|
13. **Plugin parity** (FR-8.2.15): the same plant logic runs unchanged as
|
||||||
|
a classic `plugins:` entry and as an `env.observe/apply` decision
|
||||||
|
source — identical outputs over a 1000-tick headless run (regression
|
||||||
|
gate for the adapter).
|
||||||
|
14. **External agent**: a decision source stepping the env over a
|
||||||
|
socket (non-Python agent) produces identical results to the in-process
|
||||||
|
variant on the same scenario (FR-8.2.15).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -910,6 +996,10 @@ Terminal monitors (§9) suffice for single engineers, but FAT benches are
|
|||||||
defined); `pulse` (with duration) and `ramp` (target + duration) actions
|
defined); `pulse` (with duration) and `ramp` (target + duration) actions
|
||||||
on DI/DO/AI. All writes go through the datastore's Modbus-equivalent
|
on DI/DO/AI. All writes go through the datastore's Modbus-equivalent
|
||||||
paths (FR-9.2.6) so mirrors, watchdog, and statistics stay consistent.
|
paths (FR-9.2.6) so mirrors, watchdog, and statistics stay consistent.
|
||||||
|
Clicking an analog **bar indicator** (requested 2026-10-04) sets the
|
||||||
|
value proportionally to the click position within the channel's
|
||||||
|
display range (FR-10.2.14) — AI = field side, AO = master-side force;
|
||||||
|
exact values remain enterable via the channel list.
|
||||||
- FR-10.2.5: **Diagnostics view**: watchdog status (0x1006), statistics
|
- FR-10.2.5: **Diagnostics view**: watchdog status (0x1006), statistics
|
||||||
block (0x1029, with reset button), module diagnosis (0x1050), LED error
|
block (0x1029, with reset button), module diagnosis (0x1050), LED error
|
||||||
registers (0x1020/0x1021) — mirroring WBM pages of the real device —
|
registers (0x1020/0x1021) — mirroring WBM pages of the real device —
|
||||||
@@ -928,7 +1018,8 @@ Terminal monitors (§9) suffice for single engineers, but FAT benches are
|
|||||||
message (alias-ownership rules of §8 FR-8.2.2 apply).
|
message (alias-ownership rules of §8 FR-8.2.2 apply).
|
||||||
- FR-10.2.10: The UI's **configuration** pages (Ethernet/port settings,
|
- FR-10.2.10: The UI's **configuration** pages (Ethernet/port settings,
|
||||||
Modbus watchdog settings, I/O Config with SAVE CONFIG, Modbus Mapping
|
Modbus watchdog settings, I/O Config with SAVE CONFIG, Modbus Mapping
|
||||||
overview, address-selection DIP switch) are specified in §11; §10 covers
|
overview, address-selection DIP switch) are specified in §11; the
|
||||||
|
Docker-gated host network settings page is specified in §15. §10 covers
|
||||||
value/state oversight and manipulation.
|
value/state oversight and manipulation.
|
||||||
- FR-10.2.11: **Channel names** (requested 2026-10-03): every module
|
- FR-10.2.11: **Channel names** (requested 2026-10-03): every module
|
||||||
channel can carry an application-specific name (e.g. `start_button`,
|
channel can carry an application-specific name (e.g. `start_button`,
|
||||||
@@ -974,6 +1065,52 @@ Terminal monitors (§9) suffice for single engineers, but FAT benches are
|
|||||||
onto the bar). Simulated AI values and AO forces accept negative
|
onto the bar). Simulated AI values and AO forces accept negative
|
||||||
numbers within the same window; values are stored in the image
|
numbers within the same window; values are stored in the image
|
||||||
as their 16-bit two's-complement representation.
|
as their 16-bit two's-complement representation.
|
||||||
|
**Dense runtime store** (2026-10-04): `node.channel_ranges` is
|
||||||
|
always complete — every analog channel of the current layout has
|
||||||
|
an entry (unconfigured channels carry the materialized default),
|
||||||
|
codes that no longer match an analog channel are pruned on
|
||||||
|
rebuild, so envs and API consumers can read ranges without
|
||||||
|
default-fallback guesswork. Persistence (node YAML, config
|
||||||
|
export) stays **lean**: only explicit non-default entries are
|
||||||
|
written, so defaults survive a future DEFAULT_RANGE change.
|
||||||
|
- FR-10.2.15: **Plant environment control** (requested 2026-10-04):
|
||||||
|
the web UI can load/unload environments at runtime — the CLI's
|
||||||
|
`--env` becomes the startup default, not the only option. An
|
||||||
|
**environment card** shows the loaded ref, its state (running /
|
||||||
|
paused / done / none), sim-time, and tick ms; controls: **start**
|
||||||
|
(load + tick), **stop** (cancel the ticker, field freezes),
|
||||||
|
**pause/resume** (ticker cancelled or rescheduled, instance
|
||||||
|
kept — pause is a deliberate FAT fault: "plant stuck"),
|
||||||
|
**reset** (`env.reset()`, deterministic fresh episode,
|
||||||
|
NFR-8.7.3), **restart** (stop + start of the same ref, fresh
|
||||||
|
instance). Rules: exactly one environment at a time (one decision
|
||||||
|
source, FR-8.2.15); `is_done()` ends the ticker (state `done`, no
|
||||||
|
auto-restart); a module-list rebuild (SAVE CONFIG, FR-11.2.1)
|
||||||
|
**discards** the env — its bindings (channel bindings in the
|
||||||
|
model, sim state) belong to the old layout, exactly like values
|
||||||
|
are lost on a KBUS reset; starting a new env is refused while
|
||||||
|
one is loaded (stop first — explicit, no silent replacement);
|
||||||
|
readonly mode (FR-10.2.8) disables every mutating control.
|
||||||
|
- FR-10.2.16: **Environment file authoring** (requested 2026-10-04,
|
||||||
|
planned): create/modify/delete environment source files (e.g.
|
||||||
|
`envs/*.py`) through the web UI — an editor pane with the plain
|
||||||
|
PlantEnv subclass pattern (FR-8.2.17), plus upload/download.
|
||||||
|
Security note: the UI writing importable Python is remote code
|
||||||
|
execution **by design**; it requires the same trust boundary as
|
||||||
|
the bench network (auth, FR-10.2.8) and SHALL be disabled in
|
||||||
|
readonly mode; delete is refused while the env is loaded; the
|
||||||
|
on-disk directory must be explicit in configuration (today
|
||||||
|
`envs/` resolves from the working directory, not the installed
|
||||||
|
package). Status: planned, roadmap phase 5.
|
||||||
|
- FR-10.2.17: **Hot reload of environment code** (requested
|
||||||
|
2026-10-04, implemented same day as part of FR-10.2.15): Python
|
||||||
|
caches modules in `sys.modules`; a restart of an env whose
|
||||||
|
source changed on disk would silently reuse the old class.
|
||||||
|
`load_env(ref, reload=True)` — used by the UI's start/restart —
|
||||||
|
invalidates the cached module before import so a fresh instance
|
||||||
|
always picks up the edited code. The CLI's startup `--env` and
|
||||||
|
the headless `sim` runner do not reload (the process is new
|
||||||
|
anyway).
|
||||||
|
|
||||||
### 10.3 Implementation decisions (binding, 2026-10-03)
|
### 10.3 Implementation decisions (binding, 2026-10-03)
|
||||||
|
|
||||||
@@ -1036,6 +1173,10 @@ src/wago_sim/
|
|||||||
| `GET/PUT /api/config/modules` | module list CRUD + process-image rebuild | §11 FR-11.2.1–11.2.4 |
|
| `GET/PUT /api/config/modules` | module list CRUD + process-image rebuild | §11 FR-11.2.1–11.2.4 |
|
||||||
| `GET /api/config/export` · `POST /api/config/import` | full configuration (modules + channel names + coupler) as JSON | 10.2.12 |
|
| `GET /api/config/export` · `POST /api/config/import` | full configuration (modules + channel names + coupler) as JSON | 10.2.12 |
|
||||||
| `GET/PUT /api/config/channel-names` | read/write per-channel names | 10.2.11 |
|
| `GET/PUT /api/config/channel-names` | read/write per-channel names | 10.2.11 |
|
||||||
|
| `GET/PUT /api/config/channel-ranges` | read/write analog display ranges | 10.2.14 |
|
||||||
|
| `GET /api/env` | environment status: ref, state, sim-time, tick ms | 10.2.15 |
|
||||||
|
| `POST /api/env/start` | load + start an env by ref (refuses while one is loaded) | 10.2.15 |
|
||||||
|
| `POST /api/env/stop` · `pause` · `resume` · `reset` · `restart` | ticker lifecycle; reset = deterministic fresh episode | 10.2.15 |
|
||||||
|
|
||||||
### 10.6 Milestones
|
### 10.6 Milestones
|
||||||
|
|
||||||
@@ -1047,7 +1188,10 @@ src/wago_sim/
|
|||||||
| M4 | Diagnostics — watchdog/statistics/diagnosis view, reset, fault injection |
|
| M4 | Diagnostics — watchdog/statistics/diagnosis view, reset, fault injection |
|
||||||
| M5 | Configuration — coupler forms; module CRUD with image rebuild and persistence |
|
| M5 | Configuration — coupler forms; module CRUD with image rebuild and persistence |
|
||||||
| M5a | Channel names (FR-10.2.11) + configuration import/export (FR-10.2.12) + split overview with channel list (FR-10.2.13) |
|
| M5a | Channel names (FR-10.2.11) + configuration import/export (FR-10.2.12) + split overview with channel list (FR-10.2.13) |
|
||||||
|
| M5b | Analog display ranges (FR-10.2.14) |
|
||||||
| M6 | (deferred) Scenario run view (FR-10.2.6) + alias-ownership rejection (FR-10.2.9) |
|
| M6 | (deferred) Scenario run view (FR-10.2.6) + alias-ownership rejection (FR-10.2.9) |
|
||||||
|
| M7 | Plant environment control (FR-10.2.15 + FR-10.2.17): runtime load/stop/pause/resume/reset/restart + hot reload |
|
||||||
|
| M8 | (planned) Environment file authoring (FR-10.2.16) |
|
||||||
|
|
||||||
### 10.7 Acceptance criteria
|
### 10.7 Acceptance criteria
|
||||||
|
|
||||||
@@ -1063,6 +1207,13 @@ src/wago_sim/
|
|||||||
with a visible message; the scenario continues unaffected.
|
with a visible message; the scenario continues unaffected.
|
||||||
6. Two simultaneous browsers stay consistent with each other and with
|
6. Two simultaneous browsers stay consistent with each other and with
|
||||||
the Modbus image.
|
the Modbus image.
|
||||||
|
7. **Environment control** (FR-10.2.15): an env started from the UI ticks
|
||||||
|
paced (its field changes visible to a Modbus master and the overview);
|
||||||
|
pause freezes the field; reset returns to the initial state; a
|
||||||
|
module-list SAVE CONFIG discards the loaded env (status none); an
|
||||||
|
edit to the env source on disk + restart runs the new code (hot
|
||||||
|
reload); starting while loaded is refused; readonly blocks all
|
||||||
|
env mutations.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1122,6 +1273,15 @@ This feature request closes that gap.
|
|||||||
- FR-11.2.5: A reconfiguration event is logged (sim-time, change
|
- FR-11.2.5: A reconfiguration event is logged (sim-time, change
|
||||||
description, new image sizes) and visible in the web UI's I/O Config
|
description, new image sizes) and visible in the web UI's I/O Config
|
||||||
page, mirroring the WBM SAVE CONFIG flow.
|
page, mirroring the WBM SAVE CONFIG flow.
|
||||||
|
- FR-11.2.6: **Module reordering** (requested 2026-10-04, implemented
|
||||||
|
same day): the web UI's I/O configuration pane supports rearranging
|
||||||
|
modules two ways — drag-and-drop of module rows (HTML5 DnD) and
|
||||||
|
move up/down arrow controls per slot; both are one gesture each and
|
||||||
|
trigger the same process-image rebuild + persistence as any module
|
||||||
|
change (FR-11.2.1–11.2.3). Because I/O codes number modules per type
|
||||||
|
(FR-4.3.11 v1.5), moving a module past a module of a different kind
|
||||||
|
does not renumber the other kind's channels — only the relative
|
||||||
|
order within the same type affects addressing.
|
||||||
|
|
||||||
### 11.3 Requirements — address-selection DIP switch emulation
|
### 11.3 Requirements — address-selection DIP switch emulation
|
||||||
|
|
||||||
@@ -1435,7 +1595,212 @@ the same process-image core.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 15. Roadmap (post-framework)
|
## 15. Feature Request — Host Network Settings (Docker Bench Host)
|
||||||
|
|
||||||
|
> **Status: implemented 2026-10-06 (v1 core ops; link up/down stays
|
||||||
|
> v2 per FR-15.2.4).** A settings page in the web UI that views and
|
||||||
|
> manipulates the **host OS's IP addresses**, exposed only when an
|
||||||
|
> explicit environment flag is set **and** the simulator runs inside
|
||||||
|
> Docker. Implemented as specified: double-gated page (404 when off —
|
||||||
|
> no dormant surface), whitelisted host-side agent over a mounted
|
||||||
|
> Unix socket, runtime-only changes with auto-snapshot/undo, and the
|
||||||
|
> operator-tagged audit trail. FR-15.2.9 (DIP dedicated-IP routing
|
||||||
|
> through the agent) remains deferred until §11 ships the DIP model.
|
||||||
|
> 58 dedicated tests in `tests/test_hostnet.py`; full suite 339 green.
|
||||||
|
|
||||||
|
### 15.1 Motivation
|
||||||
|
|
||||||
|
A containerized simulator (§12) cannot reach beyond its own network
|
||||||
|
namespace. It can rebind its listen address (FR-11.3.3 dedicated-IP
|
||||||
|
mode), but the **host's** network interfaces — what the bench network
|
||||||
|
and the PLC under test actually see — are outside the container. As a
|
||||||
|
result, FAT cases that depend on *network-level* events cannot be
|
||||||
|
staged from inside the simulator today:
|
||||||
|
|
||||||
|
- "Re-addressing" the virtual coupler (DIP switch, FR-11.3.1–11.3.3)
|
||||||
|
changes nothing on the bench network in the default bridge
|
||||||
|
deployment: the PLC keeps talking to the unchanged host address, so
|
||||||
|
the reconnect/re-addressing logic in the PLC program is never
|
||||||
|
exercised.
|
||||||
|
- Cable-pull / node-power-off fault cases require stopping the whole
|
||||||
|
container (coarse — every port drops at once) or shell access to the
|
||||||
|
bench host, breaking the "operate everything from the browser"
|
||||||
|
workflow of the web UI (§10).
|
||||||
|
- Moving the simulated node to a different subnet mid-bench
|
||||||
|
(multi-bench swaps, address conflicts) likewise needs host access.
|
||||||
|
|
||||||
|
The requested feature closes the gap: an opt-in settings page through
|
||||||
|
which an authenticated operator changes the host OS's IP addresses
|
||||||
|
from the bench UI — making the simulated node's network identity
|
||||||
|
**real on the bench network**, and making network-level fault cases
|
||||||
|
first-class FAT scenarios.
|
||||||
|
|
||||||
|
### 15.2 Requirements
|
||||||
|
|
||||||
|
- FR-15.2.1: **Opt-in via environment flag.** The feature is disabled
|
||||||
|
unless `WAGO_SIM_HOSTNET` is set truthy (same semantics as
|
||||||
|
`WAGO_SIM_VERBOSE`: `1`/`true`/`yes`/`on`); the flag lives in the
|
||||||
|
compose file so enabling it is a visible, deliberate deployment
|
||||||
|
change. Default: off. When off, the page is absent and every
|
||||||
|
`/api/hostnet/*` route answers 404 — no dormant surface.
|
||||||
|
- FR-15.2.2: **Docker-only.** Independently, the simulator must detect
|
||||||
|
a container environment — binding: the compose file sets
|
||||||
|
`WAGO_SIM_IN_DOCKER=1` (supplementary signal: `/.dockerenv` exists).
|
||||||
|
Outside docker the routes stay 404 regardless of the flag: a
|
||||||
|
bare-metal operator has direct access to the OS network settings;
|
||||||
|
the page exists precisely because a container does not.
|
||||||
|
- FR-15.2.3: **Settings page.** A page in the web UI's configuration
|
||||||
|
area (alongside the Port/Ethernet page, FR-11.4.1) listing the
|
||||||
|
host's network interfaces — name, link state, current IPv4
|
||||||
|
addresses — read from **live host state**, not a desired-state
|
||||||
|
mirror, so out-of-band changes made on the host appear on refresh.
|
||||||
|
The page also shows the agent's health and the effective whitelist.
|
||||||
|
- FR-15.2.4: **Operations.** Add an address (address + prefix length),
|
||||||
|
remove an address, replace an interface's primary address — each
|
||||||
|
validated client-side and agent-side (address/prefix syntax,
|
||||||
|
interface exists, interface whitelisted). Changes are
|
||||||
|
**runtime-only** (like `ip addr add`: non-persistent across host
|
||||||
|
reboots); v1 deliberately does not write netplan/NetworkManager/
|
||||||
|
systemd-networkd configuration, so a host reboot is a guaranteed
|
||||||
|
escape hatch back to the deployed addressing. Optional v2, behind
|
||||||
|
the same whitelist and an explicit "disruptive" confirmation: set
|
||||||
|
an interface's link up/down (cable-pull simulation).
|
||||||
|
- FR-15.2.5: **Safety rails.** The agent enforces an interface
|
||||||
|
**whitelist** from its own config file (operations on other
|
||||||
|
interfaces are refused and surfaced as errors on the page); it
|
||||||
|
snapshots an interface's address set before every mutation, and the
|
||||||
|
page offers **undo last** / **restore interface**; every mutation
|
||||||
|
and every refusal is appended to a single-line **audit log**
|
||||||
|
(operator session, interface, old → new, timestamp) written to
|
||||||
|
stdout/journald like the simulator's own logging (FR-12.2.6) and
|
||||||
|
viewable (bounded) on the page.
|
||||||
|
- FR-15.2.6: **Trust boundary** (same stance as FR-10.2.16): the page
|
||||||
|
requires an authenticated session (FR-10.2.8); read-only mode
|
||||||
|
(`WAGO_SIM_WEB_READONLY`) hides the page and answers mutations with
|
||||||
|
403. Changing host addresses is a privileged operation by design —
|
||||||
|
the flag must never be set on a network the operator does not
|
||||||
|
control.
|
||||||
|
- FR-15.2.7: **Host-side agent** (binding architecture decision,
|
||||||
|
implemented 2026-10-06). A container cannot change host addresses,
|
||||||
|
and the simulator container itself shall not run privileged (§12
|
||||||
|
non-root, least-privilege deployment). Instead a small companion
|
||||||
|
service — `wago-sim hostnet-agent` — runs **on the host** (systemd
|
||||||
|
unit `deploy/wago-sim-hostnet-agent.service`, root or
|
||||||
|
`CAP_NET_ADMIN`), listening on a Unix domain socket
|
||||||
|
(`deploy/hostnet-agent.yaml` config; default
|
||||||
|
`/run/wago-sim/hostnet.sock`) that the compose file mounts into the
|
||||||
|
container read-write. The agent's protocol is a narrow whitelist of
|
||||||
|
operations (health, list, snapshot, address add/remove/change,
|
||||||
|
restore, audit) over JSON lines; it never executes a shell with
|
||||||
|
operator input — on Linux v1 reads use netlink (`pyroute2` when
|
||||||
|
installed) with `ip -o` output parsing as fallback, and writes go
|
||||||
|
through the `ip` CLI (one uniform mutation path). *Rejected
|
||||||
|
alternative, documented why*: running the simulator container
|
||||||
|
itself privileged with host namespaces — it forfeits the non-root
|
||||||
|
deployment and couples host control to the simulator's own
|
||||||
|
lifecycle and restarts.
|
||||||
|
- FR-15.2.8: **Degraded states never break the UI.** Flag set but the
|
||||||
|
agent socket absent/unreachable: the page shows a clear "host
|
||||||
|
control unavailable" banner and mutation routes answer 503 with a
|
||||||
|
message; the Modbus server and the rest of the web UI are
|
||||||
|
unaffected.
|
||||||
|
- FR-15.2.9: **DIP dedicated-IP integration** (FR-11.3.3). When a DIP
|
||||||
|
address change is applied in dedicated-IP mode and the derived
|
||||||
|
interface is whitelisted, the apply step may route through the same
|
||||||
|
agent — the derived IP is added to the host NIC — so the PLC on the
|
||||||
|
bench network must genuinely re-connect to the new node address,
|
||||||
|
exactly like a physical coupler after a DIP change. Under
|
||||||
|
`network_mode: host` the simulator rebinds directly per FR-11.3.3;
|
||||||
|
either way the agent supplies the address on the host.
|
||||||
|
|
||||||
|
### 15.3 Architecture sketch (as-built 2026-10-06)
|
||||||
|
|
||||||
|
```
|
||||||
|
bench host (Linux)
|
||||||
|
├── wago-sim hostnet-agent --config /etc/wago-sim/hostnet.yaml
|
||||||
|
│ (deploy/wago-sim-hostnet-agent.service; root or CAP_NET_ADMIN —
|
||||||
|
│ it owns the interface whitelist; audit → journald + optional file)
|
||||||
|
│ config (deploy/hostnet-agent.yaml): socket, whitelist, backend,
|
||||||
|
│ audit_path; refuses to start with an empty whitelist
|
||||||
|
└── docker compose
|
||||||
|
├── mounts /run/wago-sim:/run/wago-sim (agent socket in, 0660)
|
||||||
|
├── WAGO_SIM_IN_DOCKER=1 (always) — the docker gate's marker
|
||||||
|
├── WAGO_SIM_HOSTNET=1 (opt-in) — the flag gate
|
||||||
|
└── wago-sim serve
|
||||||
|
└── webui: page + /api/hostnet/* routes, mounted only
|
||||||
|
when hostnet_enabled() passes at build time
|
||||||
|
(src/wago_sim/hostnet.py: HostNetAgent / HostNetClient /
|
||||||
|
HostNetService; Iproute2Backend = pyroute2 reads with
|
||||||
|
`ip`-CLI writes, `ip -o` parse fallback; FakeBackend
|
||||||
|
for tests and rehearsal)
|
||||||
|
```
|
||||||
|
|
||||||
|
Web API, mounted only under the FR-15.2.1/2 gates (404 otherwise):
|
||||||
|
|
||||||
|
| Method/Route | Purpose | FR |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /api/hostnet` | availability (gates + agent health) + interface/address list + whitelist; an unreachable agent answers `available:false`, never 5xx | 15.2.3, 15.2.8 |
|
||||||
|
| `POST /api/hostnet/interfaces/{name}/addresses` | add an address (address, prefix) | 15.2.4 |
|
||||||
|
| `DELETE /api/hostnet/interfaces/{name}/addresses/{addr}` | remove an address | 15.2.4 |
|
||||||
|
| `PUT /api/hostnet/interfaces/{name}/addresses` | change an interface's primary address (add-first, never address-less mid-change) | 15.2.4 |
|
||||||
|
| `POST /api/hostnet/interfaces/{name}/restore` | undo last / restore snapshot (auto-snapshot precedes every mutation) | 15.2.5 |
|
||||||
|
| `GET /api/hostnet/audit` | bounded recent audit lines (mutations AND refusals) | 15.2.5 |
|
||||||
|
|
||||||
|
Error mapping: agent unreachable → 503; whitelist/unknown-interface refusal → 403; validation/backend errors → 400; readonly mode hides mutations and answers 403 (FR-15.2.6). Every mutation line in the audit names the logged-in operator.
|
||||||
|
|
||||||
|
### 15.4 Scoping questions (answer before planning)
|
||||||
|
|
||||||
|
1. Platform scope v1: Linux bench hosts only (agent via `pyroute2`)?
|
||||||
|
Windows hosts run the container inside Docker Desktop/WSL2 — the
|
||||||
|
container's "host" is the WSL2 VM; reaching the Windows NICs needs
|
||||||
|
a Windows-side agent over a named pipe — defer or include?
|
||||||
|
2. Networking-mode story for the DIP integration (FR-15.2.9): keep
|
||||||
|
the current bridge deployment (host IP + mapped port) as v1, or
|
||||||
|
require `network_mode: host` for full parity (node IP == host IP,
|
||||||
|
port 502)?
|
||||||
|
3. Agent authentication: Unix-socket file permissions only, or a
|
||||||
|
shared token in the agent config to defend against other local
|
||||||
|
users?
|
||||||
|
4. IPv6 in v1 or deferred?
|
||||||
|
5. Does link up/down (cable pull) belong in v1 — it can sever the
|
||||||
|
operator's own web UI session on that NIC — or are address
|
||||||
|
add/remove/change sufficient for the planned FAT cases?
|
||||||
|
|
||||||
|
### 15.5 Acceptance criteria
|
||||||
|
|
||||||
|
Automated coverage (58 tests, `tests/test_hostnet.py`) verifies every
|
||||||
|
item on the fake backend + InProcessClient path; the real-host
|
||||||
|
`iproute2` path shares the same dispatch in `handle()` and the same
|
||||||
|
parse fallbacks (unit-tested):
|
||||||
|
|
||||||
|
1. Without `WAGO_SIM_HOSTNET`: no page (the tab stays hidden — the SPA
|
||||||
|
probe gets 404), all `/api/hostnet/*` routes answer 404, and the
|
||||||
|
rest of the UI is unchanged (full regression suite green).
|
||||||
|
2. Flag set but not in docker: same as 1 (`hostnet_enabled` unit
|
||||||
|
tests cover the marker/env combinations).
|
||||||
|
3. Flag set + docker + agent reachable: the page lists the host's
|
||||||
|
interfaces; adding `192.168.1.33/24` to a whitelisted interface
|
||||||
|
lands in the backend and is visible on the next `list` (the
|
||||||
|
host-visible `ip addr` effect is the same write path).
|
||||||
|
4. Removing that address makes the next list reflect it — the FAT
|
||||||
|
case (severing a master's route) is staged from the bench UI.
|
||||||
|
5. Restore returns the interface to the auto-snapshot taken before
|
||||||
|
the mutation; changes are runtime-only (FR-15.2.4) so a host reboot
|
||||||
|
likewise restores the deployed addressing.
|
||||||
|
6. An operation on a non-whitelisted interface is refused (403),
|
||||||
|
surfaced on the page via the banner, and recorded as `REFUSED` in
|
||||||
|
the audit log — as is every mutation with the operator's name.
|
||||||
|
7. Read-only mode: status stays readable; every mutation answers 403.
|
||||||
|
8. DIP integration (FR-15.2.9): **deferred** until the §11 DIP model
|
||||||
|
ships; the agent surface it will route through is stable.
|
||||||
|
9. Agent socket missing: `available:false` with the socket message
|
||||||
|
(banner on the page), mutations 503 — and the Modbus server and
|
||||||
|
the rest of the web UI unaffected (verified in-process with a
|
||||||
|
dead client and by the regression suite).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Roadmap (post-framework)
|
||||||
|
|
||||||
| Phase | Feature |
|
| Phase | Feature |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -1444,11 +1809,11 @@ the same process-image core.
|
|||||||
| 4 | FAT report generator (HTML/PDF), scenario comparison, template library per project type |
|
| 4 | FAT report generator (HTML/PDF), scenario comparison, template library per project type |
|
||||||
| 5 | Multi-node orchestration (several couplers in one FAT bench), UDP variant, WBM simulation; **web UI (§10)**; **runtime device configuration (§11)**: module hot-configuration with image rebuild, DIP-switch emulation, WBM-equivalent pages, device-state persistence |
|
| 5 | Multi-node orchestration (several couplers in one FAT bench), UDP variant, WBM simulation; **web UI (§10)**; **runtime device configuration (§11)**: module hot-configuration with image rebuild, DIP-switch emulation, WBM-equivalent pages, device-state persistence |
|
||||||
| 6 | CI integration: run scenarios headless, export JUnit XML |
|
| 6 | CI integration: run scenarios headless, export JUnit XML |
|
||||||
| ? | **Multi-vendor device profiles (§13)** and **additional fieldbus protocols PROFINET/COMLI (§14)** — not yet scheduled; phase assignment after §13.3/§14.3 scoping |
|
| ? | **Multi-vendor device profiles (§13)** and **additional fieldbus protocols PROFINET/COMLI (§14)** — not yet scheduled; phase assignment after §13.3/§14.3 scoping. **Host network settings (§15) implemented 2026-10-06** (v1 ops; link up/down and DIP routing deferred) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 16. References
|
## 17. References
|
||||||
|
|
||||||
- WAGO 750-362 manual `m07500362_xxxxxxxx_0en.pdf`, sections 7.2–7.3 (process image), 11.2 (Modbus), 12.2 (module data architecture)
|
- WAGO 750-362 manual `m07500362_xxxxxxxx_0en.pdf`, sections 7.2–7.3 (process image), 11.2 (Modbus), 12.2 (module data architecture)
|
||||||
- WAGO I/O System 750 system description `m075xxxxx_xxxxxxxx_0en.pdf`
|
- WAGO I/O System 750 system description `m075xxxxx_xxxxxxxx_0en.pdf`
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""User-authored plant environments (spec FR-8.2.17).
|
||||||
|
|
||||||
|
Each module here is loadable by reference:
|
||||||
|
|
||||||
|
wago-sim serve --node ... --env envs.pump_station_env:PumpStationEnv
|
||||||
|
wago-sim sim --node ... --env envs.pump_station_env:make_env
|
||||||
|
wago-sim serve --node ... --env envs.pump_station_env # picks Env
|
||||||
|
"""
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Example pump-station plant environment (spec FR-8.2.17).
|
||||||
|
|
||||||
|
Demonstrates environment authoring: subclass PlantEnv, override
|
||||||
|
``model()`` — read what the PLC commanded (DO/AO), compute what the
|
||||||
|
field now shows (DI/AI). Run it live against a Modbus master:
|
||||||
|
|
||||||
|
wago-sim serve --node config/pump_node.yaml --port 5020 \
|
||||||
|
--env envs.pump_station_env:PumpStationEnv
|
||||||
|
|
||||||
|
or headless for model development:
|
||||||
|
|
||||||
|
wago-sim sim --node config/pump_node.yaml \
|
||||||
|
--env envs.pump_station_env:PumpStationEnv
|
||||||
|
|
||||||
|
Physics (deliberately simple, for demonstration): one tank.
|
||||||
|
- inflow is constant
|
||||||
|
- the pump drains at `flow_max * capacity * speed` while DO0.0 is ON;
|
||||||
|
the speed fraction is AO0.0 normalized over its display range
|
||||||
|
(FR-10.2.14)
|
||||||
|
- the level integrates the balance, clamped at the brim; AI0.0
|
||||||
|
reports the level scaled over the channel's display range
|
||||||
|
(default 0-4095 counts = 100 %)
|
||||||
|
- a high-level switch DI0.1 closes when the level rises above 2.7 m
|
||||||
|
- optional is_done: stop after a given sim time (default: run forever)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from wago_sim.scenario.plantenv import PlantEnv, PlantState
|
||||||
|
|
||||||
|
TANK_AREA = 5.0 # m^2
|
||||||
|
FLOW_MAX = 30.0 # m^3/h the pump moves at full speed
|
||||||
|
INFLOW = 12.0 # m^3/h constant inflow
|
||||||
|
LEVEL_SENSOR_MAX = 5.0 # m — tank level meter full-scale
|
||||||
|
LEVEL_BRIM = 2.9 # m — tank brim (physical clamp)
|
||||||
|
LEVEL_HIGH = 2.7 # m — high-level switch threshold
|
||||||
|
|
||||||
|
|
||||||
|
class PumpStationEnv(PlantEnv):
|
||||||
|
"""Closed-loop tank + pump physics over the pump_node layout."""
|
||||||
|
|
||||||
|
TICK_MS = 10.0 # overrides PlantEnv TICK_MS
|
||||||
|
|
||||||
|
def __init__(self, *args, stop_after_ms: float | None = None, **kw):
|
||||||
|
super().__init__(*args, **kw)
|
||||||
|
self._level_m = 1.2 # initial tank level
|
||||||
|
self.stop_after_ms = stop_after_ms
|
||||||
|
|
||||||
|
def model(self, state: PlantState, dt_s: float) -> dict[str, object]:
|
||||||
|
dt_h = dt_s / 3600.0
|
||||||
|
# what the PLC currently commands (master side: DO/AO)
|
||||||
|
pump_running = bool(state.by_code("DO0.0"))
|
||||||
|
|
||||||
|
# speed setpoint from AO0.0, normalized over its display range
|
||||||
|
ao_lo, ao_hi = state.channel_range("AO0.0")
|
||||||
|
ao_value = state.by_code("AO0.0")
|
||||||
|
speed_raw = float(ao_value) if isinstance(ao_value, (int, float)) else 0.0
|
||||||
|
try:
|
||||||
|
speed = min(1.0, max(0.0, (speed_raw - ao_lo) / (ao_hi - ao_lo)))
|
||||||
|
except ZeroDivisionError:
|
||||||
|
speed = 0.0
|
||||||
|
|
||||||
|
# physics: water balance with pump wear (parameters attachable)
|
||||||
|
capacity = state.parameters.get("pump_capacity", 1.0)
|
||||||
|
outflow = FLOW_MAX * capacity * speed if pump_running else 0.0
|
||||||
|
d_level_m = (INFLOW - outflow) / TANK_AREA * dt_h
|
||||||
|
self._level_m = min(LEVEL_BRIM, max(0.0, self._level_m + d_level_m))
|
||||||
|
|
||||||
|
# the field the PLC reads back: level over the AI display range
|
||||||
|
# (dense store, FR-10.2.14 — defaults to 0-4095 counts = 100 %)
|
||||||
|
ai_lo, ai_hi = state.channel_range("AI0.0")
|
||||||
|
level_raw = int((self._level_m / LEVEL_SENSOR_MAX) * (ai_hi - ai_lo)
|
||||||
|
+ ai_lo)
|
||||||
|
level_raw = min(ai_hi, max(ai_lo, level_raw))
|
||||||
|
high_switch = self._level_m > LEVEL_HIGH # DI0.1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"AI0.0": level_raw,
|
||||||
|
"DI0.1": high_switch,
|
||||||
|
}
|
||||||
|
|
||||||
|
def is_done(self, state: PlantState) -> bool:
|
||||||
|
if self.stop_after_ms is None:
|
||||||
|
return False # default: run until stopped
|
||||||
|
return state.sim_time_ms >= self.stop_after_ms
|
||||||
|
|
||||||
|
|
||||||
|
def make_env(node=None, engine=None) -> PumpStationEnv:
|
||||||
|
"""Factory form (FR-8.2.17): envs.pump_station_env:make_env."""
|
||||||
|
return PumpStationEnv(node=node, engine=engine)
|
||||||
|
|
||||||
|
|
||||||
|
# bare-module form: `--env envs.pump_station_env` picks this up
|
||||||
|
Env = PumpStationEnv
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Test-only environment: is_done() after 30 ms of sim time.
|
||||||
|
|
||||||
|
Used by the web-UI env-control tests (FR-10.2.15): the paced ticker
|
||||||
|
must notice is_done(), set state "done" (no auto-restart), and keep
|
||||||
|
the instance for inspection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from wago_sim.scenario.plantenv import PlantEnv, PlantState
|
||||||
|
|
||||||
|
|
||||||
|
class PumpStationEnv(PlantEnv):
|
||||||
|
"""Minimal env: fills AI0.0, done after 30 ms."""
|
||||||
|
|
||||||
|
def __init__(self, *args, stop_after_ms: float | None = 30.0, **kw):
|
||||||
|
super().__init__(*args, **kw)
|
||||||
|
self.stop_after_ms = stop_after_ms
|
||||||
|
|
||||||
|
def model(self, state: PlantState, dt_s: float) -> dict[str, object]:
|
||||||
|
level = float(state.by_code("AI0.0"))
|
||||||
|
return {"AI0.0": level + 1.0}
|
||||||
|
|
||||||
|
def is_done(self, state: PlantState) -> bool:
|
||||||
|
return state.sim_time_ms >= (self.stop_after_ms or 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
Env = PumpStationEnv
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Example plant/PLC model plugin for the pump-station FAT scenario.
|
||||||
|
|
||||||
|
In a real FAT bench the PLC under test drives the outputs. For a
|
||||||
|
headless demo (or pre-FAT scenario smoke tests) this plugin emulates
|
||||||
|
the expected PLC behavior so the scenario can PASS end-to-end.
|
||||||
|
|
||||||
|
Register it in a scenario via:
|
||||||
|
|
||||||
|
plugins: [plugins.pump_plant.pump_plant]
|
||||||
|
|
||||||
|
The engine calls it once per tick.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# latch state per engine instance (module-level dict keyed by id)
|
||||||
|
_LATCH = {}
|
||||||
|
|
||||||
|
|
||||||
|
def pump_plant(engine, value) -> None:
|
||||||
|
"""Emulated PLC with classic start/stop latch circuit:
|
||||||
|
|
||||||
|
- rising edge on start_button latches the pump ON
|
||||||
|
- e-stop OFF breaks the latch (and the safe state follows)
|
||||||
|
- valve follows the pump; speed setpoint follows level above 4000
|
||||||
|
"""
|
||||||
|
key = id(engine)
|
||||||
|
start = bool(engine.alias_value("start_button"))
|
||||||
|
em_stop = bool(engine.alias_value("em_stop_ok"))
|
||||||
|
level = float(engine.alias_value("level_sensor"))
|
||||||
|
|
||||||
|
# rising edge of the start button sets the latch; e-stop clears it
|
||||||
|
if engine.consume_rising_edge("start_button"):
|
||||||
|
_LATCH[key] = True
|
||||||
|
if not em_stop:
|
||||||
|
_LATCH[key] = False
|
||||||
|
|
||||||
|
pump_on = _LATCH.get(key, False) and em_stop
|
||||||
|
speed = int(max(0.0, (level - 4000.0) * 10.0)) if pump_on else 0
|
||||||
|
|
||||||
|
_set_do(engine, "pump_relay", pump_on)
|
||||||
|
_set_do(engine, "valve_open", pump_on)
|
||||||
|
_set_ao(engine, "speed_setpoint", speed)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_do(engine, alias_name: str, value: bool) -> None:
|
||||||
|
"""Emulate a Modbus master writing the output coil.
|
||||||
|
|
||||||
|
Uses the engine's global alias location: ``loc.bit`` is the 0-based
|
||||||
|
global digital channel index == the Modbus bit address (Table 122).
|
||||||
|
"""
|
||||||
|
loc = engine.alias_location(alias_name)
|
||||||
|
result = engine.datastore.write_bits(loc.bit, [bool(value)])
|
||||||
|
assert not isinstance(result, Exception), result
|
||||||
|
|
||||||
|
|
||||||
|
def _set_ao(engine, alias_name: str, value: int) -> None:
|
||||||
|
"""Emulate a Modbus master writing the output register.
|
||||||
|
|
||||||
|
``loc.word`` is the global analog output word index == the Modbus
|
||||||
|
word address in output area 1 (0x0000-0x00FF).
|
||||||
|
"""
|
||||||
|
alias = engine._aliases[alias_name]
|
||||||
|
loc = engine.alias_location(alias_name)
|
||||||
|
raw = int(value * alias.scale + alias.offset) & 0xFFFF
|
||||||
|
result = engine.datastore.write_words(loc.word, [raw])
|
||||||
|
assert not isinstance(result, Exception), result
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Development / test dependencies for wago-modbustcp-simulator.
|
||||||
|
# Mirrors [project.optional-dependencies].dev in pyproject.toml.
|
||||||
|
# Includes everything from requirements.txt, plus:
|
||||||
|
# - pytest / pytest-asyncio: test suite
|
||||||
|
# - pymodbus: integration tests acting as a real Modbus TCP master
|
||||||
|
# - debugpy: VS Code debugging setup
|
||||||
|
# - httpx: ASGI test client for the FastAPI Web UI
|
||||||
|
# Fully pinned versions for reproducible installs: requirements-lock.txt.
|
||||||
|
-r requirements.txt
|
||||||
|
pytest>=8.0
|
||||||
|
pytest-asyncio>=0.23
|
||||||
|
pymodbus>=3.6
|
||||||
|
debugpy>=1.8
|
||||||
|
httpx>=0.27
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Locked dependency versions for wago-modbustcp-simulator.
|
||||||
|
# Generated 2026-10-05 from the tested .venv (Python 3.14.5, Windows).
|
||||||
|
# Install exactly; do not edit by hand. Regenerate with:
|
||||||
|
# pip install -r requirements.txt
|
||||||
|
# pip freeze --exclude-editable > requirements-lock.txt
|
||||||
|
# (then re-add the section comments below).
|
||||||
|
#
|
||||||
|
# Sections mirror pyproject.toml [project].dependencies (production) and
|
||||||
|
# [project.optional-dependencies].dev (dev/test) — the same package set
|
||||||
|
# pip freeze would emit, just organized for review.
|
||||||
|
|
||||||
|
# --- Production (pyproject [project].dependencies) ---
|
||||||
|
pyyaml==6.0.3
|
||||||
|
fastapi==0.142.2
|
||||||
|
uvicorn==0.54.0
|
||||||
|
websockets==17.2
|
||||||
|
# Transitive production dependencies
|
||||||
|
annotated-doc==0.0.5
|
||||||
|
annotated-types==0.8.0
|
||||||
|
anyio==4.15.1
|
||||||
|
opentelemetry-api==1.45.0
|
||||||
|
click==8.5.0
|
||||||
|
h11==0.16.0
|
||||||
|
pydantic==2.13.5
|
||||||
|
pydantic-core==2.46.5
|
||||||
|
starlette==1.7.0
|
||||||
|
typing-extensions==4.16.0
|
||||||
|
typing-inspection==0.4.4
|
||||||
|
# anyio (required unconditionally by starlette) pulls idna
|
||||||
|
idna==3.20
|
||||||
|
|
||||||
|
# --- Dev / test (pyproject [project.optional-dependencies].dev) ---
|
||||||
|
pytest==9.1.1
|
||||||
|
pytest-asyncio==1.4.0
|
||||||
|
pymodbus==3.15.0
|
||||||
|
debugpy==1.8.22
|
||||||
|
httpx==0.28.1
|
||||||
|
# Transitive dev/test dependencies
|
||||||
|
certifi==2026.7.22
|
||||||
|
httpcore==1.0.9
|
||||||
|
iniconfig==2.3.0
|
||||||
|
packaging==26.3
|
||||||
|
pluggy==1.6.0
|
||||||
|
pygments==2.21.0
|
||||||
|
colorama==0.4.6; sys_platform == "win32" # pytest dep on Windows only
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Production runtime dependencies for wago-modbustcp-simulator.
|
||||||
|
# Mirrors [project].dependencies in pyproject.toml — the set that
|
||||||
|
# `pip install .` (and the Docker image) actually needs:
|
||||||
|
# - PyYAML: node config / scenario DSL files
|
||||||
|
# - fastapi + uvicorn + websockets: optional Web UI (serve --web)
|
||||||
|
# Development and test dependencies live in requirements-dev.txt.
|
||||||
|
# Fully pinned versions for reproducible installs: requirements-lock.txt.
|
||||||
|
PyYAML>=6.0
|
||||||
|
fastapi>=0.110
|
||||||
|
uvicorn>=0.29
|
||||||
|
websockets>=12.0
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Example CONTINUOUS (soak) scenario — pump station endurance run.
|
||||||
|
#
|
||||||
|
# Demonstrates spec §8: long-duration simulation driven by a CSV
|
||||||
|
# inflow dataset IN PARALLEL with logic, degradation, invariants and
|
||||||
|
# snapshots. Run:
|
||||||
|
#
|
||||||
|
# wago-sim run --node config/pump_node.yaml \
|
||||||
|
# --scenario scenarios/pump_soak.yaml \
|
||||||
|
# --report-jsonl out/pump_soak.jsonl
|
||||||
|
|
||||||
|
name: pump_soak_8h
|
||||||
|
description: "8-hour soak: diurnal inflow, pump wear, level bounds"
|
||||||
|
|
||||||
|
mode: continuous
|
||||||
|
until: 8h # run length (sim time)
|
||||||
|
|
||||||
|
# datasets: inflow demand replayed from a historian export (optional;
|
||||||
|
# logic-only runs are equally valid — see FR-8.2.13)
|
||||||
|
datasets:
|
||||||
|
inflow:
|
||||||
|
file: data/inflow_day.csv
|
||||||
|
interpolation: linear
|
||||||
|
repeat: 1
|
||||||
|
|
||||||
|
# aliases (per-type addressing per FR-4.3.11 v1.5)
|
||||||
|
inputs:
|
||||||
|
tank_level: { type: ai, module: 0, channel: 0, scale: 0.1 } # AI0.0
|
||||||
|
inflow_cmd: { type: ai, module: 0, channel: 1, scale: 1.0 } # AI0.1
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
pump_relay: { type: do, module: 0, channel: 0 } # DO0.0
|
||||||
|
|
||||||
|
# aging plant: capacity decays with pump runtime; maintenance alarm
|
||||||
|
# expected from the PLC when capacity drops below 75 %
|
||||||
|
degradation:
|
||||||
|
pump_capacity:
|
||||||
|
observes: pump_relay
|
||||||
|
model: linear
|
||||||
|
rate: 0.01 # -1% capacity per runtime hour
|
||||||
|
from: 1.0
|
||||||
|
maintenance_at: 0.75
|
||||||
|
diagnosis: { module: 0, channel: 0, value: 0x0001 }
|
||||||
|
|
||||||
|
# every-tick conditions; breaches fail the run as bounded episodes
|
||||||
|
invariants:
|
||||||
|
- { alias: tank_level, condition: '>= 0', label: level_floor }
|
||||||
|
- { alias: tank_level, condition: '<= 9000', label: level_brim }
|
||||||
|
|
||||||
|
rate_limits:
|
||||||
|
- { alias: tank_level, max_per_hour: 5000 } # level may not race
|
||||||
|
|
||||||
|
snapshot_every: 30m # periodic state dumps for the report
|
||||||
|
|
||||||
|
# scheduled discrete events interleave with the datasets (FR-8.4.6)
|
||||||
|
sequence:
|
||||||
|
- at: 4h
|
||||||
|
report: PASS # mid-run milestone check
|
||||||
|
- at: 7h
|
||||||
|
maintenance_reset: true # service the pump: capacity restored
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Example FAT scenario for a pump-station PLC program.
|
||||||
|
#
|
||||||
|
# With a REAL PLC under test: run `wago-sim serve` against this node
|
||||||
|
# configuration; the PLC drives the outputs and the scenario asserts on
|
||||||
|
# them. Remove the `plugins:` line below — the PLC is the plant.
|
||||||
|
#
|
||||||
|
# Headless (no PLC): the plugins line loads an emulated plant model so
|
||||||
|
# the scenario can be smoke-tested end-to-end before the FAT bench is
|
||||||
|
# wired.
|
||||||
|
|
||||||
|
name: pump_station_fat
|
||||||
|
description: "Basic FAT round: start button -> pump relay -> valve -> level ramp"
|
||||||
|
|
||||||
|
plugins: [plugins.pump_plant.pump_plant] # remove when a real PLC is connected
|
||||||
|
|
||||||
|
inputs: # simulated field signals (what the PLC reads)
|
||||||
|
# module-based addressing: module number is
|
||||||
|
# 0-based WITHIN ITS TYPE (DI0, DO0, AI0,
|
||||||
|
# AO0 — FR-4.3.11 v1.5); channel 0-based
|
||||||
|
start_button: { type: di, module: 0, channel: 0 } # DI0.0
|
||||||
|
em_stop_ok: { type: di, module: 0, channel: 1 } # DI0.1
|
||||||
|
level_sensor: { type: ai, module: 0, channel: 0, scale: 0.1 } # AI0.0, level mm -> counts
|
||||||
|
|
||||||
|
outputs: # PLC outputs observed by the scenario
|
||||||
|
pump_relay: { type: do, module: 0, channel: 0 } # DO0.0
|
||||||
|
valve_open: { type: do, module: 0, channel: 1 } # DO0.1
|
||||||
|
speed_setpoint: { type: ao, module: 0, channel: 0, scale: 0.1 } # AO0.0
|
||||||
|
|
||||||
|
macros:
|
||||||
|
pulse_start:
|
||||||
|
- set: { start_button: ON }
|
||||||
|
- wait: 250ms
|
||||||
|
- set: { start_button: OFF }
|
||||||
|
|
||||||
|
sequence:
|
||||||
|
# --- startup checks -------------------------------------------------------
|
||||||
|
- set: { em_stop_ok: ON }
|
||||||
|
- assert: { label: "pump idle", conditions: { pump_relay: OFF }, within: 1s }
|
||||||
|
|
||||||
|
# --- start sequence ---------------------------------------------------------
|
||||||
|
- macro: pulse_start
|
||||||
|
- assert: { label: "pump starts", conditions: { pump_relay: ON }, within: 2s }
|
||||||
|
- assert: { label: "valve opens", conditions: { valve_open: ON }, within: 2s }
|
||||||
|
|
||||||
|
# --- process dynamics -------------------------------------------------------
|
||||||
|
- ramp: { name: level_sensor, to: 8500, over: 30s }
|
||||||
|
- wait: 31s
|
||||||
|
- assert: { label: "speed setpoint raised", conditions: { speed_setpoint: ">= 1000" }, within: 3s }
|
||||||
|
|
||||||
|
# --- stop sequence ------------------------------------------------------------
|
||||||
|
- set: { em_stop_ok: OFF }
|
||||||
|
- assert: { label: "safe state on e-stop", conditions: { pump_relay: OFF, valve_open: OFF }, within: 2s }
|
||||||
|
|
||||||
|
- report: PASS
|
||||||
@@ -14,16 +14,17 @@ description: "Basic FAT round: start button -> pump relay -> valve -> level ramp
|
|||||||
plugins: [plugins.pump_plant.pump_plant] # remove when a real PLC is connected
|
plugins: [plugins.pump_plant.pump_plant] # remove when a real PLC is connected
|
||||||
|
|
||||||
inputs: # simulated field signals (what the PLC reads)
|
inputs: # simulated field signals (what the PLC reads)
|
||||||
# module-based addressing: module slot and
|
# module-based addressing: module number is
|
||||||
# channel number on the module, both 0-based
|
# 0-based WITHIN ITS TYPE (DI0, DO0, AI0,
|
||||||
|
# AO0 — FR-4.3.11 v1.5); channel 0-based
|
||||||
start_button: { type: di, module: 0, channel: 0 } # DI0.0
|
start_button: { type: di, module: 0, channel: 0 } # DI0.0
|
||||||
em_stop_ok: { type: di, module: 0, channel: 1 } # DI0.1
|
em_stop_ok: { type: di, module: 0, channel: 1 } # DI0.1
|
||||||
level_sensor: { type: ai, module: 4, channel: 0, scale: 0.1 } # AI4.0, level mm -> counts
|
level_sensor: { type: ai, module: 0, channel: 0, scale: 0.1 } # AI0.0, level mm -> counts
|
||||||
|
|
||||||
outputs: # PLC outputs observed by the scenario
|
outputs: # PLC outputs observed by the scenario
|
||||||
pump_relay: { type: do, module: 2, channel: 0 } # DO2.0
|
pump_relay: { type: do, module: 0, channel: 0 } # DO0.0
|
||||||
valve_open: { type: do, module: 2, channel: 1 } # DO2.1
|
valve_open: { type: do, module: 0, channel: 1 } # DO0.1
|
||||||
speed_setpoint: { type: ao, module: 5, channel: 0, scale: 0.1 } # AO5.0
|
speed_setpoint: { type: ao, module: 0, channel: 0, scale: 0.1 } # AO0.0
|
||||||
|
|
||||||
macros:
|
macros:
|
||||||
pulse_start:
|
pulse_start:
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Live smoke test of the §15 host network settings page.
|
||||||
|
|
||||||
|
Runs the real server + web UI with BOTH gates forced on and an
|
||||||
|
in-process fake agent (the Windows-dev path), then probes the page in
|
||||||
|
a real browser: tab visibility, interface cards, an add-address
|
||||||
|
mutation, the audit view, and the whitelist refusal banner.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ["WAGO_SIM_HOSTNET"] = "1"
|
||||||
|
os.environ["WAGO_SIM_IN_DOCKER"] = "1"
|
||||||
|
os.environ["WAGO_SIM_HOSTNET_SOCKET"] = "unused" # we inject in-process
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from wago_sim.hostnet import (FakeBackend, HostNetAgent, HostNetService,
|
||||||
|
InProcessClient)
|
||||||
|
from wago_sim.webui.app import build_webui
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
agent = HostNetAgent(
|
||||||
|
backend=FakeBackend({
|
||||||
|
"eth0": ["192.168.1.10/24", "192.168.1.11/24"],
|
||||||
|
"eth1": ["10.0.0.5/8"],
|
||||||
|
"lo": ["127.0.0.1/8"],
|
||||||
|
}),
|
||||||
|
whitelist=["eth0", "eth1"],
|
||||||
|
)
|
||||||
|
service = HostNetService(InProcessClient(agent))
|
||||||
|
|
||||||
|
from wago_sim.app import build_app
|
||||||
|
from wago_sim.modules import DI_8, DI_16, DO_8, DO_16, AI_2, AO_2
|
||||||
|
|
||||||
|
sim = build_app(modules=[DI_8, DI_16, DO_8, DO_16, AI_2, AO_2])
|
||||||
|
webui = build_webui(sim, user="admin", password="simu",
|
||||||
|
hostnet_service=service)
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
config = uvicorn.Config(webui, host="127.0.0.1", port=8099,
|
||||||
|
log_level="warning")
|
||||||
|
server = uvicorn.Server(config)
|
||||||
|
task = asyncio.create_task(server.serve())
|
||||||
|
for _ in range(100):
|
||||||
|
if server.started or task.done():
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
assert server.started, "web UI failed to start"
|
||||||
|
|
||||||
|
# --- API probe (no browser needed for the data layer) -------------
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(base_url="http://127.0.0.1:8099") as c:
|
||||||
|
await c.post("/login", json={"user": "admin", "password": "simu"})
|
||||||
|
r = await c.get("/api/hostnet")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
data = r.json()
|
||||||
|
assert data["status"]["available"] is True
|
||||||
|
assert data["status"]["agent"]["whitelist"] == ["eth0", "eth1"]
|
||||||
|
names = {i["name"] for i in data["interfaces"]}
|
||||||
|
print("interfaces:", sorted(names))
|
||||||
|
|
||||||
|
r = await c.post("/api/hostnet/interfaces/eth0/addresses",
|
||||||
|
json={"address": "192.168.1.33", "prefix": 24})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
print("added 192.168.1.33 to eth0")
|
||||||
|
|
||||||
|
r = await c.get("/api/hostnet/audit")
|
||||||
|
assert "192.168.1.33" in "\n".join(r.json()["lines"])
|
||||||
|
print("audit:", r.json()["lines"][-2:])
|
||||||
|
|
||||||
|
# whitelist refusal surfaces as 403 with a clean message
|
||||||
|
r = await c.post("/api/hostnet/interfaces/lo/addresses",
|
||||||
|
json={"address": "127.0.0.2", "prefix": 8})
|
||||||
|
assert r.status_code == 403, r.text
|
||||||
|
print("refused lo mutation:", r.json()["detail"][:60], "…")
|
||||||
|
|
||||||
|
# restore walks the add back
|
||||||
|
r = await c.post("/api/hostnet/interfaces/eth0/restore")
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = await c.get("/api/hostnet")
|
||||||
|
eth0 = next(i for i in r.json()["interfaces"] if i["name"] == "eth0")
|
||||||
|
addrs = [a["address"] for a in eth0["addresses"]]
|
||||||
|
assert "192.168.1.33" not in addrs
|
||||||
|
print("restored eth0 to:", addrs)
|
||||||
|
|
||||||
|
print("ALL SMOKE CHECKS PASSED")
|
||||||
|
if os.environ.get("HOSTNET_SMOKE_PERSIST"):
|
||||||
|
print("PERSIST mode: serving on http://127.0.0.1:8099 until Ctrl+C")
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
server.should_exit = True
|
||||||
|
await asyncio.wait_for(task, timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
+13
-2
@@ -3,9 +3,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from .datastore import WagoDatastore
|
from .datastore import WagoDatastore
|
||||||
|
from .envrunner import EnvRunner
|
||||||
from .modules import ModuleType, VirtualNode
|
from .modules import ModuleType, VirtualNode
|
||||||
from .registers import ConfigRegisterBank
|
from .registers import ConfigRegisterBank
|
||||||
from .server import WagoSimServer
|
from .server import WagoSimServer
|
||||||
@@ -36,6 +37,8 @@ class SimulatorApp:
|
|||||||
server: WagoSimServer
|
server: WagoSimServer
|
||||||
web: WebUIConfig | None = None
|
web: WebUIConfig | None = None
|
||||||
node_config_path: str | None = None # where the module list was loaded from
|
node_config_path: str | None = None # where the module list was loaded from
|
||||||
|
runner: EnvRunner = field(init=False) # env lifecycle owner (FR-10.2.15);
|
||||||
|
# holds the loaded env + its ref
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def modules(self) -> list[ModuleType]:
|
def modules(self) -> list[ModuleType]:
|
||||||
@@ -60,9 +63,15 @@ def rebuild_node(app: SimulatorApp, modules: list[ModuleType],
|
|||||||
datastore = app.datastore
|
datastore = app.datastore
|
||||||
names = channel_names if channel_names is not None else app.node.channel_names
|
names = channel_names if channel_names is not None else app.node.channel_names
|
||||||
ranges = channel_ranges if channel_ranges is not None else app.node.channel_ranges
|
ranges = channel_ranges if channel_ranges is not None else app.node.channel_ranges
|
||||||
|
# a loaded env binds to the OLD layout (its model() reads channels by
|
||||||
|
# code); the rebuild renumbers everything — discard it like the values
|
||||||
|
# are lost on a KBUS reset (FR-10.2.15 discard rule)
|
||||||
|
app.runner.dispose()
|
||||||
datastore.reset_images()
|
datastore.reset_images()
|
||||||
app.node = VirtualNode(datastore, modules,
|
app.node = VirtualNode(datastore, modules,
|
||||||
channel_names=names, channel_ranges=ranges)
|
channel_names=names, channel_ranges=ranges)
|
||||||
|
# re-point the runner at the new layout so the next start binds to it
|
||||||
|
app.runner = EnvRunner(app.node)
|
||||||
app.registers.set_process_image_bits(
|
app.registers.set_process_image_bits(
|
||||||
analog_in=app.node.analog_in_bits,
|
analog_in=app.node.analog_in_bits,
|
||||||
analog_out=app.node.analog_out_bits,
|
analog_out=app.node.analog_out_bits,
|
||||||
@@ -145,7 +154,7 @@ def build_app(
|
|||||||
idle_timeout_provider=lambda: registers.idle_timeout_ms,
|
idle_timeout_provider=lambda: registers.idle_timeout_ms,
|
||||||
)
|
)
|
||||||
server_ref: dict[str, WagoSimServer] = {"server": server}
|
server_ref: dict[str, WagoSimServer] = {"server": server}
|
||||||
return SimulatorApp(
|
app = SimulatorApp(
|
||||||
datastore=datastore,
|
datastore=datastore,
|
||||||
node=node,
|
node=node,
|
||||||
stats=stats,
|
stats=stats,
|
||||||
@@ -155,3 +164,5 @@ def build_app(
|
|||||||
web=web,
|
web=web,
|
||||||
node_config_path=node_config_path,
|
node_config_path=node_config_path,
|
||||||
)
|
)
|
||||||
|
app.runner = EnvRunner(node)
|
||||||
|
return app
|
||||||
+91
-3
@@ -35,6 +35,7 @@ import os
|
|||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import NoReturn
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -101,7 +102,7 @@ def _build_logger(verbose: bool) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _fail(message: str, code: int = 2):
|
def _fail(message: str, code: int = 2) -> NoReturn:
|
||||||
"""Report a bad-argument / bad-configuration error cleanly and exit.
|
"""Report a bad-argument / bad-configuration error cleanly and exit.
|
||||||
|
|
||||||
Prints a one-line ``wago-sim: error: ...`` to stderr (never a
|
Prints a one-line ``wago-sim: error: ...`` to stderr (never a
|
||||||
@@ -195,6 +196,14 @@ async def _serve(args: argparse.Namespace) -> int:
|
|||||||
app = build_app(modules=modules, host=args.host, port=args.port, web=web_cfg,
|
app = build_app(modules=modules, host=args.host, port=args.port, web=web_cfg,
|
||||||
node_config_path=args.node, channel_names=channel_names,
|
node_config_path=args.node, channel_names=channel_names,
|
||||||
channel_ranges=channel_ranges)
|
channel_ranges=channel_ranges)
|
||||||
|
# startup default (FR-10.2.15): load BEFORE the servers start so a
|
||||||
|
# bad --env fails cleanly with nothing half-started; the UI can
|
||||||
|
# stop/restart it at runtime
|
||||||
|
if getattr(args, "env", None):
|
||||||
|
try:
|
||||||
|
app.runner.start(args.env)
|
||||||
|
except ScenarioError as exc:
|
||||||
|
_fail(f"--env {args.env}: {exc}")
|
||||||
await app.server.start()
|
await app.server.start()
|
||||||
web_server = None
|
web_server = None
|
||||||
if web_cfg is not None:
|
if web_cfg is not None:
|
||||||
@@ -203,6 +212,10 @@ async def _serve(args: argparse.Namespace) -> int:
|
|||||||
f":{web_cfg.port} (user: {web_cfg.user})")
|
f":{web_cfg.port} (user: {web_cfg.user})")
|
||||||
print(f"Simulating WAGO 750-362 with {len(modules)} modules "
|
print(f"Simulating WAGO 750-362 with {len(modules)} modules "
|
||||||
f"on {args.host}:{args.port} (Ctrl+C to stop)")
|
f"on {args.host}:{args.port} (Ctrl+C to stop)")
|
||||||
|
if app.runner.env is not None:
|
||||||
|
env = app.runner.env
|
||||||
|
print(f"Plant environment: {app.runner.env_ref} "
|
||||||
|
f"({env.TICK_MS:.0f} ms paced tick, Ctrl+C to stop)")
|
||||||
|
|
||||||
# Graceful shutdown on SIGINT/SIGTERM (docker stop, systemctl stop).
|
# Graceful shutdown on SIGINT/SIGTERM (docker stop, systemctl stop).
|
||||||
# asyncio.run installs a SIGINT handler; SIGTERM arrives as a raw
|
# asyncio.run installs a SIGINT handler; SIGTERM arrives as a raw
|
||||||
@@ -219,6 +232,8 @@ async def _serve(args: argparse.Namespace) -> int:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass # SIGINT via asyncio.run's handler
|
pass # SIGINT via asyncio.run's handler
|
||||||
finally:
|
finally:
|
||||||
|
if app.runner.env is not None:
|
||||||
|
await app.runner.stop()
|
||||||
if web_server is not None:
|
if web_server is not None:
|
||||||
task = getattr(web_server, "_serve_task", None)
|
task = getattr(web_server, "_serve_task", None)
|
||||||
web_server.should_exit = True
|
web_server.should_exit = True
|
||||||
@@ -231,6 +246,34 @@ async def _serve(args: argparse.Namespace) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _sim(args: argparse.Namespace) -> int:
|
||||||
|
"""Headless environment run (FR-8.2.17): unpaced, until is_done()
|
||||||
|
or Ctrl+C — for plant-model development without a PLC."""
|
||||||
|
node_config = _load_yaml_file(args.node, "node config")
|
||||||
|
modules = load_node_modules(node_config)
|
||||||
|
channel_names = load_channel_names(node_config)
|
||||||
|
channel_ranges = load_channel_ranges(node_config)
|
||||||
|
app = build_app(modules=modules, channel_names=channel_names,
|
||||||
|
channel_ranges=channel_ranges)
|
||||||
|
from .scenario.plantenv import load_env
|
||||||
|
env = load_env(args.env, node=app.node)
|
||||||
|
print(f"Running {args.env} headless (Ctrl+C to stop; is_done() to end)")
|
||||||
|
ticks = 0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
state = env.tick()
|
||||||
|
ticks += 1
|
||||||
|
if env.is_done(state):
|
||||||
|
print(f"is_done() at tick {ticks} (sim {state.sim_time_ms:.0f} ms)")
|
||||||
|
break
|
||||||
|
if ticks % 1000 == 0 and args.verbose:
|
||||||
|
print(f"tick {ticks}: {state.sim_time_ms:.0f} ms")
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print(f"\nstopped manually at tick {ticks} "
|
||||||
|
f"(sim {env.sim_time_ms:.0f} ms)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _run_scenario(args: argparse.Namespace) -> int:
|
def _run_scenario(args: argparse.Namespace) -> int:
|
||||||
node_config = _load_yaml_file(args.node, "node config")
|
node_config = _load_yaml_file(args.node, "node config")
|
||||||
modules = load_node_modules(node_config)
|
modules = load_node_modules(node_config)
|
||||||
@@ -257,7 +300,13 @@ def _run_scenario(args: argparse.Namespace) -> int:
|
|||||||
_fail(f"scenario {args.scenario}: {exc}")
|
_fail(f"scenario {args.scenario}: {exc}")
|
||||||
engine.start()
|
engine.start()
|
||||||
result = None
|
result = None
|
||||||
max_ticks = 2_000_000
|
# sequence runs cap at 2M ticks; continuous runs scale with `until`
|
||||||
|
# (spec §8): a 28-day soak needs ~242M ticks, so the limit is
|
||||||
|
# derived from the configured run length plus headroom.
|
||||||
|
if engine.mode == "continuous":
|
||||||
|
max_ticks = int((engine._until_ms or 0) / engine.TICK_MS) + 10_000_000
|
||||||
|
else:
|
||||||
|
max_ticks = 2_000_000
|
||||||
ticks = 0
|
ticks = 0
|
||||||
while result is None and ticks < max_ticks:
|
while result is None and ticks < max_ticks:
|
||||||
result = engine.pump()
|
result = engine.pump()
|
||||||
@@ -266,7 +315,10 @@ def _run_scenario(args: argparse.Namespace) -> int:
|
|||||||
print("Scenario did not terminate (tick limit reached)", file=sys.stderr)
|
print("Scenario did not terminate (tick limit reached)", file=sys.stderr)
|
||||||
return 3
|
return 3
|
||||||
print(result_summary(result))
|
print(result_summary(result))
|
||||||
if report_jsonl:
|
# continuous runs append episodes/snapshots to the JSONL report
|
||||||
|
if report_jsonl and engine.invariants is not None:
|
||||||
|
write_jsonl(result, report_jsonl, extra=engine.invariants.as_dict())
|
||||||
|
elif report_jsonl:
|
||||||
write_jsonl(result, report_jsonl)
|
write_jsonl(result, report_jsonl)
|
||||||
if report_csv:
|
if report_csv:
|
||||||
write_csv(result, report_csv)
|
write_csv(result, report_csv)
|
||||||
@@ -285,6 +337,18 @@ def _validate(args: argparse.Namespace) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _hostnet_agent(args: argparse.Namespace) -> int:
|
||||||
|
"""Run the host-side agent (spec §15, FR-15.2.7).
|
||||||
|
|
||||||
|
This runs on the docker HOST — not in the container: it is the
|
||||||
|
component that holds the privileges to change host addresses and
|
||||||
|
the interface whitelist. Clean one-line errors (NFR-6.5) for
|
||||||
|
missing/invalid config; the agent logs to stdout/journald.
|
||||||
|
"""
|
||||||
|
from .hostnet import agent_main
|
||||||
|
return agent_main(["--config", args.config])
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="wago-sim",
|
prog="wago-sim",
|
||||||
@@ -322,6 +386,12 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
"--web-port", type=_port_type, default=_env_port("WAGO_SIM_WEB_PORT", 8080),
|
"--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)",
|
help="web UI port (or env: WAGO_SIM_WEB_PORT; default 8080)",
|
||||||
)
|
)
|
||||||
|
serve.add_argument(
|
||||||
|
"--env", default=None,
|
||||||
|
help=("plant environment module (FR-8.2.17): e.g. "
|
||||||
|
"envs.pump_station_env:PumpStationEnv — ticked wall-clock-"
|
||||||
|
"paced alongside Modbus; runs until stopped"),
|
||||||
|
)
|
||||||
serve.set_defaults(func=_serve)
|
serve.set_defaults(func=_serve)
|
||||||
|
|
||||||
runp = sub.add_parser("run", help="run a scenario headless (no server)")
|
runp = sub.add_parser("run", help="run a scenario headless (no server)")
|
||||||
@@ -335,6 +405,24 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
val.add_argument("--scenario", required=True)
|
val.add_argument("--scenario", required=True)
|
||||||
val.set_defaults(func=_validate)
|
val.set_defaults(func=_validate)
|
||||||
|
|
||||||
|
sim = sub.add_parser(
|
||||||
|
"sim", help="run a plant environment headless (FR-8.2.17)")
|
||||||
|
sim.add_argument("--node", required=True, help="node config YAML")
|
||||||
|
sim.add_argument("--env", required=True,
|
||||||
|
help="environment module, e.g. envs.pump_station_env:PumpStationEnv")
|
||||||
|
sim.set_defaults(func=_sim)
|
||||||
|
|
||||||
|
hostnet = sub.add_parser(
|
||||||
|
"hostnet-agent",
|
||||||
|
help="run the host-side agent for the web UI's host network "
|
||||||
|
"settings page (spec §15; runs on the DOCKER HOST as "
|
||||||
|
"root/CAP_NET_ADMIN, not in the container)")
|
||||||
|
hostnet.add_argument(
|
||||||
|
"--config", required=True,
|
||||||
|
help="agent config YAML (socket, whitelist, backend, audit_path)",
|
||||||
|
)
|
||||||
|
hostnet.set_defaults(func=lambda args: _hostnet_agent(args))
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
_build_logger(args.verbose)
|
_build_logger(args.verbose)
|
||||||
# `--node` may come from the environment when omitted (service mode);
|
# `--node` may come from the environment when omitted (service mode);
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""EnvRunner: web-UI-controllable ticker for a plant environment.
|
||||||
|
|
||||||
|
Owns the wall-clock-paced tick task that used to live as a closure in
|
||||||
|
the CLI's ``_serve`` (FR-8.2.17 runner (a)). Moved here so the web UI
|
||||||
|
can control the lifecycle at runtime (spec FR-10.2.15):
|
||||||
|
|
||||||
|
start(ref) load (+ hot reload, FR-10.2.17) + tick
|
||||||
|
stop() cancel the ticker; the field freezes
|
||||||
|
pause() cancel the ticker, keep the instance
|
||||||
|
resume() reschedule the ticker
|
||||||
|
reset() env.reset() — deterministic fresh episode
|
||||||
|
restart() stop + start of the same ref, fresh instance
|
||||||
|
|
||||||
|
State machine: none -> running <-> paused -> done; stop/discard -> none.
|
||||||
|
Exactly one env at a time (one decision source, FR-8.2.15): start is
|
||||||
|
refused while one is loaded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from .scenario.engine import ScenarioError
|
||||||
|
from .scenario.plantenv import PlantEnv, load_env
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .modules import VirtualNode
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EnvRunner:
|
||||||
|
"""Paced ticker + lifecycle for one PlantEnv on a virtual node."""
|
||||||
|
|
||||||
|
def __init__(self, node: VirtualNode) -> None:
|
||||||
|
self.node = node
|
||||||
|
self.env: PlantEnv | None = None
|
||||||
|
self.env_ref: str | None = None
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
self._state: str = "none" # none | running | paused | done
|
||||||
|
self._done_reason: str | None = None
|
||||||
|
|
||||||
|
# -- introspection ---------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> str:
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
def status(self) -> dict[str, object]:
|
||||||
|
"""Status snapshot for the web UI (FR-10.2.15)."""
|
||||||
|
return {
|
||||||
|
"ref": self.env_ref,
|
||||||
|
"state": self._state,
|
||||||
|
"reason": self._done_reason,
|
||||||
|
"sim_time_ms": self.env.sim_time_ms if self.env else None,
|
||||||
|
"tick_ms": self.env.TICK_MS if self.env else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- lifecycle --------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(self, ref: str) -> None:
|
||||||
|
"""Load + start an environment (refused while one is loaded).
|
||||||
|
|
||||||
|
``reload=True`` passes through to ``load_env`` (FR-10.2.17):
|
||||||
|
a cached module is invalidated first, so starting after an
|
||||||
|
edit of the source file runs the NEW code.
|
||||||
|
"""
|
||||||
|
if self.env is not None:
|
||||||
|
raise ScenarioError(
|
||||||
|
"an environment is already loaded "
|
||||||
|
f"({self.env_ref!r}); stop it first")
|
||||||
|
env = load_env(ref, node=self.node, reload=True)
|
||||||
|
self.env = env
|
||||||
|
self.env_ref = ref
|
||||||
|
self._state = "running"
|
||||||
|
self._done_reason = None
|
||||||
|
self._task = asyncio.create_task(self._run())
|
||||||
|
log.info("env started: %s", ref)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Cancel the ticker and drop the env (field freezes)."""
|
||||||
|
await self._cancel_task()
|
||||||
|
self.env = None
|
||||||
|
self.env_ref = None
|
||||||
|
self._state = "none"
|
||||||
|
self._done_reason = None
|
||||||
|
log.info("env stopped")
|
||||||
|
|
||||||
|
async def pause(self) -> None:
|
||||||
|
"""Cancel the ticker, keep the instance (plant stuck fault)."""
|
||||||
|
if self.env is None or self._state not in ("running", "done"):
|
||||||
|
raise ScenarioError(f"cannot pause: state is {self._state!r}")
|
||||||
|
if self._state == "done":
|
||||||
|
raise ScenarioError("environment already done; restart it")
|
||||||
|
await self._cancel_task()
|
||||||
|
self._state = "paused"
|
||||||
|
log.info("env paused (sim %.0f ms)", self.env.sim_time_ms)
|
||||||
|
|
||||||
|
async def resume(self) -> None:
|
||||||
|
"""Reschedule the ticker after a pause."""
|
||||||
|
if self.env is None or self._state != "paused":
|
||||||
|
raise ScenarioError(f"cannot resume: state is {self._state!r}")
|
||||||
|
self._state = "running"
|
||||||
|
self._task = asyncio.create_task(self._run())
|
||||||
|
log.info("env resumed (sim %.0f ms)", self.env.sim_time_ms)
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Deterministic fresh episode (NFR-8.7.3); ticker keeps running."""
|
||||||
|
if self.env is None:
|
||||||
|
raise ScenarioError("no environment loaded")
|
||||||
|
self.env.reset()
|
||||||
|
self._done_reason = None
|
||||||
|
if self._state == "done":
|
||||||
|
# the criterion may hold again on the fresh episode — but
|
||||||
|
# is_done() is re-checked every tick anyway, so just resume
|
||||||
|
self._state = "running"
|
||||||
|
self._task = asyncio.create_task(self._run())
|
||||||
|
log.info("env reset")
|
||||||
|
|
||||||
|
async def restart(self) -> None:
|
||||||
|
"""Fresh instance of the same ref (picks up edited source)."""
|
||||||
|
if self.env_ref is None:
|
||||||
|
raise ScenarioError("no environment to restart")
|
||||||
|
ref = self.env_ref
|
||||||
|
await self.stop()
|
||||||
|
self.start(ref)
|
||||||
|
|
||||||
|
async def discard(self) -> None:
|
||||||
|
"""Silent unload (module-list rebuild, shutdown path)."""
|
||||||
|
await self._cancel_task()
|
||||||
|
self.env = None
|
||||||
|
self.env_ref = None
|
||||||
|
self._state = "none"
|
||||||
|
self._done_reason = None
|
||||||
|
|
||||||
|
def dispose(self) -> None:
|
||||||
|
"""Synchronous unload without awaiting (rebuild path).
|
||||||
|
|
||||||
|
The API handlers ``await discard()`` BEFORE rebuilding; this
|
||||||
|
is the belt-and-braces guarantee for sync callers
|
||||||
|
(``rebuild_node``) that the ticker can never tick the new
|
||||||
|
image: ``cancel()`` takes effect before the loop resumes, and
|
||||||
|
a fully synchronous function yields no loop slots.
|
||||||
|
"""
|
||||||
|
if self._task is not None:
|
||||||
|
self._task.cancel()
|
||||||
|
self._task = None
|
||||||
|
self.env = None
|
||||||
|
self.env_ref = None
|
||||||
|
self._state = "none"
|
||||||
|
self._done_reason = None
|
||||||
|
|
||||||
|
# -- ticker -------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _run(self) -> None:
|
||||||
|
"""Monotonic-scheduled paced ticker (no drift), as in _serve."""
|
||||||
|
assert self.env is not None
|
||||||
|
period = self.env.TICK_MS / 1000.0
|
||||||
|
next_at = time.monotonic()
|
||||||
|
while True:
|
||||||
|
self.env.tick()
|
||||||
|
if self.env.is_done(self.env.observe()):
|
||||||
|
self._state = "done"
|
||||||
|
self._done_reason = "is_done()"
|
||||||
|
log.info("env is_done() satisfied; ticker stopped")
|
||||||
|
return
|
||||||
|
next_at += period
|
||||||
|
delay = next_at - time.monotonic()
|
||||||
|
if delay > 0:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
else:
|
||||||
|
next_at = time.monotonic() # catch up, no drift burst
|
||||||
|
|
||||||
|
async def _cancel_task(self) -> None:
|
||||||
|
if self._task is not None:
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._task = None
|
||||||
@@ -0,0 +1,966 @@
|
|||||||
|
"""Host network settings (spec §15): view and manipulate the host OS's
|
||||||
|
IP addresses from the web UI — Docker-gated, flag-gated, agent-mediated.
|
||||||
|
|
||||||
|
Split (FR-15.2.7): a container cannot change host addresses and the
|
||||||
|
simulator container itself shall not run privileged (§12 non-root
|
||||||
|
deployment), so a small companion service — the **hostnet agent** —
|
||||||
|
runs on the HOST, owns the interface whitelist, and serves a narrow
|
||||||
|
protocol over a Unix domain socket that compose mounts into the
|
||||||
|
container. The simulator talks to it through `HostNetClient`.
|
||||||
|
|
||||||
|
Protocol: one JSON request object per line, one JSON response object
|
||||||
|
per line (JSONL over the socket stream).
|
||||||
|
|
||||||
|
Operations (the whole surface — there is deliberately no
|
||||||
|
"run this shell command" op):
|
||||||
|
|
||||||
|
{"op": "health"} -> agent version, whitelist
|
||||||
|
{"op": "list"} -> interfaces + IPv4 addresses
|
||||||
|
{"op": "snapshot", "iface": "eth0"} -> address-set snapshot (token)
|
||||||
|
{"op": "add_address", "iface": ..., "address": ..., "prefix": ...}
|
||||||
|
{"op": "remove_address", "iface": ..., "address": ...}
|
||||||
|
{"op": "change_address", "iface": ..., "old": ..., "new": ...,
|
||||||
|
"prefix": ...} -> replace primary address
|
||||||
|
{"op": "restore", "iface": ...} -> restore last snapshot
|
||||||
|
{"op": "audit"} -> recent audit lines (bounded)
|
||||||
|
|
||||||
|
Validation happens TWICE by design (FR-15.2.4): the API validates the
|
||||||
|
payload shape before sending, and the agent re-validates against its
|
||||||
|
own whitelist and the live interface state — the container is never
|
||||||
|
trusted.
|
||||||
|
|
||||||
|
The agent writes changes through a **backend**:
|
||||||
|
* `Iproute2Backend` — netlink via pyroute2 if available, else the
|
||||||
|
`ip` CLI as a subprocess (Linux v1, FR-15.2.7)
|
||||||
|
* `FakeBackend` — in-memory interface model (tests, and the
|
||||||
|
`--dry-run` agent mode for bench rehearsal)
|
||||||
|
|
||||||
|
Changes are RUNTIME-ONLY (FR-15.2.4): nothing is written to
|
||||||
|
netplan/NetworkManager/systemd-networkd, so a host reboot restores the
|
||||||
|
deployed addressing — the guaranteed escape hatch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PROTOCOL_VERSION = 1
|
||||||
|
AUDIT_MAX = 200
|
||||||
|
DEFAULT_SOCKET = "/run/wago-sim/hostnet.sock"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Errors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class HostNetError(Exception):
|
||||||
|
"""Operator-visible error: bad input, refused operation, agent down."""
|
||||||
|
|
||||||
|
|
||||||
|
class HostNetRefused(HostNetError):
|
||||||
|
"""The agent refused the operation (whitelist, unknown interface)."""
|
||||||
|
|
||||||
|
|
||||||
|
class HostNetUnavailable(HostNetError):
|
||||||
|
"""The agent is not reachable (socket missing) — FR-15.2.8."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Address helpers (shared validation — API and agent both use these)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ipv4(text: object) -> str:
|
||||||
|
"""Validate an IPv4 address string, return its normalized form.
|
||||||
|
|
||||||
|
Raises HostNetError with a clean operator message otherwise — the
|
||||||
|
same message appears in the API's 400 detail and the agent's error
|
||||||
|
response.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
addr = ipaddress.IPv4Address(str(text).strip())
|
||||||
|
except (ipaddress.AddressValueError, ValueError, TypeError):
|
||||||
|
raise HostNetError(f"invalid IPv4 address {text!r}") from None
|
||||||
|
return str(addr)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_prefix(text: object) -> int:
|
||||||
|
"""Validate a prefix length 1..32 (an int or '24'-style string)."""
|
||||||
|
try:
|
||||||
|
prefix = int(text)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HostNetError(f"invalid prefix length {text!r} (1-32)") from None
|
||||||
|
if not 1 <= prefix <= 32:
|
||||||
|
raise HostNetError(f"prefix length must be 1-32, got {prefix}")
|
||||||
|
return prefix
|
||||||
|
|
||||||
|
|
||||||
|
def parse_iface(text: object) -> str:
|
||||||
|
"""Validate an interface name (alphanumeric + - _ . @)."""
|
||||||
|
name = str(text or "").strip()
|
||||||
|
if not name or len(name) > 15:
|
||||||
|
# Linux restricts interface names to 15 chars (IFNAMSIZ - 1)
|
||||||
|
raise HostNetError(f"invalid interface name {text!r}")
|
||||||
|
valid = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.@")
|
||||||
|
if not set(name) <= valid:
|
||||||
|
raise HostNetError(f"invalid interface name {text!r}")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Backends: the actual host mutation paths (FR-15.2.7)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBackend:
|
||||||
|
"""In-memory interface model — tests and the agent's --dry-run.
|
||||||
|
|
||||||
|
Models exactly the surface the agent needs: interfaces with a set
|
||||||
|
of (address, prefix) entries and an up/down flag (up only, for
|
||||||
|
display; link toggling is v2 per FR-15.2.4).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, interfaces: dict[str, list[str]] | None = None) -> None:
|
||||||
|
# iface -> ordered [(address, prefix), ...]; first = primary
|
||||||
|
self._ifaces: dict[str, list[tuple[str, int]]] = {}
|
||||||
|
for name, addrs in (interfaces or {}).items():
|
||||||
|
parsed: list[tuple[str, int]] = []
|
||||||
|
for entry in addrs:
|
||||||
|
addr, _, prefix = entry.partition("/")
|
||||||
|
parsed.append((parse_ipv4(addr), parse_prefix(prefix or 24)))
|
||||||
|
self._ifaces[name] = parsed
|
||||||
|
self.link_up: dict[str, bool] = {n: True for n in self._ifaces}
|
||||||
|
|
||||||
|
# -- read side ----------------------------------------------------------
|
||||||
|
|
||||||
|
def interfaces(self) -> list[dict[str, Any]]:
|
||||||
|
out = []
|
||||||
|
for name in sorted(self._ifaces):
|
||||||
|
out.append({
|
||||||
|
"name": name,
|
||||||
|
"up": self.link_up.get(name, True),
|
||||||
|
"addresses": [
|
||||||
|
{"address": a, "prefix": p} for a, p in self._ifaces[name]
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
def addresses(self, iface: str) -> list[tuple[str, int]]:
|
||||||
|
return list(self._ifaces.get(iface, []))
|
||||||
|
|
||||||
|
# -- write side ----------------------------------------------------------
|
||||||
|
|
||||||
|
def add_address(self, iface: str, address: str, prefix: int) -> None:
|
||||||
|
self._ifaces.setdefault(iface, [])
|
||||||
|
if address in [a for a, _ in self._ifaces[iface]]:
|
||||||
|
raise HostNetError(f"{address}/{prefix} already exists on {iface}")
|
||||||
|
self._ifaces[iface].append((address, prefix))
|
||||||
|
|
||||||
|
def remove_address(self, iface: str, address: str) -> None:
|
||||||
|
entries = self._ifaces.get(iface, [])
|
||||||
|
keep = [(a, p) for a, p in entries if a != address]
|
||||||
|
if len(keep) == len(entries):
|
||||||
|
raise HostNetError(f"{address} is not assigned to {iface}")
|
||||||
|
self._ifaces[iface] = keep
|
||||||
|
|
||||||
|
def replace_address(self, iface: str, old: str, new: str, prefix: int) -> None:
|
||||||
|
entries = self._ifaces.get(iface, [])
|
||||||
|
if old not in [a for a, _ in entries]:
|
||||||
|
raise HostNetError(f"{old} is not assigned to {iface}")
|
||||||
|
if new in [a for a, _ in entries] and new != old:
|
||||||
|
raise HostNetError(f"{new}/{prefix} already exists on {iface}")
|
||||||
|
self._ifaces[iface] = [
|
||||||
|
(new, prefix) if a == old else (a, p) for a, p in entries
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Iproute2Backend:
|
||||||
|
"""Linux host backend (FR-15.2.7).
|
||||||
|
|
||||||
|
Reads prefer netlink (pyroute2 — no process spawn per poll) and
|
||||||
|
fall back to parsing ``ip -o`` output when pyroute2 is not
|
||||||
|
installed. Writes ALWAYS go through the ``ip`` CLI: one uniform
|
||||||
|
mutation path with one error-message shape and one audit trail,
|
||||||
|
regardless of pyroute2 availability (as-built decision; it also
|
||||||
|
keeps minimal hosts working).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._ipr: Any = None
|
||||||
|
try:
|
||||||
|
from pyroute2 import IPRoute # type: ignore[import-not-found]
|
||||||
|
self._ipr = IPRoute()
|
||||||
|
except Exception: # pragma: no cover - exercised only on hosts
|
||||||
|
log.info("pyroute2 not available — using the `ip` CLI for reads too")
|
||||||
|
|
||||||
|
def interfaces(self) -> list[dict[str, Any]]:
|
||||||
|
out: dict[str, dict[str, Any]] = {}
|
||||||
|
for name, up in self._link_list():
|
||||||
|
out[name] = {"name": name, "up": up, "addresses": []}
|
||||||
|
for name, addr, prefix in self._addr_list():
|
||||||
|
if name in out:
|
||||||
|
out[name]["addresses"].append(
|
||||||
|
{"address": addr, "prefix": prefix})
|
||||||
|
return [out[k] for k in sorted(out)]
|
||||||
|
|
||||||
|
def addresses(self, iface: str) -> list[tuple[str, int]]:
|
||||||
|
return [(a, p) for n, a, p in self._addr_list() if n == iface]
|
||||||
|
|
||||||
|
def add_address(self, iface: str, address: str, prefix: int) -> None:
|
||||||
|
self._ip_cmd(["ip", "addr", "add", f"{address}/{prefix}", "dev", iface])
|
||||||
|
|
||||||
|
def remove_address(self, iface: str, address: str) -> None:
|
||||||
|
self._ip_cmd(["ip", "addr", "del", f"{address}/32", "dev", iface])
|
||||||
|
|
||||||
|
def replace_address(self, iface: str, old: str, new: str, prefix: int) -> None:
|
||||||
|
# order matters: add the NEW address first so the interface is
|
||||||
|
# never address-less mid-change (the operator's own session
|
||||||
|
# may ride on it). If the del then fails, BOTH addresses are
|
||||||
|
# present — recoverable, never connection-killing.
|
||||||
|
self._ip_cmd(["ip", "addr", "add", f"{new}/{prefix}", "dev", iface])
|
||||||
|
self._ip_cmd(["ip", "addr", "del", f"{old}/32", "dev", iface])
|
||||||
|
|
||||||
|
# -- internals ------------------------------------------------------------
|
||||||
|
|
||||||
|
def _link_list(self) -> list[tuple[str, bool]]:
|
||||||
|
if self._ipr is not None:
|
||||||
|
out = []
|
||||||
|
for link in self._ipr.get_links():
|
||||||
|
name = link.get_attr("IFLA_IFNAME")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
flags = link.get("flags", [])
|
||||||
|
if isinstance(flags, (list, tuple)):
|
||||||
|
up = "UP" in flags # pyroute2: name list
|
||||||
|
elif isinstance(flags, str):
|
||||||
|
up = "UP" in flags.split(",")
|
||||||
|
else:
|
||||||
|
up = bool(int(flags) & 1) # raw mask
|
||||||
|
out.append((name, bool(up)))
|
||||||
|
return out
|
||||||
|
text = self._ip_cmd(["ip", "-o", "link", "show"])
|
||||||
|
out = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
# "2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 ..."
|
||||||
|
head = line.split(":", 2)
|
||||||
|
if len(head) < 2:
|
||||||
|
continue
|
||||||
|
name = head[1].split("@", 1)[0].strip()
|
||||||
|
if "<" in line and ">" in line:
|
||||||
|
flagseg = line[line.index("<") + 1: line.index(">")]
|
||||||
|
up = "UP" in flagseg.split(",")
|
||||||
|
else:
|
||||||
|
up = False
|
||||||
|
out.append((name, bool(up)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _addr_list(self) -> list[tuple[str, str, int]]:
|
||||||
|
if self._ipr is not None:
|
||||||
|
idx_name: dict[int, str] = {}
|
||||||
|
for link in self._ipr.get_links():
|
||||||
|
idx_name[int(link["index"])] = link.get_attr("IFLA_IFNAME") or ""
|
||||||
|
out = []
|
||||||
|
for a in self._ipr.get_addr(family=socket.AF_INET):
|
||||||
|
out.append((
|
||||||
|
idx_name.get(int(a.get("index", -1)), ""),
|
||||||
|
a.get_attr("IFA_LOCAL") or "",
|
||||||
|
int(a.get("prefixlen", 0) or 0),
|
||||||
|
))
|
||||||
|
return out
|
||||||
|
text = self._ip_cmd(["ip", "-o", "-4", "addr", "show"])
|
||||||
|
out = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
# "2: eth0 inet 192.168.1.5/24 brd ... scope global eth0"
|
||||||
|
words = line.split()
|
||||||
|
if len(words) < 4 or words[2] != "inet":
|
||||||
|
continue
|
||||||
|
addr, _, prefix = words[3].partition("/")
|
||||||
|
try:
|
||||||
|
out.append((words[1].split("@", 1)[0].strip(),
|
||||||
|
str(ipaddress.IPv4Address(addr)), int(prefix)))
|
||||||
|
except ValueError:
|
||||||
|
continue # e.g. "peer <addr>" point-to-point entries
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _ip_cmd(self, cmd: list[str]) -> str:
|
||||||
|
"""Run one `ip` command; map failures to operator messages."""
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
cmd, capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HostNetUnavailable(
|
||||||
|
f"`ip` tool not found on this host ({exc})") from None
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise HostNetError(
|
||||||
|
f"`{' '.join(cmd[1:])}` timed out") from exc
|
||||||
|
if proc.returncode != 0:
|
||||||
|
stderr = proc.stderr.strip() or "no error output"
|
||||||
|
raise HostNetError(f"`{' '.join(cmd[1:])}` failed: {stderr}")
|
||||||
|
return proc.stdout
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The agent (host side)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HostNetAgent:
|
||||||
|
"""Serves the hostnet protocol on a Unix socket (host process).
|
||||||
|
|
||||||
|
The agent is deliberately synchronous, single-threaded per
|
||||||
|
connection: the operations are quick, low-frequency, and must be
|
||||||
|
strictly ordered (snapshot → mutate) to be safe.
|
||||||
|
"""
|
||||||
|
|
||||||
|
backend: Any = None # FakeBackend | Iproute2Backend
|
||||||
|
socket_path: str = "/run/wago-sim/hostnet.sock"
|
||||||
|
whitelist: list[str] = field(default_factory=list)
|
||||||
|
audit_path: str | None = None # separate audit file (optional)
|
||||||
|
_snapshots: dict[str, list[tuple[str, int]]] = field(default_factory=dict)
|
||||||
|
_audit: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.backend is None:
|
||||||
|
self.backend = FakeBackend()
|
||||||
|
if not self.whitelist:
|
||||||
|
raise HostNetError(
|
||||||
|
"refusing to start with an empty whitelist — "
|
||||||
|
"name the interfaces the page may touch "
|
||||||
|
"(config key: whitelist)")
|
||||||
|
|
||||||
|
# -- audit trail (FR-15.2.5) ----------------------------------------------
|
||||||
|
|
||||||
|
def audit(self, line: str) -> None:
|
||||||
|
"""Append one single-line audit record; bounded in memory."""
|
||||||
|
stamp = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
entry = f"{stamp} {line}"
|
||||||
|
self._audit.append(entry)
|
||||||
|
if len(self._audit) > AUDIT_MAX:
|
||||||
|
del self._audit[: len(self._audit) - AUDIT_MAX]
|
||||||
|
# also log through the service log (stdout/journald, FR-12.2.6)
|
||||||
|
log.info("hostnet-audit %s", line)
|
||||||
|
if self.audit_path:
|
||||||
|
try:
|
||||||
|
Path(self.audit_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(self.audit_path, "a", encoding="utf-8") as fh:
|
||||||
|
fh.write(entry + "\n")
|
||||||
|
except OSError as exc: # pragma: no cover - host filesystem
|
||||||
|
log.warning("audit append failed: %s", exc)
|
||||||
|
|
||||||
|
def audit_lines(self, limit: int = 50) -> list[str]:
|
||||||
|
return list(self._audit[-limit:])
|
||||||
|
|
||||||
|
# -- snapshot/restore (FR-15.2.5) -------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _by(operator: str) -> str:
|
||||||
|
"""Audit suffix naming the acting operator, if known."""
|
||||||
|
return f" by {operator}" if operator else ""
|
||||||
|
|
||||||
|
def _snapshot(self, iface: str,
|
||||||
|
operator: str = "") -> list[tuple[str, int]]:
|
||||||
|
entries = [(a, p) for a, p in self.backend.addresses(iface)]
|
||||||
|
self._snapshots[iface] = entries
|
||||||
|
self.audit(f"snapshot {iface} "
|
||||||
|
f"[{', '.join(f'{a}/{p}' for a, p in entries)}]"
|
||||||
|
f"{self._by(operator)}")
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def _restore(self, iface: str,
|
||||||
|
operator: str = "") -> list[tuple[str, int]]:
|
||||||
|
snap = self._snapshots.get(iface)
|
||||||
|
if snap is None:
|
||||||
|
raise HostNetError(f"no snapshot for {iface} — nothing to restore")
|
||||||
|
current = [(a, p) for a, p in self.backend.addresses(iface)]
|
||||||
|
# apply the snapshot: remove addresses not in it, add missing
|
||||||
|
# ones. Restore itself does NOT snapshot — undo stays undoable.
|
||||||
|
snap_addrs = {a for a, _ in snap}
|
||||||
|
for a, _p in current:
|
||||||
|
if a not in snap_addrs:
|
||||||
|
try:
|
||||||
|
self.backend.remove_address(iface, a)
|
||||||
|
except HostNetError:
|
||||||
|
pass # address vanished on its own — already fine
|
||||||
|
for a, p in snap:
|
||||||
|
if a not in {ca for ca, _ in current}:
|
||||||
|
self.backend.add_address(iface, a, p)
|
||||||
|
self.audit(f"restore {iface} -> "
|
||||||
|
f"[{', '.join(f'{a}/{p}' for a, p in snap)}]"
|
||||||
|
f"{self._by(operator)}")
|
||||||
|
return self._snapshots[iface]
|
||||||
|
|
||||||
|
# -- operation dispatch ------------------------------------------------------
|
||||||
|
|
||||||
|
# ops that carry an iface argument — everything else is either
|
||||||
|
# op-only (health/list/audit) or unknown (rejected below)
|
||||||
|
_IFACE_OPS = frozenset({"snapshot", "restore", "add_address",
|
||||||
|
"remove_address", "change_address"})
|
||||||
|
|
||||||
|
def handle(self, request: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""One request object -> one response object (protocol v1)."""
|
||||||
|
op = str(request.get("op", "")).strip()
|
||||||
|
operator = str(request.get("operator", "")).strip()
|
||||||
|
by = self._by(operator)
|
||||||
|
try:
|
||||||
|
if op not in ("health", "list", "audit") and op not in self._IFACE_OPS:
|
||||||
|
raise HostNetError(f"unknown operation {op!r}")
|
||||||
|
if op == "health":
|
||||||
|
return {"ok": True, "protocol": PROTOCOL_VERSION,
|
||||||
|
"backend": type(self.backend).__name__,
|
||||||
|
"whitelist": list(self.whitelist)}
|
||||||
|
if op == "list":
|
||||||
|
ifaces = self.backend.interfaces()
|
||||||
|
return {"ok": True, "interfaces": [
|
||||||
|
{"name": i["name"], "up": i["up"],
|
||||||
|
"addresses": i["addresses"]}
|
||||||
|
for i in ifaces
|
||||||
|
]}
|
||||||
|
if op == "audit":
|
||||||
|
try:
|
||||||
|
limit = int(request.get("limit", 50))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 50
|
||||||
|
return {"ok": True, "lines": self.audit_lines(
|
||||||
|
max(1, min(limit, AUDIT_MAX)))}
|
||||||
|
# every iface op shares the same guards (FR-15.2.4/5)
|
||||||
|
iface = parse_iface(request.get("iface"))
|
||||||
|
self._check_whitelist(iface)
|
||||||
|
self._check_exists(iface)
|
||||||
|
if op == "snapshot":
|
||||||
|
return {"ok": True, "iface": iface,
|
||||||
|
"addresses": self._snapshot(iface, operator)}
|
||||||
|
if op == "restore":
|
||||||
|
return {"ok": True, "iface": iface,
|
||||||
|
"addresses": self._restore(iface, operator)}
|
||||||
|
if op == "add_address":
|
||||||
|
address = parse_ipv4(request.get("address"))
|
||||||
|
prefix = parse_prefix(request.get("prefix", 24))
|
||||||
|
self._require_absent(iface, address, prefix)
|
||||||
|
self._snapshot(iface, operator) # auto-snapshot (FR-15.2.5)
|
||||||
|
self.backend.add_address(iface, address, prefix)
|
||||||
|
self.audit(f"add {iface} {address}/{prefix}{by}")
|
||||||
|
return {"ok": True, "iface": iface, "address": address,
|
||||||
|
"prefix": prefix}
|
||||||
|
if op == "remove_address":
|
||||||
|
address = parse_ipv4(request.get("address"))
|
||||||
|
self._require_present(iface, address)
|
||||||
|
self._snapshot(iface, operator)
|
||||||
|
self.backend.remove_address(iface, address)
|
||||||
|
self.audit(f"remove {iface} {address}{by}")
|
||||||
|
return {"ok": True, "iface": iface, "address": address}
|
||||||
|
if op == "change_address":
|
||||||
|
old = parse_ipv4(request.get("old"))
|
||||||
|
new = parse_ipv4(request.get("new"))
|
||||||
|
prefix = parse_prefix(request.get("prefix", 24))
|
||||||
|
self._require_present(iface, old)
|
||||||
|
if new != old:
|
||||||
|
self._require_absent(iface, new, prefix)
|
||||||
|
self._snapshot(iface, operator)
|
||||||
|
self.backend.replace_address(iface, old, new, prefix)
|
||||||
|
self.audit(f"change {iface} {old} -> {new}/{prefix}{by}")
|
||||||
|
return {"ok": True, "iface": iface, "address": new,
|
||||||
|
"prefix": prefix}
|
||||||
|
except HostNetRefused as exc:
|
||||||
|
# refusals are auditable events too (FR-15.2.5)
|
||||||
|
self.audit(f"REFUSED op={op or 'none'} {exc}")
|
||||||
|
return {"ok": False, "error": str(exc), "refused": True}
|
||||||
|
except HostNetError as exc:
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
def _require_present(self, iface: str, address: str) -> None:
|
||||||
|
if address not in [a for a, _ in self.backend.addresses(iface)]:
|
||||||
|
raise HostNetError(f"{address} is not assigned to {iface}")
|
||||||
|
|
||||||
|
def _require_absent(self, iface: str, address: str, prefix: int) -> None:
|
||||||
|
if address in [a for a, _ in self.backend.addresses(iface)]:
|
||||||
|
raise HostNetError(
|
||||||
|
f"{address}/{prefix} already exists on {iface}")
|
||||||
|
|
||||||
|
def _check_whitelist(self, iface: str) -> None:
|
||||||
|
if iface not in self.whitelist:
|
||||||
|
raise HostNetRefused(
|
||||||
|
f"interface {iface!r} is not in the agent whitelist "
|
||||||
|
f"({', '.join(self.whitelist)})")
|
||||||
|
|
||||||
|
def _check_exists(self, iface: str) -> None:
|
||||||
|
names = {i["name"] for i in self.backend.interfaces()}
|
||||||
|
if iface not in names:
|
||||||
|
raise HostNetRefused(f"interface {iface!r} does not exist")
|
||||||
|
|
||||||
|
# -- socket serving --------------------------------------------------------
|
||||||
|
|
||||||
|
def serve_forever(self) -> None: # pragma: no cover - host process
|
||||||
|
"""Listen on the Unix socket, one thread per connection.
|
||||||
|
|
||||||
|
Runs until the process is terminated (systemd stop). Socket
|
||||||
|
directory is created (0755) and the socket itself 0660 so the
|
||||||
|
container's non-root user can connect when the compose mount
|
||||||
|
carries the same group.
|
||||||
|
"""
|
||||||
|
path = Path(self.socket_path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
server.bind(str(path))
|
||||||
|
os.chmod(path, 0o660)
|
||||||
|
server.listen(8)
|
||||||
|
log.info("hostnet agent listening on %s (whitelist: %s)",
|
||||||
|
self.socket_path, ", ".join(self.whitelist))
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
conn, _ = server.accept()
|
||||||
|
threading.Thread(
|
||||||
|
target=self._serve_conn, args=(conn,), daemon=True
|
||||||
|
).start()
|
||||||
|
finally:
|
||||||
|
server.close()
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _serve_conn(self, conn: socket.socket) -> None: # pragma: no cover
|
||||||
|
"""One connection: newline-framed JSONL, one op per line."""
|
||||||
|
try:
|
||||||
|
conn.settimeout(30)
|
||||||
|
buf = b""
|
||||||
|
while True:
|
||||||
|
data = conn.recv(4096)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
buf += data
|
||||||
|
while b"\n" in buf:
|
||||||
|
line, buf = buf.split(b"\n", 1)
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
request = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
response = {"ok": False, "error": "invalid JSON"}
|
||||||
|
else:
|
||||||
|
if not isinstance(request, dict):
|
||||||
|
response = {"ok": False, "error": "request must be an object"}
|
||||||
|
else:
|
||||||
|
response = self.handle(request)
|
||||||
|
conn.sendall(json.dumps(response).encode() + b"\n")
|
||||||
|
except (OSError, socket.timeout):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The client (container side)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class HostNetClient:
|
||||||
|
"""Synchronous JSONL client for the hostnet agent (container side).
|
||||||
|
|
||||||
|
Used from FastAPI's threadpool (each request gets a fresh socket
|
||||||
|
connection — ops are low-frequency and must be strictly ordered;
|
||||||
|
a per-request connection also makes "agent restarted" a non-event).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, socket_path: str = DEFAULT_SOCKET,
|
||||||
|
timeout: float = 5.0) -> None:
|
||||||
|
self.socket_path = socket_path
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
# -- transport --------------------------------------------------------------
|
||||||
|
|
||||||
|
def _connect(self) -> socket.socket:
|
||||||
|
"""Open one connection to the agent (raises HostNetUnavailable)."""
|
||||||
|
if not os.path.exists(self.socket_path):
|
||||||
|
raise HostNetUnavailable(
|
||||||
|
f"host agent socket not found: {self.socket_path} — is the "
|
||||||
|
"hostnet agent running on the host and is the socket "
|
||||||
|
"mounted into the container?")
|
||||||
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(self.timeout)
|
||||||
|
try:
|
||||||
|
sock.connect(self.socket_path)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HostNetUnavailable(
|
||||||
|
f"host agent socket not found: {self.socket_path}") from exc
|
||||||
|
except ConnectionRefusedError as exc:
|
||||||
|
raise HostNetUnavailable(
|
||||||
|
"host agent refused the connection — socket present but no "
|
||||||
|
"agent listening") from exc
|
||||||
|
except (OSError, socket.timeout) as exc:
|
||||||
|
raise HostNetUnavailable(f"host agent unreachable: {exc}") from exc
|
||||||
|
return sock
|
||||||
|
|
||||||
|
def _call(self, request: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
sock = self._connect()
|
||||||
|
except HostNetUnavailable:
|
||||||
|
raise
|
||||||
|
buf = b""
|
||||||
|
try:
|
||||||
|
sock.sendall(json.dumps(request).encode() + b"\n")
|
||||||
|
while b"\n" not in buf:
|
||||||
|
chunk = sock.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buf += chunk
|
||||||
|
except (OSError, socket.timeout) as exc:
|
||||||
|
raise HostNetUnavailable(f"host agent unreachable: {exc}") from exc
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
sock.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if not buf:
|
||||||
|
raise HostNetUnavailable("host agent closed the connection")
|
||||||
|
try:
|
||||||
|
response = json.loads(buf.split(b"\n", 1)[0])
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise HostNetUnavailable(f"garbled agent response: {exc}") from exc
|
||||||
|
if not isinstance(response, dict):
|
||||||
|
raise HostNetUnavailable("agent response is not an object")
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _mutate(self, request: dict[str, Any], opname: str) -> dict[str, Any]:
|
||||||
|
"""One mutating call; map ok:false to the right error type.
|
||||||
|
|
||||||
|
`refused: true` (whitelist / unknown interface) maps back to
|
||||||
|
HostNetRefused so the API can answer 403 — everything else is
|
||||||
|
a validation/backend error (400).
|
||||||
|
"""
|
||||||
|
response = self._call(request)
|
||||||
|
if not response.get("ok"):
|
||||||
|
if response.get("refused"):
|
||||||
|
raise HostNetRefused(
|
||||||
|
response.get("error", f"agent refused {opname}"))
|
||||||
|
raise HostNetError(response.get("error", f"agent {opname} failed"))
|
||||||
|
return response
|
||||||
|
|
||||||
|
# -- protocol ops ------------------------------------------------------------
|
||||||
|
|
||||||
|
def health(self) -> dict[str, Any]:
|
||||||
|
return self._call({"op": "health"})
|
||||||
|
|
||||||
|
def list(self) -> list[dict[str, Any]]:
|
||||||
|
response = self._call({"op": "list"})
|
||||||
|
if not response.get("ok"):
|
||||||
|
raise HostNetError(response.get("error", "agent list failed"))
|
||||||
|
return response.get("interfaces", [])
|
||||||
|
|
||||||
|
def snapshot(self, iface: str, operator: str = "") -> list[dict[str, Any]]:
|
||||||
|
response = self._mutate(
|
||||||
|
{"op": "snapshot", "iface": parse_iface(iface),
|
||||||
|
"operator": operator}, "snapshot")
|
||||||
|
return response.get("addresses", [])
|
||||||
|
|
||||||
|
def add_address(self, iface: str, address: object, prefix: object,
|
||||||
|
operator: str = "") -> dict[str, Any]:
|
||||||
|
return self._mutate({
|
||||||
|
"op": "add_address",
|
||||||
|
"iface": parse_iface(iface),
|
||||||
|
"address": parse_ipv4(address),
|
||||||
|
"prefix": parse_prefix(prefix),
|
||||||
|
"operator": operator,
|
||||||
|
}, "add")
|
||||||
|
|
||||||
|
def remove_address(self, iface: str, address: object,
|
||||||
|
operator: str = "") -> dict[str, Any]:
|
||||||
|
return self._mutate({
|
||||||
|
"op": "remove_address",
|
||||||
|
"iface": parse_iface(iface),
|
||||||
|
"address": parse_ipv4(address),
|
||||||
|
"operator": operator,
|
||||||
|
}, "remove")
|
||||||
|
|
||||||
|
def change_address(self, iface: str, old: object, new: object,
|
||||||
|
prefix: object, operator: str = "") -> dict[str, Any]:
|
||||||
|
return self._mutate({
|
||||||
|
"op": "change_address",
|
||||||
|
"iface": parse_iface(iface),
|
||||||
|
"old": parse_ipv4(old),
|
||||||
|
"new": parse_ipv4(new),
|
||||||
|
"prefix": parse_prefix(prefix),
|
||||||
|
"operator": operator,
|
||||||
|
}, "change")
|
||||||
|
|
||||||
|
def restore(self, iface: str, operator: str = "") -> dict[str, Any]:
|
||||||
|
return self._mutate(
|
||||||
|
{"op": "restore", "iface": parse_iface(iface),
|
||||||
|
"operator": operator}, "restore")
|
||||||
|
|
||||||
|
def audit(self, limit: int = 50) -> list[str]:
|
||||||
|
response = self._call({"op": "audit", "limit": int(limit)})
|
||||||
|
if not response.get("ok"):
|
||||||
|
raise HostNetError(response.get("error", "agent audit failed"))
|
||||||
|
return response.get("lines", [])
|
||||||
|
|
||||||
|
|
||||||
|
class InProcessClient:
|
||||||
|
"""Drop-in client that calls a HostNetAgent directly, in process.
|
||||||
|
|
||||||
|
Used where a Unix-socket round trip is impossible (Windows dev
|
||||||
|
machines, ASGI tests) — same validation, same audit trail, because
|
||||||
|
every call funnels through the very same `handle()` the socket
|
||||||
|
server uses. NOT for production container use (FR-15.2.7: the
|
||||||
|
container must never mutate host state directly).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, agent: HostNetAgent) -> None:
|
||||||
|
self.agent = agent
|
||||||
|
self._operator = ""
|
||||||
|
|
||||||
|
def for_operator(self, operator: str) -> "InProcessClient":
|
||||||
|
"""Bound client whose calls audit with this operator name."""
|
||||||
|
clone = InProcessClient(self.agent)
|
||||||
|
clone._operator = operator
|
||||||
|
return clone
|
||||||
|
|
||||||
|
def _call(self, request: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
req = dict(request)
|
||||||
|
req.setdefault("operator", self._operator)
|
||||||
|
return self.agent.handle(req)
|
||||||
|
|
||||||
|
# mirror HostNetClient's op surface exactly ----------------------------
|
||||||
|
|
||||||
|
def _raise(self, response: dict[str, Any], opname: str) -> dict[str, Any]:
|
||||||
|
"""Map ok:false to the right error type (refused -> 403 path)."""
|
||||||
|
if not response.get("ok"):
|
||||||
|
if response.get("refused"):
|
||||||
|
raise HostNetRefused(
|
||||||
|
response.get("error", f"agent refused {opname}"))
|
||||||
|
raise HostNetError(response.get("error", f"agent {opname} failed"))
|
||||||
|
return response
|
||||||
|
|
||||||
|
def health(self) -> dict[str, Any]:
|
||||||
|
return self._call({"op": "health"})
|
||||||
|
|
||||||
|
def list(self) -> list[dict[str, Any]]:
|
||||||
|
response = self._call({"op": "list"})
|
||||||
|
if not response.get("ok"):
|
||||||
|
raise HostNetError(response.get("error", "agent list failed"))
|
||||||
|
return response.get("interfaces", [])
|
||||||
|
|
||||||
|
def snapshot(self, iface: str, operator: str = "") -> list[dict[str, Any]]:
|
||||||
|
response = self._call({"op": "snapshot", "iface": iface,
|
||||||
|
"operator": operator or self._operator})
|
||||||
|
return self._raise(response, "snapshot").get("addresses", [])
|
||||||
|
|
||||||
|
def add_address(self, iface: str, address: object, prefix: object,
|
||||||
|
operator: str = "") -> dict[str, Any]:
|
||||||
|
response = self._call({
|
||||||
|
"op": "add_address", "iface": iface, "address": address,
|
||||||
|
"prefix": prefix, "operator": operator or self._operator})
|
||||||
|
return self._raise(response, "add")
|
||||||
|
|
||||||
|
def remove_address(self, iface: str, address: object,
|
||||||
|
operator: str = "") -> dict[str, Any]:
|
||||||
|
response = self._call({"op": "remove_address", "iface": iface,
|
||||||
|
"address": address,
|
||||||
|
"operator": operator or self._operator})
|
||||||
|
return self._raise(response, "remove")
|
||||||
|
|
||||||
|
def change_address(self, iface: str, old: object, new: object,
|
||||||
|
prefix: object, operator: str = "") -> dict[str, Any]:
|
||||||
|
response = self._call({
|
||||||
|
"op": "change_address", "iface": iface, "old": old,
|
||||||
|
"new": new, "prefix": prefix,
|
||||||
|
"operator": operator or self._operator})
|
||||||
|
return self._raise(response, "change")
|
||||||
|
|
||||||
|
def restore(self, iface: str, operator: str = "") -> dict[str, Any]:
|
||||||
|
response = self._call({"op": "restore", "iface": iface,
|
||||||
|
"operator": operator or self._operator})
|
||||||
|
return self._raise(response, "restore")
|
||||||
|
|
||||||
|
def audit(self, limit: int = 50) -> list[str]:
|
||||||
|
response = self._call({"op": "audit", "limit": int(limit)})
|
||||||
|
if not response.get("ok"):
|
||||||
|
raise HostNetError(response.get("error", "agent audit failed"))
|
||||||
|
return response.get("lines", [])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The web-facing service (container side, §15.2 API mapping)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class HostNetService:
|
||||||
|
"""Facade the web API talks to: gating status + agent calls.
|
||||||
|
|
||||||
|
One instance per web UI (held on `fastapi_app.state.hostnet`).
|
||||||
|
`status()` answers FR-15.2.3's availability object without ever
|
||||||
|
touching the socket for gate checks; `interfaces()` etc. wrap the
|
||||||
|
client with clean HostNet* errors for the API to map to 503/400.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client: HostNetClient | InProcessClient) -> None:
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
def status(self) -> dict[str, Any]:
|
||||||
|
"""Availability + agent health (never raises)."""
|
||||||
|
health: dict[str, Any] | None = None
|
||||||
|
error: str | None = None
|
||||||
|
try:
|
||||||
|
health = self.client.health()
|
||||||
|
if not health.get("ok"):
|
||||||
|
error = str(health.get("error", "agent health failed"))
|
||||||
|
except HostNetError as exc:
|
||||||
|
error = str(exc)
|
||||||
|
return {
|
||||||
|
"available": error is None,
|
||||||
|
"error": error,
|
||||||
|
"agent": health,
|
||||||
|
}
|
||||||
|
|
||||||
|
def interfaces(self) -> list[dict[str, Any]]:
|
||||||
|
return self.client.list()
|
||||||
|
|
||||||
|
def add_address(self, iface: str, address: object, prefix: object,
|
||||||
|
operator: str = "") -> dict[str, Any]:
|
||||||
|
return self.client.add_address(iface, address, prefix, operator)
|
||||||
|
|
||||||
|
def remove_address(self, iface: str, address: object,
|
||||||
|
operator: str = "") -> dict[str, Any]:
|
||||||
|
return self.client.remove_address(iface, address, operator)
|
||||||
|
|
||||||
|
def change_address(self, iface: str, old: object, new: object,
|
||||||
|
prefix: object, operator: str = "") -> dict[str, Any]:
|
||||||
|
return self.client.change_address(iface, old, new, prefix, operator)
|
||||||
|
|
||||||
|
def restore(self, iface: str, operator: str = "") -> dict[str, Any]:
|
||||||
|
return self.client.restore(iface, operator)
|
||||||
|
|
||||||
|
def audit(self, limit: int = 50) -> list[str]:
|
||||||
|
return self.client.audit(limit)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Gating (FR-15.2.1 / FR-15.2.2): flag AND docker — computed once per process
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def hostnet_enabled(env: dict[str, str] | None = None,
|
||||||
|
docker_markers: list[Path] | None = None) -> bool:
|
||||||
|
"""True only when BOTH gates pass (spec §15.2).
|
||||||
|
|
||||||
|
Gate 1 — flag: WAGO_SIM_HOSTNET truthy (same semantics as
|
||||||
|
WAGO_SIM_VERBOSE: 1/true/yes/on).
|
||||||
|
Gate 2 — docker: WAGO_SIM_IN_DOCKER=1 (compose sets it) or the
|
||||||
|
/.dockerenv marker file exists (supplementary signal).
|
||||||
|
"""
|
||||||
|
e = env if env is not None else os.environ
|
||||||
|
flag = str(e.get("WAGO_SIM_HOSTNET", "")).strip().lower() in (
|
||||||
|
"1", "true", "yes", "on")
|
||||||
|
if not flag:
|
||||||
|
return False
|
||||||
|
in_docker = str(e.get("WAGO_SIM_IN_DOCKER", "")).strip().lower() in (
|
||||||
|
"1", "true", "yes", "on")
|
||||||
|
if not in_docker:
|
||||||
|
markers = docker_markers if docker_markers is not None \
|
||||||
|
else [Path("/.dockerenv")]
|
||||||
|
in_docker = any(m.exists() for m in markers)
|
||||||
|
return in_docker
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Agent configuration + entry point (host side)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def load_agent_config(path: str) -> dict[str, Any]:
|
||||||
|
"""Load the agent's YAML config with clean errors (CLI policy).
|
||||||
|
|
||||||
|
Required keys: `socket`, `whitelist` (non-empty list). Optional:
|
||||||
|
`audit_path`, `backend` (fake|iproute2, default iproute2).
|
||||||
|
"""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
p = Path(path)
|
||||||
|
if not p.is_file():
|
||||||
|
raise HostNetError(f"agent config file not found: {path}")
|
||||||
|
try:
|
||||||
|
cfg = yaml.safe_load(p.read_text(encoding="utf-8"))
|
||||||
|
except yaml.YAMLError as exc:
|
||||||
|
raise HostNetError(f"agent config {path} is not valid YAML:\n {exc}") from None
|
||||||
|
if not isinstance(cfg, dict):
|
||||||
|
raise HostNetError(f"agent config {path} must be a mapping")
|
||||||
|
if not cfg.get("socket"):
|
||||||
|
raise HostNetError(f"agent config {path}: 'socket' is required")
|
||||||
|
wl = cfg.get("whitelist")
|
||||||
|
if not isinstance(wl, list) or not wl or \
|
||||||
|
not all(isinstance(w, str) and w.strip() for w in wl):
|
||||||
|
raise HostNetError(
|
||||||
|
f"agent config {path}: 'whitelist' must be a non-empty list "
|
||||||
|
"of interface names")
|
||||||
|
cfg["whitelist"] = [str(w).strip() for w in wl]
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def build_agent_from_config(cfg: dict[str, Any]) -> HostNetAgent:
|
||||||
|
backend_name = str(cfg.get("backend", "iproute2")).lower()
|
||||||
|
if backend_name in ("fake", "dry-run"):
|
||||||
|
backend = FakeBackend()
|
||||||
|
elif backend_name == "iproute2":
|
||||||
|
backend = Iproute2Backend()
|
||||||
|
else:
|
||||||
|
raise HostNetError(
|
||||||
|
f"unknown backend {cfg.get('backend')!r} (use 'iproute2' or 'fake')")
|
||||||
|
return HostNetAgent(
|
||||||
|
backend=backend,
|
||||||
|
socket_path=str(cfg["socket"]),
|
||||||
|
whitelist=[str(w) for w in cfg["whitelist"]],
|
||||||
|
audit_path=cfg.get("audit_path"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def agent_main(argv: list[str] | None = None) -> int:
|
||||||
|
"""Entry point for ``wago-sim hostnet-agent`` (host process, root or
|
||||||
|
CAP_NET_ADMIN). Clean CLI errors per the policy (NFR-6.5)."""
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="wago-sim hostnet-agent",
|
||||||
|
description="Host-side agent for the simulator's host network "
|
||||||
|
"settings page (spec §15)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--config", required=True,
|
||||||
|
help="agent config YAML (socket, whitelist, backend)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
try:
|
||||||
|
cfg = load_agent_config(args.config)
|
||||||
|
agent = build_agent_from_config(cfg)
|
||||||
|
except HostNetError as exc:
|
||||||
|
print(f"wago-sim hostnet-agent: error: {exc}", file=sys_stderr())
|
||||||
|
return 2
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||||
|
stream=sys_stdout(),
|
||||||
|
)
|
||||||
|
agent.serve_forever()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def sys_stdout() -> Any:
|
||||||
|
import sys
|
||||||
|
return sys.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def sys_stderr() -> Any:
|
||||||
|
import sys
|
||||||
|
return sys.stderr
|
||||||
+129
-30
@@ -196,28 +196,77 @@ class VirtualNode:
|
|||||||
self.digital_in_bits = digital_in_bits
|
self.digital_in_bits = digital_in_bits
|
||||||
self.digital_out_bits = digital_out_bits
|
self.digital_out_bits = digital_out_bits
|
||||||
|
|
||||||
|
self._densify_ranges()
|
||||||
|
|
||||||
|
def _densify_ranges(self) -> None:
|
||||||
|
"""Complete the analog range map (FR-10.2.14, dense store).
|
||||||
|
|
||||||
|
Every analog channel gets an entry — defaults materialized —
|
||||||
|
and codes that no longer match an analog channel are pruned
|
||||||
|
(module list changed, FR-11.2). Digital codes never belong in
|
||||||
|
the range map (ranges apply to analog bars only).
|
||||||
|
"""
|
||||||
|
analog_codes = {self.io_code_at_slot("ai", loc.module_index, loc.channel - 1)
|
||||||
|
for loc in self._ai_channels} | \
|
||||||
|
{self.io_code_at_slot("ao", loc.module_index, loc.channel - 1)
|
||||||
|
for loc in self._ao_channels}
|
||||||
|
dense: dict[str, tuple[int, int]] = {}
|
||||||
|
for code in sorted(analog_codes):
|
||||||
|
spec = self.channel_ranges.get(code)
|
||||||
|
if spec is None or spec == self.DEFAULT_RANGE:
|
||||||
|
dense[code] = self.DEFAULT_RANGE
|
||||||
|
else:
|
||||||
|
dense[code] = spec
|
||||||
|
self.channel_ranges = dense
|
||||||
|
|
||||||
|
def set_channel_ranges(self, ranges: dict[str, tuple[int, int]]) -> None:
|
||||||
|
"""Replace all analog display ranges, then re-densify.
|
||||||
|
|
||||||
|
The store ends dense (FR-10.2.14): every analog channel of the
|
||||||
|
CURRENT layout present (missing entries become the default),
|
||||||
|
codes that no longer match an analog channel pruned.
|
||||||
|
"""
|
||||||
|
self.channel_ranges = dict(ranges)
|
||||||
|
self._densify_ranges()
|
||||||
|
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
"""Text for firmware-info register 0x2030 (module description)."""
|
"""Text for firmware-info register 0x2030 (module description)."""
|
||||||
return ", ".join(f"{i}:{m.article}" for i, m in enumerate(self.modules))
|
return ", ".join(f"{i}:{m.article}" for i, m in enumerate(self.modules))
|
||||||
|
|
||||||
# -- channel naming (spec FR-10.2.11) ----------------------------------
|
# -- channel naming (spec FR-10.2.11) ----------------------------------
|
||||||
|
|
||||||
def io_code(self, kind: str, module: int, channel: int) -> str:
|
def io_code_at_slot(self, kind: str, module_slot: int, channel: int) -> str:
|
||||||
"""I/O code for a module channel, e.g. ('di', 0, 0) -> 'DI0.0'."""
|
"""I/O code for a channel identified by PHYSICAL slot, e.g.
|
||||||
return f"{kind.upper()}{module}.{channel}"
|
('di', 0, 0) -> 'DI0.0' (first DI module). Internal callers that
|
||||||
|
walk the channel pools (slot-keyed) use this; the code itself
|
||||||
|
carries the per-type module number (FR-4.3.11 v1.5)."""
|
||||||
|
try:
|
||||||
|
per_type = self.kind_index(module_slot).get(kind)
|
||||||
|
except IndexError:
|
||||||
|
return f"{kind.upper()}?.{channel}"
|
||||||
|
if per_type is None:
|
||||||
|
return f"{kind.upper()}?.{channel}"
|
||||||
|
return f"{kind.upper()}{per_type}.{channel}"
|
||||||
|
|
||||||
def channel_name(self, kind: str, module: int, channel: int) -> str | None:
|
def channel_name(self, kind: str, module_slot: int, channel: int) -> str | None:
|
||||||
"""Configured application name for a channel, or None."""
|
"""Configured application name for a channel, or None."""
|
||||||
return self.channel_names.get(self.io_code(kind, module, channel))
|
return self.channel_names.get(
|
||||||
|
self.io_code_at_slot(kind, module_slot, channel)
|
||||||
|
)
|
||||||
|
|
||||||
def channel_range(self, kind: str, module: int, channel: int) -> tuple[int, int]:
|
def channel_range(self, kind: str, module_slot: int, channel: int) -> tuple[int, int]:
|
||||||
"""Display range (min, max) raw counts for an analog channel.
|
"""Display range (min, max) raw counts for an analog channel.
|
||||||
|
|
||||||
Defaults to (0, 4095) when unconfigured (FR-10.2.14).
|
Always defined for analog channels (dense store, FR-10.2.14):
|
||||||
|
the node materializes the default (0, 4095) at layout time,
|
||||||
|
so this returns the stored entry directly. Digital kinds are
|
||||||
|
rejected — ranges apply to analog bars only.
|
||||||
"""
|
"""
|
||||||
return self.channel_ranges.get(
|
if kind not in ("ai", "ao"):
|
||||||
self.io_code(kind, module, channel), self.DEFAULT_RANGE
|
raise ValueError(f"channel_range() is analog-only, got kind {kind!r}")
|
||||||
)
|
return self.channel_ranges[
|
||||||
|
self.io_code_at_slot(kind, module_slot, channel)
|
||||||
|
]
|
||||||
|
|
||||||
def validate_channel_names(self) -> list[str]:
|
def validate_channel_names(self) -> list[str]:
|
||||||
"""Names whose I/O code does not exist on this node (after a
|
"""Names whose I/O code does not exist on this node (after a
|
||||||
@@ -226,7 +275,7 @@ class VirtualNode:
|
|||||||
for kind, pool in (("di", self._di_channels), ("do", self._do_channels),
|
for kind, pool in (("di", self._di_channels), ("do", self._do_channels),
|
||||||
("ai", self._ai_channels), ("ao", self._ao_channels)):
|
("ai", self._ai_channels), ("ao", self._ao_channels)):
|
||||||
for loc in pool:
|
for loc in pool:
|
||||||
known.add(self.io_code(kind, loc.module_index, loc.channel - 1))
|
known.add(self.io_code_at_slot(kind, loc.module_index, loc.channel - 1))
|
||||||
return sorted(set(self.channel_names) - known)
|
return sorted(set(self.channel_names) - known)
|
||||||
|
|
||||||
# -- named channel access (scenario engine API) -----------------------------
|
# -- named channel access (scenario engine API) -----------------------------
|
||||||
@@ -256,24 +305,57 @@ class VirtualNode:
|
|||||||
raise IndexError(f"no {kind} channel {index} (have {len(pool)})")
|
raise IndexError(f"no {kind} channel {index} (have {len(pool)})")
|
||||||
return pool[index - 1]
|
return pool[index - 1]
|
||||||
|
|
||||||
|
def kind_modules(self, kind: str) -> list[ModuleType]:
|
||||||
|
"""Modules providing the given kind, in physical order.
|
||||||
|
|
||||||
|
`channel_by_module` numbers modules 0-based within this list
|
||||||
|
(FR-4.3.11 v1.5: DI0, DO0, AI0, AO0 start each kind's sequence).
|
||||||
|
"""
|
||||||
|
kind_field = {
|
||||||
|
"di": "input_bits", "do": "output_bits",
|
||||||
|
"ai": "input_words", "ao": "output_words",
|
||||||
|
}
|
||||||
|
if kind not in kind_field:
|
||||||
|
raise ValueError(f"unknown channel kind: {kind!r}")
|
||||||
|
field = kind_field[kind]
|
||||||
|
return [m for m in self.modules
|
||||||
|
if getattr(m, kind_field[kind]) > 0]
|
||||||
|
|
||||||
|
def kind_index(self, module_index: int) -> dict[str, int]:
|
||||||
|
"""Per-type number (FR-4.3.11 v1.5) for the module at the given
|
||||||
|
physical slot: {'di': 0, ...} for the first DI module etc."""
|
||||||
|
mod = self.modules[module_index]
|
||||||
|
out: dict[str, int] = {}
|
||||||
|
for kind in ("di", "do", "ai", "ao"):
|
||||||
|
field = {"di": "input_bits", "do": "output_bits",
|
||||||
|
"ai": "input_words", "ao": "output_words"}[kind]
|
||||||
|
if getattr(mod, field) == 0:
|
||||||
|
continue
|
||||||
|
same_kind = [i for i, m in enumerate(self.modules)
|
||||||
|
if getattr(m, field) > 0]
|
||||||
|
out[kind] = same_kind.index(module_index)
|
||||||
|
return out
|
||||||
|
|
||||||
def channel_by_module(self, kind: str, module: int, channel: int) -> ChannelLocation:
|
def channel_by_module(self, kind: str, module: int, channel: int) -> ChannelLocation:
|
||||||
"""Access a channel by module position and channel number, both 0-based.
|
"""Access a channel by module number and channel number, both 0-based.
|
||||||
|
|
||||||
Addresses a channel as *module type + module number + channel
|
Addresses a channel as *module type + module number + channel
|
||||||
number*, e.g. DI0.1 = digital input, module slot 0, channel 1.
|
number*, e.g. DI1.0 = digital inputs, the 2nd DI module, channel 0
|
||||||
The module number is the position in the configured module list;
|
(0-based) — **the module number counts within the module's type**
|
||||||
the channel number is the channel on that module (0-based), i.e.
|
(FR-4.3.11 v1.5): DI modules are numbered DI0, DI1, … regardless
|
||||||
DI0.1 selects the module's 2nd digital input.
|
of their physical slots among DO/AI/AO modules.
|
||||||
|
|
||||||
The channel kind must match what the module actually provides —
|
The channel kind must match what the module actually provides —
|
||||||
DI0.1 on a module with no inputs is a configuration error.
|
DI1.0 on a module with no inputs is a configuration error.
|
||||||
"""
|
"""
|
||||||
if not 0 <= module < len(self.modules):
|
modules_of_kind = self.kind_modules(kind)
|
||||||
|
if not 0 <= module < len(modules_of_kind):
|
||||||
raise IndexError(
|
raise IndexError(
|
||||||
f"no module {module} (node has {len(self.modules)} modules, "
|
f"no {kind.upper()} module {module} "
|
||||||
f"numbered 0-{len(self.modules) - 1})"
|
f"(node has {len(modules_of_kind)} {kind.upper()} modules, "
|
||||||
|
f"numbered 0-{len(modules_of_kind) - 1} within the type)"
|
||||||
)
|
)
|
||||||
mod = self.modules[module]
|
mod = modules_of_kind[module]
|
||||||
pool_map = {
|
pool_map = {
|
||||||
"di": (self._di_channels, mod.input_bits),
|
"di": (self._di_channels, mod.input_bits),
|
||||||
"do": (self._do_channels, mod.output_bits),
|
"do": (self._do_channels, mod.output_bits),
|
||||||
@@ -285,24 +367,41 @@ class VirtualNode:
|
|||||||
pool, module_channel_count = pool_map[kind]
|
pool, module_channel_count = pool_map[kind]
|
||||||
if module_channel_count == 0:
|
if module_channel_count == 0:
|
||||||
raise ChannelMismatch(
|
raise ChannelMismatch(
|
||||||
f"module {module} ({mod.article}) has no {kind.upper()} channels"
|
f"{kind.upper()} module {module} ({mod.article}) has no "
|
||||||
|
f"{kind.upper()} channels"
|
||||||
)
|
)
|
||||||
if not 0 <= channel < module_channel_count:
|
if not 0 <= channel < module_channel_count:
|
||||||
raise IndexError(
|
raise IndexError(
|
||||||
f"module {module} ({mod.article}) has no {kind.upper()} "
|
f"{kind.upper()} module {module} ({mod.article}) has no "
|
||||||
f"channel {channel} (has {module_channel_count}: 0-"
|
f"{kind.upper()} channel {channel} (has {module_channel_count}: "
|
||||||
f"{module_channel_count - 1})"
|
f"0-{module_channel_count - 1})"
|
||||||
)
|
)
|
||||||
# find the location: channels of one module are contiguous in the
|
# channels of one module are contiguous in the kind pool; find the
|
||||||
# kind pool; locate the first channel of this module in the pool
|
# physical slot of the per-type module and its matching location
|
||||||
|
physical_slot = [
|
||||||
|
i for i, m in enumerate(self.modules) if m is mod
|
||||||
|
][0]
|
||||||
for loc in pool:
|
for loc in pool:
|
||||||
if loc.module_index == module and loc.channel == channel + 1:
|
if (loc.module_index == physical_slot
|
||||||
|
and loc.channel == channel + 1):
|
||||||
return loc
|
return loc
|
||||||
raise ChannelMismatch(
|
raise ChannelMismatch(
|
||||||
f"module {module} ({mod.article}) channel {channel} is not a "
|
f"{kind.upper()} module {module} ({mod.article}) channel "
|
||||||
f"{kind.upper()} channel"
|
f"{channel} is not a {kind.upper()} channel"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def io_code(self, kind: str, module: int, channel: int) -> str:
|
||||||
|
"""I/O code with the PER-TYPE module number, e.g. ('di', 0, 0) -> 'DI0.0'
|
||||||
|
where the module number is the index within the module's type
|
||||||
|
(FR-4.3.11 v1.5)."""
|
||||||
|
modules_of_kind = self.kind_modules(kind)
|
||||||
|
if not 0 <= module < len(modules_of_kind):
|
||||||
|
raise IndexError(
|
||||||
|
f"no {kind.upper()} module {module} "
|
||||||
|
f"(node has {len(modules_of_kind)} {kind.upper()} modules)"
|
||||||
|
)
|
||||||
|
return f"{kind.upper()}{module}.{channel}"
|
||||||
|
|
||||||
# -- field-side setters (simulated world) -------------------------------------
|
# -- field-side setters (simulated world) -------------------------------------
|
||||||
|
|
||||||
def set_di(self, channel: int, value: bool) -> None:
|
def set_di(self, channel: int, value: bool) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""Streaming CSV dataset playback for continuous scenarios (spec §8, FR-8.2.2-11).
|
||||||
|
|
||||||
|
A dataset drives designated input aliases from a time-series CSV:
|
||||||
|
|
||||||
|
datasets:
|
||||||
|
inflow:
|
||||||
|
file: data/inflow.csv # time column + one column per alias
|
||||||
|
interpolation: linear # analog only; digital always holds
|
||||||
|
repeat: 4 # optional seamless loop
|
||||||
|
time_factor: 10 # dataset seconds per sim-second
|
||||||
|
offset: 1h # optional start shift on sim-time
|
||||||
|
time_column: time # default 'time'
|
||||||
|
time_format: elapsed # 'elapsed' (seconds) | 'iso8601'
|
||||||
|
|
||||||
|
Design points (FR-8.2.6): files are STREAMED with one-row lookahead —
|
||||||
|
a months-long CSV never loads into RAM. Malformed rows raise a
|
||||||
|
DataError carrying file+line so the engine can end the run with
|
||||||
|
verdict ERROR (FR-8.2.11).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .engine import DataError, ScenarioError
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_digital(text: str) -> bool:
|
||||||
|
v = text.strip().upper()
|
||||||
|
if v in ("ON", "1", "TRUE", "YES"):
|
||||||
|
return True
|
||||||
|
if v in ("OFF", "0", "FALSE", "NO", ""):
|
||||||
|
return False
|
||||||
|
raise ValueError(f"cannot parse digital value {text!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_time(text: str, fmt: str) -> float:
|
||||||
|
"""Elapsed seconds or ISO-8601 -> elapsed seconds since the epoch."""
|
||||||
|
if fmt == "iso8601":
|
||||||
|
dt = datetime.fromisoformat(text.strip())
|
||||||
|
return dt.timestamp()
|
||||||
|
return float(text)
|
||||||
|
|
||||||
|
|
||||||
|
class CsvDataset:
|
||||||
|
"""One streaming dataset (FR-8.2.2-11).
|
||||||
|
|
||||||
|
The engine calls :meth:`apply_at` every tick with the current
|
||||||
|
sim-time; the dataset sets its aliases via the engine's set_alias.
|
||||||
|
Bounded memory: exactly one lookahead row is held.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
engine,
|
||||||
|
name: str,
|
||||||
|
spec: dict,
|
||||||
|
*,
|
||||||
|
base_path: Path | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.engine = engine
|
||||||
|
self.name = name
|
||||||
|
self.path = Path(spec["file"])
|
||||||
|
if not self.path.is_absolute() and base_path is not None:
|
||||||
|
self.path = base_path / self.path
|
||||||
|
self.time_column = str(spec.get("time_column", "time"))
|
||||||
|
self.time_format = str(spec.get("time_format", "elapsed"))
|
||||||
|
self.interpolation = str(spec.get("interpolation", "linear"))
|
||||||
|
self.repeat = int(spec.get("repeat", 1))
|
||||||
|
self.time_factor = float(spec.get("time_factor", 1.0))
|
||||||
|
self.offset_ms = _parse_duration_ms(spec.get("offset", 0))
|
||||||
|
self.aliases: list[str] = []
|
||||||
|
self._fh = None
|
||||||
|
self._reader = None
|
||||||
|
self._header: list[str] | None = None
|
||||||
|
self._row: list[str] | None = None # current row
|
||||||
|
self._next: list[str] | None = None # one-row lookahead
|
||||||
|
self._row_time = 0.0 # dataset time of _row
|
||||||
|
self._loop = 1 # current repeat loop
|
||||||
|
self._start_epoch: float | None = None # iso8601 anchor
|
||||||
|
self._finished = False
|
||||||
|
self._line_no = 0
|
||||||
|
|
||||||
|
# -- lifecycle ------------------------------------------------------------
|
||||||
|
|
||||||
|
def open(self) -> None:
|
||||||
|
"""Validate the header and alias columns; prime the lookahead."""
|
||||||
|
if not self.path.is_file():
|
||||||
|
raise ScenarioError(f"dataset file not found: {self.path}")
|
||||||
|
self._fh = self.path.open("r", encoding="utf-8", newline="")
|
||||||
|
self._reader = csv.reader(self._fh)
|
||||||
|
try:
|
||||||
|
header = next(self._reader)
|
||||||
|
except StopIteration:
|
||||||
|
raise ScenarioError(f"dataset {self.path} is empty") from None
|
||||||
|
self._line_no = 1
|
||||||
|
header = [h.strip() for h in header]
|
||||||
|
if self.time_column not in header:
|
||||||
|
raise DataError(
|
||||||
|
str(self.path), 1, f"time column {self.time_column!r} not in header")
|
||||||
|
cols = [c for c in header if c != self.time_column]
|
||||||
|
if not cols:
|
||||||
|
raise DataError(str(self.path), 1, "no alias columns")
|
||||||
|
for col in cols:
|
||||||
|
if col not in self.engine._aliases:
|
||||||
|
raise DataError(
|
||||||
|
str(self.path), 1,
|
||||||
|
f"column {col!r} is not a known alias")
|
||||||
|
alias = self.engine._aliases[col]
|
||||||
|
if alias.kind in ("do", "ao"):
|
||||||
|
raise DataError(
|
||||||
|
str(self.path), 1,
|
||||||
|
f"output alias {col!r} cannot be dataset-driven")
|
||||||
|
self.aliases = cols
|
||||||
|
self._header = header
|
||||||
|
self._advance_row() # prime _row / _row_time
|
||||||
|
# iso8601 datasets: anchor elapsed time at the first sample so
|
||||||
|
# `offset` positions the series on the sim-time axis
|
||||||
|
if self.time_format == "iso8601" and self._row is not None:
|
||||||
|
self._start_epoch = self._row_time
|
||||||
|
self._row_time = 0.0
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._fh is not None:
|
||||||
|
self._fh.close()
|
||||||
|
self._fh = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def finished(self) -> bool:
|
||||||
|
return self._finished
|
||||||
|
|
||||||
|
# -- streaming -----------------------------------------------------------
|
||||||
|
|
||||||
|
def _read_next(self) -> list[str] | None:
|
||||||
|
"""Read the next data row, skipping comment lines (#)."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
raw = next(self._reader)
|
||||||
|
except StopIteration:
|
||||||
|
return None
|
||||||
|
self._line_no += 1
|
||||||
|
if raw and raw[0].lstrip().startswith("#"):
|
||||||
|
continue
|
||||||
|
return raw
|
||||||
|
|
||||||
|
def _advance_row(self) -> None:
|
||||||
|
"""Move the lookahead into the current row slot."""
|
||||||
|
if self._next is None:
|
||||||
|
self._next = self._read_next()
|
||||||
|
if self._next is not None:
|
||||||
|
self._next_t = self._parse_time_of(self._next)
|
||||||
|
if self._next is None:
|
||||||
|
# end of file: loop if repeats remain
|
||||||
|
if self._loop < self.repeat:
|
||||||
|
self._loop += 1
|
||||||
|
self._fh.seek(0)
|
||||||
|
next(self._reader) # skip header
|
||||||
|
self._line_no = 1
|
||||||
|
self._next = self._read_next()
|
||||||
|
if self._next is None:
|
||||||
|
self._finished = True
|
||||||
|
return
|
||||||
|
self._next_t = self._parse_time_of(self._next)
|
||||||
|
# seamless seam (FR-8.2.7): continue the time axis from
|
||||||
|
# the last sample of the previous loop
|
||||||
|
self._next_t = self._row_time + (
|
||||||
|
self._next_t - self._loop_anchor)
|
||||||
|
else:
|
||||||
|
self._finished = True
|
||||||
|
return
|
||||||
|
self._row = self._next
|
||||||
|
self._row_time = self._next_t if self._loop == 1 else self._row_time + (
|
||||||
|
self._next_t - self._row_time)
|
||||||
|
self._next = self._read_next()
|
||||||
|
if self._next is not None:
|
||||||
|
self._next_t = self._parse_time_of(self._next)
|
||||||
|
if self._next_t <= self._row_time:
|
||||||
|
raise DataError(
|
||||||
|
str(self.path), self._line_no,
|
||||||
|
f"non-monotonic time {self._next_t}")
|
||||||
|
|
||||||
|
_loop_anchor = 0.0
|
||||||
|
|
||||||
|
def _parse_time_of(self, row: list[str]) -> float:
|
||||||
|
try:
|
||||||
|
idx = self._header.index(self.time_column)
|
||||||
|
return _parse_time(row[idx], self.time_format)
|
||||||
|
except (ValueError, IndexError) as exc:
|
||||||
|
raise DataError(
|
||||||
|
str(self.path), self._line_no,
|
||||||
|
f"cannot parse time: {exc}") from None
|
||||||
|
|
||||||
|
def _value_of(self, row: list[str], alias: str) -> object:
|
||||||
|
idx = self._header.index(alias)
|
||||||
|
if idx >= len(row):
|
||||||
|
raise DataError(str(self.path), self._line_no,
|
||||||
|
f"missing column {alias!r}")
|
||||||
|
alias_spec = self.engine._aliases[alias]
|
||||||
|
text = row[idx]
|
||||||
|
try:
|
||||||
|
if alias_spec.kind == "di":
|
||||||
|
return _parse_digital(text)
|
||||||
|
return float(text)
|
||||||
|
except ValueError:
|
||||||
|
raise DataError(
|
||||||
|
str(self.path), self._line_no,
|
||||||
|
f"cannot parse value for {alias!r}: {text!r}") from None
|
||||||
|
|
||||||
|
# -- per-tick playback ---------------------------------------------------
|
||||||
|
|
||||||
|
def apply_at(self, sim_time_ms: float) -> None:
|
||||||
|
"""Set this dataset's aliases to their values at sim_time_ms."""
|
||||||
|
if self._finished:
|
||||||
|
return
|
||||||
|
# dataset-time position on the sim-time axis (time_factor applied)
|
||||||
|
effective_ms = (sim_time_ms - self.offset_ms) * self.time_factor
|
||||||
|
if effective_ms < 0:
|
||||||
|
return # not started yet (offset)
|
||||||
|
effective_s = effective_ms / 1000.0
|
||||||
|
# advance the stream until the row pair brackets the time
|
||||||
|
while (self._next is not None
|
||||||
|
and self._next_t <= effective_s):
|
||||||
|
self._advance_row()
|
||||||
|
if self._row is None:
|
||||||
|
return
|
||||||
|
# interpolate/hold between _row and _next (FR-8.2.5)
|
||||||
|
for alias in self.aliases:
|
||||||
|
kind = self.engine._aliases[alias].kind
|
||||||
|
if kind == "di":
|
||||||
|
self.engine.set_alias(alias, self._value_of(self._row, alias))
|
||||||
|
continue
|
||||||
|
cur = self._value_of(self._row, alias)
|
||||||
|
if (self.interpolation == "linear" and self._next is not None):
|
||||||
|
nxt = self._value_of(self._next, alias)
|
||||||
|
span = self._next_t - self._row_time
|
||||||
|
if span > 0:
|
||||||
|
frac = (effective_s - self._row_time) / span
|
||||||
|
frac = min(1.0, max(0.0, frac))
|
||||||
|
cur = cur + (nxt - cur) * frac
|
||||||
|
self.engine.set_alias(alias, cur)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_duration_ms(value: str | int | float) -> float:
|
||||||
|
"""'1h30m' style offsets -> milliseconds (reuses engine parser)."""
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return float(value)
|
||||||
|
text = str(value).strip().lower()
|
||||||
|
multipliers = {"ms": 1.0, "s": 1000.0, "m": 60000.0, "h": 3600000.0}
|
||||||
|
for suffix in ("ms", "s", "m", "h"):
|
||||||
|
if text.endswith(suffix):
|
||||||
|
try:
|
||||||
|
return float(text[: -len(suffix)]) * multipliers[suffix]
|
||||||
|
except ValueError:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
return float(text)
|
||||||
|
except ValueError:
|
||||||
|
raise ScenarioError(f"cannot parse offset {value!r}") from None
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Wear counters and degradation models (spec §8.3, FR-8.3.1-5).
|
||||||
|
|
||||||
|
Plant parameters (e.g. ``pump_capacity``) age from wear instead of
|
||||||
|
staying ideal forever, exercising the PLC's compensation and
|
||||||
|
maintenance logic:
|
||||||
|
|
||||||
|
degradation:
|
||||||
|
pump_capacity: # parameter name (float 0..1)
|
||||||
|
from: 1.0 # fresh value (also the reset target)
|
||||||
|
model: linear # linear | exponential | staircase
|
||||||
|
rate: 0.0002 # per runtime hour (linear/exp)
|
||||||
|
per: runtime_hours # wear basis
|
||||||
|
on: pump_relay # alias observed for wear (any kind)
|
||||||
|
staircase: [0.95, 0.85, 0.7] # staircase model: levels per step
|
||||||
|
maintenance_at: 0.75 # parameter threshold -> diagnosis
|
||||||
|
diagnosis: # injected when threshold crossed
|
||||||
|
module: 2 # per-type DI/DO... module number
|
||||||
|
channel: 0
|
||||||
|
value: 0x0001
|
||||||
|
|
||||||
|
Counters update once per tick from the observed alias truth (runtime
|
||||||
|
hours, rising/falling edges). The engine reads parameters via
|
||||||
|
``engine.parameter(name)`` (FR-8.3.3); the maintenance alarm is the
|
||||||
|
PLC's to raise — the scenario asserts it (FR-8.3.4); the built-in
|
||||||
|
``maintenance_reset`` action clears diagnosis and restores ``from``
|
||||||
|
(FR-8.3.5).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WearCounters:
|
||||||
|
"""Per-alias wear (FR-8.3.1)."""
|
||||||
|
|
||||||
|
runtime_hours: float = 0.0
|
||||||
|
rising_edges: int = 0
|
||||||
|
falling_edges: int = 0
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"runtime_hours": self.runtime_hours,
|
||||||
|
"rising_edges": self.rising_edges,
|
||||||
|
"falling_edges": self.falling_edges,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DegradationModel:
|
||||||
|
"""One aging parameter (FR-8.3.2-5)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
parameter: str # parameter key, e.g. 'pump_capacity'
|
||||||
|
observes: str # alias name the wear counts come from
|
||||||
|
model: str = "linear" # linear | exponential | staircase
|
||||||
|
start: float = 1.0 # fresh value; maintenance_reset target
|
||||||
|
rate: float = 0.0 # per hour (linear/exponential) or per cycle
|
||||||
|
basis: str = "runtime_hours" # runtime_hours | cycles
|
||||||
|
staircase: list[float] = field(default_factory=list)
|
||||||
|
maintenance_at: float | None = None
|
||||||
|
diagnosis: dict | None = None
|
||||||
|
# runtime state
|
||||||
|
value: float = 1.0
|
||||||
|
cycles: int = 0
|
||||||
|
diagnosed: bool = False
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""FR-8.3.5: restore to `start`; diagnosis cleared by the engine."""
|
||||||
|
self.value = self.start
|
||||||
|
self.cycles = 0
|
||||||
|
|
||||||
|
def update(self, dt_hours: float, wear: WearCounters) -> None:
|
||||||
|
"""Advance the parameter one tick (FR-8.3.2)."""
|
||||||
|
if self.basis == "cycles":
|
||||||
|
if self.model == "staircase":
|
||||||
|
level = min(self.cycles, len(self.staircase) - 1)
|
||||||
|
self.value = self.staircase[level]
|
||||||
|
elif self.model == "linear":
|
||||||
|
self.value = self.start - self.rate * self.cycles
|
||||||
|
elif self.model == "exponential":
|
||||||
|
self.value = self.start * (self.rate ** self.cycles)
|
||||||
|
else:
|
||||||
|
if self.model == "staircase":
|
||||||
|
# `rate` = levels per runtime hour; rate<=0 = one level/hour
|
||||||
|
per_level_hours = (1.0 / self.rate) if self.rate > 0 else 1.0
|
||||||
|
level = min(int(wear.runtime_hours / per_level_hours),
|
||||||
|
len(self.staircase) - 1)
|
||||||
|
self.value = self.staircase[level]
|
||||||
|
elif self.model == "exponential":
|
||||||
|
self.value = self.start * (1.0 - self.rate) ** wear.runtime_hours
|
||||||
|
else: # linear
|
||||||
|
self.value = self.start - self.rate * wear.runtime_hours
|
||||||
|
self.value = max(0.0, min(1.0, self.value))
|
||||||
|
|
||||||
|
|
||||||
|
class DegradationManager:
|
||||||
|
"""All aging parameters of one continuous run (spec §8.3)."""
|
||||||
|
|
||||||
|
def __init__(self, engine, spec: dict | None) -> None:
|
||||||
|
self.engine = engine
|
||||||
|
self.models: dict[str, DegradationModel] = {}
|
||||||
|
self.wear: dict[str, WearCounters] = {}
|
||||||
|
self._last_truth: dict[str, bool] = {}
|
||||||
|
for param_name, cfg in (spec or {}).items():
|
||||||
|
if not isinstance(cfg, dict):
|
||||||
|
raise ValueError(f"degradation {param_name!r} must be a mapping")
|
||||||
|
# NB: the key is 'observes', NOT 'on' — YAML 1.1 parses the
|
||||||
|
# bare key `on` as boolean True (a classic trap)
|
||||||
|
observes = str(cfg.get("observes", ""))
|
||||||
|
if observes not in engine._aliases:
|
||||||
|
raise ValueError(
|
||||||
|
f"degradation {param_name!r}: unknown alias {observes!r}")
|
||||||
|
model = DegradationModel(
|
||||||
|
name=param_name,
|
||||||
|
parameter=str(cfg.get("parameter", param_name)),
|
||||||
|
observes=observes,
|
||||||
|
model=str(cfg.get("model", "linear")),
|
||||||
|
start=float(cfg.get("from", 1.0)),
|
||||||
|
rate=float(cfg.get("rate", 0.0)),
|
||||||
|
basis=str(cfg.get("per", "runtime_hours")),
|
||||||
|
staircase=[float(v) for v in cfg.get("staircase", [])],
|
||||||
|
maintenance_at=(float(cfg["maintenance_at"])
|
||||||
|
if "maintenance_at" in cfg else None),
|
||||||
|
diagnosis=cfg.get("diagnosis"),
|
||||||
|
)
|
||||||
|
if model.model == "staircase" and not model.staircase:
|
||||||
|
raise ValueError(
|
||||||
|
f"degradation {param_name!r}: staircase model needs 'staircase'")
|
||||||
|
model.value = model.start
|
||||||
|
self.models[param_name] = model
|
||||||
|
self.wear.setdefault(observes, WearCounters())
|
||||||
|
|
||||||
|
# -- per-tick update ------------------------------------------------------
|
||||||
|
|
||||||
|
def tick(self, dt_ms: float) -> None:
|
||||||
|
"""Update wear counters and parameters for one tick (FR-8.2.12 step 4)."""
|
||||||
|
dt_hours = dt_ms / 3_600_000.0
|
||||||
|
for alias_name, wear in self.wear.items():
|
||||||
|
truth = bool(self.engine.alias_value(alias_name))
|
||||||
|
before = self._last_truth.get(alias_name, False)
|
||||||
|
if truth:
|
||||||
|
wear.runtime_hours += dt_hours
|
||||||
|
if truth and not before:
|
||||||
|
wear.rising_edges += 1
|
||||||
|
# count a cycle per completed run: rising edge closes it
|
||||||
|
for model in self.models.values():
|
||||||
|
if model.observes == alias_name and model.basis == "cycles":
|
||||||
|
model.cycles += 1
|
||||||
|
if (not truth) and before:
|
||||||
|
wear.falling_edges += 1
|
||||||
|
self._last_truth[alias_name] = truth
|
||||||
|
for model in self.models.values():
|
||||||
|
model.update(dt_hours, self.wear[model.observes])
|
||||||
|
if (model.maintenance_at is not None and not model.diagnosed
|
||||||
|
and model.value <= model.maintenance_at):
|
||||||
|
model.diagnosed = True
|
||||||
|
self._inject_diagnosis(model)
|
||||||
|
|
||||||
|
def _inject_diagnosis(self, model: DegradationModel) -> None:
|
||||||
|
"""FR-8.3.4: threshold crossed -> module diagnosis injected."""
|
||||||
|
diag = model.diagnosis or {}
|
||||||
|
module = int(diag.get("module", 0))
|
||||||
|
channel = int(diag.get("channel", 0))
|
||||||
|
value = int(diag.get("value", 0x0001))
|
||||||
|
registers = getattr(self.engine, "registers", None)
|
||||||
|
if registers is not None:
|
||||||
|
registers.set_module_diagnosis(module, channel, value)
|
||||||
|
|
||||||
|
# -- engine interface (FR-8.3.3) -------------------------------------------
|
||||||
|
|
||||||
|
def parameter(self, name: str) -> float:
|
||||||
|
"""Parameter value for plant physics (pump models etc.)."""
|
||||||
|
model = self.models.get(name)
|
||||||
|
if model is None:
|
||||||
|
raise KeyError(f"unknown parameter {name!r}")
|
||||||
|
return model.value
|
||||||
|
|
||||||
|
def maintenance_reset(self) -> None:
|
||||||
|
"""FR-8.3.5 action: restore parameters, clear diagnosis."""
|
||||||
|
for model in self.models.values():
|
||||||
|
model.reset()
|
||||||
|
model.diagnosed = False
|
||||||
|
registers = getattr(self.engine, "registers", None)
|
||||||
|
if registers is not None:
|
||||||
|
registers.set_module_diagnosis(0, 0, 0)
|
||||||
|
|
||||||
|
def snapshot(self) -> dict:
|
||||||
|
"""Wear + parameter values for report snapshots (FR-8.4.3)."""
|
||||||
|
return {
|
||||||
|
"parameters": {n: m.value for n, m in self.models.items()},
|
||||||
|
"wear": {a: w.as_dict() for a, w in self.wear.items()},
|
||||||
|
}
|
||||||
@@ -136,6 +136,50 @@ def validate_scenario(scenario: dict) -> list[str]:
|
|||||||
f"'module' is given"
|
f"'module' is given"
|
||||||
)
|
)
|
||||||
seq = scenario.get("sequence") or []
|
seq = scenario.get("sequence") or []
|
||||||
if not seq and not scenario.get("states"):
|
mode = str(scenario.get("mode", "sequence"))
|
||||||
|
if not seq and not scenario.get("states") and mode != "continuous":
|
||||||
problems.append("neither 'sequence' nor 'states' defined")
|
problems.append("neither 'sequence' nor 'states' defined")
|
||||||
|
# -- continuous mode (spec §8) -------------------------------------------
|
||||||
|
mode = str(scenario.get("mode", "sequence"))
|
||||||
|
if mode not in ("sequence", "continuous"):
|
||||||
|
problems.append(f"unknown mode {mode!r} (sequence | continuous)")
|
||||||
|
if mode == "continuous":
|
||||||
|
# sequence optional (FR-8.2.1); run-length drivers required
|
||||||
|
datasets = scenario.get("datasets") or {}
|
||||||
|
scheduled = [s for s in seq if isinstance(s, dict) and "at" in s]
|
||||||
|
until = scenario.get("until")
|
||||||
|
if not datasets and not scheduled and until is None and not seq:
|
||||||
|
problems.append(
|
||||||
|
"continuous scenario needs datasets, scheduled steps "
|
||||||
|
"(at:), or until: to bound the run (FR-8.2.1)"
|
||||||
|
)
|
||||||
|
if not isinstance(datasets, dict):
|
||||||
|
problems.append("'datasets' must be a mapping")
|
||||||
|
else:
|
||||||
|
for ds_name, spec in datasets.items():
|
||||||
|
if not isinstance(spec, dict) or "file" not in spec:
|
||||||
|
problems.append(f"datasets.{ds_name}: needs 'file'")
|
||||||
|
else:
|
||||||
|
for key in ("interpolation",):
|
||||||
|
if key in spec and spec[key] not in ("linear", "hold"):
|
||||||
|
problems.append(
|
||||||
|
f"datasets.{ds_name}: interpolation must be "
|
||||||
|
f"linear | hold (FR-8.2.5)")
|
||||||
|
# invariants / rate limits reference aliases (FR-8.4)
|
||||||
|
for inv in scenario.get("invariants") or []:
|
||||||
|
if not isinstance(inv, dict) or "alias" not in inv or "condition" not in inv:
|
||||||
|
problems.append("invariants need 'alias' and 'condition'")
|
||||||
|
for rl in scenario.get("rate_limits") or []:
|
||||||
|
if not isinstance(rl, dict) or "alias" not in rl or "max_per_hour" not in rl:
|
||||||
|
problems.append("rate_limits need 'alias' and 'max_per_hour'")
|
||||||
|
deg = scenario.get("degradation") or {}
|
||||||
|
if not isinstance(deg, dict):
|
||||||
|
problems.append("'degradation' must be a mapping")
|
||||||
|
else:
|
||||||
|
for pname, cfg in deg.items():
|
||||||
|
if not isinstance(cfg, dict) or "observes" not in cfg:
|
||||||
|
problems.append(f"degradation.{pname}: needs 'observes' (alias)")
|
||||||
|
elif cfg["observes"] not in {**inputs, **outputs}:
|
||||||
|
problems.append(
|
||||||
|
f"degradation.{pname}: unknown alias {cfg['observes']!r}")
|
||||||
return problems
|
return problems
|
||||||
@@ -155,6 +155,122 @@ class ScenarioEngine:
|
|||||||
}
|
}
|
||||||
self._macro_return_wait: float | None = None
|
self._macro_return_wait: float | None = None
|
||||||
self._plugin_actions = self._load_plugins()
|
self._plugin_actions = self._load_plugins()
|
||||||
|
# -- continuous mode (spec §8) ---------------------------------------
|
||||||
|
self.mode = str(scenario.get("mode", "sequence"))
|
||||||
|
if self.mode not in ("sequence", "continuous"):
|
||||||
|
raise ScenarioError(f"unknown mode {self.mode!r}")
|
||||||
|
self.env = None # PlantEnv, attached in continuous mode
|
||||||
|
self.datasets = [] # CsvDataset instances
|
||||||
|
self.degradation = None # DegradationManager
|
||||||
|
self.invariants = None # InvariantMonitor
|
||||||
|
self._until_ms: float | None = None
|
||||||
|
self._scheduled: list[tuple[float, dict]] = [] # (due_ms, step)
|
||||||
|
self._ownership: dict[str, str] = {} # alias -> driver name
|
||||||
|
if self.mode == "continuous":
|
||||||
|
self._setup_continuous()
|
||||||
|
|
||||||
|
# -- continuous-mode setup (spec §8.2) --------------------------------------
|
||||||
|
|
||||||
|
def _setup_continuous(self) -> None:
|
||||||
|
from .datasets import CsvDataset
|
||||||
|
from .degradation import DegradationManager
|
||||||
|
from .invariants import InvariantMonitor
|
||||||
|
from .plantenv import PlantEnv
|
||||||
|
|
||||||
|
# run-length control (FR-8.2.1): until, scheduled steps, datasets
|
||||||
|
until = self.scenario.get("until")
|
||||||
|
if until is not None:
|
||||||
|
self._until_ms = _parse_duration(until)
|
||||||
|
|
||||||
|
# datasets (optional) — one driver per alias (FR-8.2.2)
|
||||||
|
base = self.scenario.get("_base_path")
|
||||||
|
for ds_name, spec in (self.scenario.get("datasets") or {}).items():
|
||||||
|
ds = CsvDataset(self, ds_name, spec, base_path=base)
|
||||||
|
ds.open()
|
||||||
|
for alias in ds.aliases:
|
||||||
|
self._claim_alias(alias, f"dataset:{ds_name}")
|
||||||
|
self.datasets.append(ds)
|
||||||
|
|
||||||
|
# scheduled steps: `at: <duration>` entries (FR-8.4.6); the rest of
|
||||||
|
# the sequence runs as a normal step queue when present
|
||||||
|
for step in self.scenario.get("sequence", []) or []:
|
||||||
|
if isinstance(step, dict) and "at" in step:
|
||||||
|
due = _parse_duration(step["at"])
|
||||||
|
inner = {k: v for k, v in step.items() if k != "at"}
|
||||||
|
self._scheduled.append((due, inner))
|
||||||
|
self._scheduled.sort(key=lambda s: s[0])
|
||||||
|
|
||||||
|
self.degradation = DegradationManager(self, self.scenario.get("degradation"))
|
||||||
|
for model in self.degradation.models.values():
|
||||||
|
self._claim_alias(model.observes, f"degradation:{model.name}")
|
||||||
|
self.invariants = InvariantMonitor(self, self.scenario)
|
||||||
|
self.env = PlantEnv(engine=self)
|
||||||
|
|
||||||
|
# FR-8.2.1: a continuous run needs at least one run-length driver
|
||||||
|
if (not self.datasets and not self._scheduled
|
||||||
|
and self._until_ms is None
|
||||||
|
and not self.scenario.get("sequence")):
|
||||||
|
raise ScenarioError(
|
||||||
|
"continuous scenario needs datasets, scheduled steps, or "
|
||||||
|
"until: to bound the run (it would run forever)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _claim_alias(self, alias: str, driver: str) -> None:
|
||||||
|
"""FR-8.2.2: exactly one driver may own an alias."""
|
||||||
|
if alias in self._ownership and self._ownership[alias] != driver:
|
||||||
|
raise ScenarioError(
|
||||||
|
f"alias {alias!r} claimed by both "
|
||||||
|
f"{self._ownership[alias]!r} and {driver!r}"
|
||||||
|
)
|
||||||
|
self._ownership[alias] = driver
|
||||||
|
|
||||||
|
def parameter(self, name: str) -> float:
|
||||||
|
"""Plant parameter accessor (FR-8.3.3): e.g. pump_capacity."""
|
||||||
|
if self.degradation is None:
|
||||||
|
raise ScenarioError("no degradation configured; parameter() unavailable")
|
||||||
|
return self.degradation.parameter(name)
|
||||||
|
|
||||||
|
def maintenance_reset(self) -> None:
|
||||||
|
"""Scenario action (FR-8.3.5): restore parameters, clear diagnosis."""
|
||||||
|
if self.degradation is not None:
|
||||||
|
self.degradation.maintenance_reset()
|
||||||
|
|
||||||
|
def reset_episode(self) -> None:
|
||||||
|
"""PlantEnv.reset (FR-8.2.16): deterministic fresh episode."""
|
||||||
|
self.datastore.reset_images()
|
||||||
|
self.sim_time_ms = 0.0
|
||||||
|
self._result = ScenarioResult(name=self.name)
|
||||||
|
self._edges = {}
|
||||||
|
self._ramps = []
|
||||||
|
self._last_snapshot = self._snapshot_aliases()
|
||||||
|
self._finished = False
|
||||||
|
self._step_queue = deque(self.scenario.get("sequence", []) or [])
|
||||||
|
self._wait_until_ms = None
|
||||||
|
self._pending_assert = None
|
||||||
|
self._assert_deadline_ms = None
|
||||||
|
for ds in self.datasets:
|
||||||
|
ds.close()
|
||||||
|
ds._loop = 1
|
||||||
|
ds._finished = False
|
||||||
|
ds._row = ds._next = None
|
||||||
|
ds._row_time = 0.0
|
||||||
|
ds._line_no = 0
|
||||||
|
ds.open()
|
||||||
|
if self.degradation is not None:
|
||||||
|
for model in self.degradation.models.values():
|
||||||
|
model.reset()
|
||||||
|
model.diagnosed = False
|
||||||
|
self.degradation._last_truth = {}
|
||||||
|
if self.invariants is not None:
|
||||||
|
self.invariants.episodes = []
|
||||||
|
for inv in self.invariants.invariants:
|
||||||
|
inv.active = None
|
||||||
|
for rl in self.invariants.rate_limits:
|
||||||
|
rl.samples = []
|
||||||
|
rl.breached = False
|
||||||
|
rl.peak_rate = 0.0
|
||||||
|
self.invariants.snapshots = []
|
||||||
|
self.invariants._last_snapshot_ms = 0.0
|
||||||
|
|
||||||
# -- setup ---------------------------------------------------------------
|
# -- setup ---------------------------------------------------------------
|
||||||
|
|
||||||
@@ -328,6 +444,8 @@ class ScenarioEngine:
|
|||||||
return self._result
|
return self._result
|
||||||
dt = dt_ms if dt_ms is not None else float(self.TICK_MS)
|
dt = dt_ms if dt_ms is not None else float(self.TICK_MS)
|
||||||
self.sim_time_ms += dt
|
self.sim_time_ms += dt
|
||||||
|
if self.mode == "continuous":
|
||||||
|
return self._pump_continuous(dt)
|
||||||
# 1) detect alias edges (inputs set in the previous tick, outputs
|
# 1) detect alias edges (inputs set in the previous tick, outputs
|
||||||
# written by the master/plant)
|
# written by the master/plant)
|
||||||
self._detect_edges()
|
self._detect_edges()
|
||||||
@@ -377,6 +495,118 @@ class ScenarioEngine:
|
|||||||
if fn is not None:
|
if fn is not None:
|
||||||
fn(self, None)
|
fn(self, None)
|
||||||
|
|
||||||
|
# -- continuous tick pipeline (spec FR-8.2.12) -------------------------------
|
||||||
|
|
||||||
|
def _pump_continuous(self, dt: float) -> ScenarioResult | None:
|
||||||
|
"""One continuous-mode tick, fixed pipeline order (FR-8.2.12):
|
||||||
|
|
||||||
|
(1) datasets apply sampled/interpolated values,
|
||||||
|
(2) alias edges are detected,
|
||||||
|
(3) plant models run (plugins + env decisions flush),
|
||||||
|
(4) degradation updates,
|
||||||
|
(5) invariants and rate limits evaluate,
|
||||||
|
(6) scheduled steps due this tick execute,
|
||||||
|
(7) snapshots are taken if due.
|
||||||
|
Run end: until elapsed + all drivers exhausted (FR-8.2.1).
|
||||||
|
"""
|
||||||
|
# (1) datasets
|
||||||
|
for ds in self.datasets:
|
||||||
|
try:
|
||||||
|
ds.apply_at(self.sim_time_ms)
|
||||||
|
except DataError as exc:
|
||||||
|
self._record("dataset", Verdict.ERROR, str(exc))
|
||||||
|
self._finish(Verdict.ERROR)
|
||||||
|
return self._result
|
||||||
|
# (2) edges
|
||||||
|
self._detect_edges()
|
||||||
|
# (3) plant logic: plugins first, then queued env decisions land
|
||||||
|
# (the scenario layer drives env via observe/apply; queued
|
||||||
|
# values are already applied by the decision source)
|
||||||
|
self._run_tick_plugins()
|
||||||
|
self._advance_ramps(dt)
|
||||||
|
# (4) degradation
|
||||||
|
if self.degradation is not None:
|
||||||
|
self.degradation.tick(dt)
|
||||||
|
# (5) invariants + rate limits
|
||||||
|
if self.invariants is not None:
|
||||||
|
self.invariants.tick()
|
||||||
|
failure = self.invariants.rate_failure
|
||||||
|
if failure:
|
||||||
|
self._record("rate_limit", Verdict.FAIL, failure)
|
||||||
|
self._finish(Verdict.FAIL)
|
||||||
|
return self._result
|
||||||
|
# (6) scheduled steps due now (FR-8.4.6)
|
||||||
|
while self._scheduled and self._scheduled[0][0] <= self.sim_time_ms:
|
||||||
|
_, step = self._scheduled.pop(0)
|
||||||
|
for key, value in step.items():
|
||||||
|
self._execute_step_key(key, value)
|
||||||
|
# non-scheduled step queue (mixed runs): drain non-blocking steps
|
||||||
|
self._drain_sequence_in_continuous()
|
||||||
|
# (7) snapshots handled inside invariants.tick()
|
||||||
|
|
||||||
|
# -- run-end check (FR-8.2.1) -----------------------------------------
|
||||||
|
# The run ends when ALL of the following hold:
|
||||||
|
# - every dataset finished (or looped to its repeat limit)
|
||||||
|
# - no scheduled steps remain and the step queue is empty
|
||||||
|
# - no blocking step is pending (wait/assert)
|
||||||
|
# - the `until` duration elapsed (until ALWAYS bounds the run)
|
||||||
|
drivers_left = (any(not ds.finished for ds in self.datasets)
|
||||||
|
or bool(self._scheduled)
|
||||||
|
or bool(self._step_queue)
|
||||||
|
or self._wait_until_ms is not None
|
||||||
|
or self._pending_assert is not None)
|
||||||
|
until_elapsed = (self._until_ms is not None
|
||||||
|
and self.sim_time_ms >= self._until_ms)
|
||||||
|
if until_elapsed or (not drivers_left and self._until_ms is None):
|
||||||
|
self._end_continuous_run()
|
||||||
|
return None if not self._finished else self._result
|
||||||
|
|
||||||
|
def _end_continuous_run(self) -> None:
|
||||||
|
"""Continuous run-end verdict (FR-8.4.4): invariants/rate-limits fail."""
|
||||||
|
verdict = Verdict.PASS
|
||||||
|
if self.invariants is not None:
|
||||||
|
if self.invariants.episodes:
|
||||||
|
verdict = Verdict.FAIL
|
||||||
|
elif self.invariants.rate_failure:
|
||||||
|
verdict = Verdict.FAIL
|
||||||
|
for step in self._result.steps:
|
||||||
|
if step.verdict is Verdict.FAIL:
|
||||||
|
verdict = Verdict.FAIL
|
||||||
|
break
|
||||||
|
self._finish(verdict)
|
||||||
|
|
||||||
|
def _drain_sequence_in_continuous(self) -> None:
|
||||||
|
"""Run queued sequence steps that don't block (mixed continuous runs)."""
|
||||||
|
while self._step_queue:
|
||||||
|
step = self._step_queue[0]
|
||||||
|
if isinstance(step, dict) and "at" in step:
|
||||||
|
self._step_queue.popleft() # scheduled steps handled above
|
||||||
|
continue
|
||||||
|
blocked = self._run_next_step()
|
||||||
|
if blocked:
|
||||||
|
break
|
||||||
|
|
||||||
|
def _execute_step_key(self, key: str, value) -> None:
|
||||||
|
"""One key of a scheduled step (subset of sequence semantics)."""
|
||||||
|
if key == "set":
|
||||||
|
for alias_name, v in value.items():
|
||||||
|
self.set_alias(alias_name, v)
|
||||||
|
elif key == "maintenance_reset":
|
||||||
|
self.maintenance_reset()
|
||||||
|
elif key == "report":
|
||||||
|
self._record("scheduled", Verdict.PASS if str(value).upper() == "PASS"
|
||||||
|
else Verdict.FAIL, str(value))
|
||||||
|
elif key in self._plugin_actions:
|
||||||
|
self._plugin_actions[key](self, value)
|
||||||
|
elif key == "assert":
|
||||||
|
# immediate one-tick check; failures recorded as episodes
|
||||||
|
label = str(value.get("label", "assert")) if isinstance(value, dict) else "assert"
|
||||||
|
conditions = value.get("conditions", {}) if isinstance(value, dict) else value
|
||||||
|
verdict = Verdict.PASS if self._matcher.evaluate(conditions) else Verdict.FAIL
|
||||||
|
self._record(label, verdict, f"at {self.sim_time_ms:.0f} ms")
|
||||||
|
else:
|
||||||
|
raise ScenarioError(f"unknown scheduled step key: {key!r}")
|
||||||
|
|
||||||
def _run_next_step(self) -> bool:
|
def _run_next_step(self) -> bool:
|
||||||
"""Execute one queued step.
|
"""Execute one queued step.
|
||||||
|
|
||||||
@@ -531,6 +761,15 @@ class ScenarioError(ValueError):
|
|||||||
"""Invalid scenario definition or runtime misuse."""
|
"""Invalid scenario definition or runtime misuse."""
|
||||||
|
|
||||||
|
|
||||||
|
class DataError(ScenarioError):
|
||||||
|
"""Malformed dataset content (FR-8.2.11); carries file and line."""
|
||||||
|
|
||||||
|
def __init__(self, path: str, line_no: int, message: str) -> None:
|
||||||
|
self.path = path
|
||||||
|
self.line_no = line_no
|
||||||
|
super().__init__(f"{path}:{line_no}: {message}")
|
||||||
|
|
||||||
|
|
||||||
def _parse_duration(value: str | int | float) -> float:
|
def _parse_duration(value: str | int | float) -> float:
|
||||||
"""Parse '250ms', '30s', '2m', or a raw number (milliseconds)."""
|
"""Parse '250ms', '30s', '2m', or a raw number (milliseconds)."""
|
||||||
if isinstance(value, (int, float)):
|
if isinstance(value, (int, float)):
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""Invariants, rate limits, and snapshots for continuous runs (spec §8.4).
|
||||||
|
|
||||||
|
An invariant is a condition that must hold EVERY tick of the run
|
||||||
|
(e.g. tank level within bounds). A breach is recorded as one bounded
|
||||||
|
*episode* (start, end, peak violation) instead of per-tick spam
|
||||||
|
(FR-8.4.1); any episode fails the run verdict. Rate limits bound how
|
||||||
|
fast an alias may change over a sliding one-hour window (FR-8.4.2).
|
||||||
|
Snapshots periodically dump alias values, wear counters, and
|
||||||
|
degradation parameters (FR-8.4.3).
|
||||||
|
|
||||||
|
YAML::
|
||||||
|
|
||||||
|
invariants:
|
||||||
|
- { alias: tank_level, condition: '>= 0', label: level_bottom }
|
||||||
|
- { alias: tank_level, condition: '<= 8500', label: level_brim }
|
||||||
|
rate_limits:
|
||||||
|
- { alias: pump_speed, max_per_hour: 3000 }
|
||||||
|
snapshot_every: 1h
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
def _compare(value: float, condition: str) -> bool:
|
||||||
|
"""Evaluate 'value <condition>' e.g. 3.2 >= 0. Returns True when OK."""
|
||||||
|
text = condition.strip()
|
||||||
|
for op in ("<=", ">=", "!=", "==", "<", ">"):
|
||||||
|
if text.startswith(op):
|
||||||
|
ref = float(text[len(op):])
|
||||||
|
return {
|
||||||
|
"<=": value <= ref, ">=": value >= ref,
|
||||||
|
"!=": value != ref, "==": value == ref,
|
||||||
|
"<": value < ref, ">": value > ref,
|
||||||
|
}[op]
|
||||||
|
raise ValueError(f"cannot parse invariant condition {condition!r}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Episode:
|
||||||
|
"""One aggregated invariant breach (FR-8.4.1)."""
|
||||||
|
|
||||||
|
label: str
|
||||||
|
started_ms: float
|
||||||
|
ended_ms: float
|
||||||
|
peak_violation: float = 0.0
|
||||||
|
samples: int = 0
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"label": self.label,
|
||||||
|
"started_ms": self.started_ms,
|
||||||
|
"ended_ms": self.ended_ms,
|
||||||
|
"peak_violation": self.peak_violation,
|
||||||
|
"samples": self.samples,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Invariant:
|
||||||
|
label: str
|
||||||
|
alias: str
|
||||||
|
condition: str
|
||||||
|
# episode runtime state
|
||||||
|
active: Episode | None = None
|
||||||
|
|
||||||
|
def evaluate(self, value: float) -> bool:
|
||||||
|
return _compare(float(value), self.condition)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RateLimit:
|
||||||
|
"""Sliding-window rate bound (FR-8.4.2).
|
||||||
|
|
||||||
|
The window is one hour of SIM-TIME (not wall-clock): samples are
|
||||||
|
(sim_time, value) pairs; older ones age out of the window.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias: str
|
||||||
|
max_per_hour: float
|
||||||
|
window_ms: float = 3_600_000.0
|
||||||
|
samples: list[tuple[float, float]] = field(default_factory=list)
|
||||||
|
breached: bool = False
|
||||||
|
peak_rate: float = 0.0
|
||||||
|
|
||||||
|
def record(self, sim_ms: float, value: float) -> None:
|
||||||
|
self.samples.append((sim_ms, float(value)))
|
||||||
|
horizon = sim_ms - self.window_ms
|
||||||
|
if self.samples and self.samples[0][0] < horizon:
|
||||||
|
self.samples = [s for s in self.samples if s[0] >= horizon]
|
||||||
|
if len(self.samples) >= 2:
|
||||||
|
t0, v0 = self.samples[0]
|
||||||
|
t1, v1 = self.samples[-1]
|
||||||
|
hours = (t1 - t0) / 3_600_000.0
|
||||||
|
if hours > 0:
|
||||||
|
rate = abs(v1 - v0) / hours
|
||||||
|
self.peak_rate = max(self.peak_rate, rate)
|
||||||
|
if rate > self.max_per_hour:
|
||||||
|
self.breached = True
|
||||||
|
|
||||||
|
|
||||||
|
class InvariantMonitor:
|
||||||
|
"""Per-tick invariant + rate-limit evaluation (FR-8.2.12 step 5)."""
|
||||||
|
|
||||||
|
def __init__(self, engine, scenario: dict) -> None:
|
||||||
|
self.engine = engine
|
||||||
|
self.invariants: list[Invariant] = []
|
||||||
|
self.rate_limits: list[RateLimit] = []
|
||||||
|
for spec in scenario.get("invariants", []) or []:
|
||||||
|
alias = str(spec["alias"])
|
||||||
|
if alias not in engine._aliases:
|
||||||
|
raise ValueError(f"invariant: unknown alias {alias!r}")
|
||||||
|
self.invariants.append(Invariant(
|
||||||
|
label=str(spec.get("label", f"{alias} {spec.get('condition')}")),
|
||||||
|
alias=alias,
|
||||||
|
condition=str(spec["condition"]),
|
||||||
|
))
|
||||||
|
for spec in scenario.get("rate_limits", []) or []:
|
||||||
|
alias = str(spec["alias"])
|
||||||
|
if alias not in engine._aliases:
|
||||||
|
raise ValueError(f"rate_limit: unknown alias {alias!r}")
|
||||||
|
self.rate_limits.append(RateLimit(
|
||||||
|
alias=alias,
|
||||||
|
max_per_hour=float(spec["max_per_hour"]),
|
||||||
|
))
|
||||||
|
self.episodes: list[Episode] = []
|
||||||
|
self.snapshot_every_ms = _parse_ms(scenario.get("snapshot_every", 0))
|
||||||
|
self.snapshots: list[dict] = []
|
||||||
|
self._last_snapshot_ms = 0.0
|
||||||
|
|
||||||
|
# -- per-tick ------------------------------------------------------------
|
||||||
|
|
||||||
|
def tick(self) -> None:
|
||||||
|
"""Evaluate invariants + rate limits; take snapshots if due.
|
||||||
|
|
||||||
|
Returns a failure detail string when a rate limit is breached
|
||||||
|
(the engine turns that into a FAIL); invariant episodes are
|
||||||
|
recorded and reported (they fail the run at the end).
|
||||||
|
"""
|
||||||
|
sim = self.engine.sim_time_ms
|
||||||
|
for inv in self.invariants:
|
||||||
|
value = self.engine.alias_value(inv.alias)
|
||||||
|
ok = inv.evaluate(value if not isinstance(value, bool) else int(value))
|
||||||
|
if not ok:
|
||||||
|
if inv.active is None:
|
||||||
|
inv.active = Episode(inv.label, sim, sim)
|
||||||
|
self.episodes.append(inv.active)
|
||||||
|
inv.active.ended_ms = sim
|
||||||
|
inv.active.samples += 1
|
||||||
|
# peak violation: magnitude from the nearest bound
|
||||||
|
inv.active.peak_violation = max(
|
||||||
|
inv.active.peak_violation, _violation(value, inv.condition))
|
||||||
|
elif inv.active is not None:
|
||||||
|
inv.active = None # episode closed; bounded record kept
|
||||||
|
for rl in self.rate_limits:
|
||||||
|
rl.record(sim, float(self.engine.alias_value(rl.alias)))
|
||||||
|
if self.snapshot_every_ms and sim - self._last_snapshot_ms >= self.snapshot_every_ms:
|
||||||
|
self._take_snapshot()
|
||||||
|
|
||||||
|
def _take_snapshot(self) -> None:
|
||||||
|
self._last_snapshot_ms = self.engine.sim_time_ms
|
||||||
|
self.snapshots.append({
|
||||||
|
"sim_time_ms": self.engine.sim_time_ms,
|
||||||
|
"wall_time": time.time(),
|
||||||
|
"aliases": self.engine._snapshot_aliases(),
|
||||||
|
"degradation": (self.engine.degradation.snapshot()
|
||||||
|
if getattr(self.engine, "degradation", None) else None),
|
||||||
|
})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rate_failure(self) -> str | None:
|
||||||
|
for rl in self.rate_limits:
|
||||||
|
if rl.breached:
|
||||||
|
return (f"rate limit: {rl.alias} peak {rl.peak_rate:.1f}/h "
|
||||||
|
f"exceeds {rl.max_per_hour}/h")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"episodes": [e.as_dict() for e in self.episodes],
|
||||||
|
"rate_limits": [
|
||||||
|
{"alias": r.alias, "max_per_hour": r.max_per_hour,
|
||||||
|
"peak_rate": r.peak_rate, "breached": r.breached}
|
||||||
|
for r in self.rate_limits
|
||||||
|
],
|
||||||
|
"snapshots": self.snapshots,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _violation(value: float, condition: str) -> float:
|
||||||
|
"""Distance beyond the violated bound (for the episode peak)."""
|
||||||
|
text = condition.strip()
|
||||||
|
for op, sign in (("<=", 1), (">=", -1)):
|
||||||
|
if text.startswith(op):
|
||||||
|
ref = float(text[len(op):])
|
||||||
|
return abs(value - ref)
|
||||||
|
return abs(float(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ms(value: str | int | float) -> float:
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return float(value)
|
||||||
|
text = str(value).strip().lower()
|
||||||
|
multipliers = {"ms": 1.0, "s": 1000.0, "m": 60000.0, "h": 3600000.0}
|
||||||
|
for suffix in ("ms", "s", "m", "h"):
|
||||||
|
if text.endswith(suffix):
|
||||||
|
return float(text[: -len(suffix)]) * multipliers[suffix]
|
||||||
|
return float(text)
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
"""PlantEnv: gymnasium-style plant environment (spec §8, FR-8.2.14-17).
|
||||||
|
|
||||||
|
Standalone-first: requires only a virtual node + datastore — no
|
||||||
|
scenario. The environment is the interface for continuous plant
|
||||||
|
simulation; per deterministic tick (runner-owned clock, never
|
||||||
|
wall-clock arithmetic):
|
||||||
|
|
||||||
|
state = env.observe() # ALL modules: DI/DO/AI/AO channels,
|
||||||
|
# engineering units, names, parameters
|
||||||
|
values = env.model(state, dt) # plant physics (subclass hook)
|
||||||
|
env.apply(values) # new FIELD state -> DI/AI inputs;
|
||||||
|
# DO/AO via env.force() (master-side)
|
||||||
|
|
||||||
|
`model()` is the override point for plant physics: read what the PLC
|
||||||
|
commanded (DO/AO), compute what the field now shows (DI/AI), return
|
||||||
|
it. `is_done(state)` gives an optional end criterion; the default is
|
||||||
|
False — environments run until manually stopped (SIGINT/SIGTERM).
|
||||||
|
|
||||||
|
Attachments: degradation (§8.3), invariants (§8.4) and aliases
|
||||||
|
(scenario-continuous mode) hook onto the same tick when the env is
|
||||||
|
constructed on an engine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from ..modules import VirtualNode
|
||||||
|
from .engine import ScenarioError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .engine import ScenarioEngine
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChannelState:
|
||||||
|
"""One channel of the observed plant state (engineering units)."""
|
||||||
|
|
||||||
|
kind: str # di | do | ai | ao
|
||||||
|
module: int # per-type module number (FR-4.3.11 v1.5)
|
||||||
|
channel: int # 0-based channel on the module
|
||||||
|
io_code: str # e.g. "DI0.0"
|
||||||
|
name: str | None # configured application name, if any
|
||||||
|
value: object # bool for di/do, int/float for ai/ao
|
||||||
|
range: tuple[int, int] | None = None # (min, max) raw counts — analog only
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlantState:
|
||||||
|
"""Full observation: every channel of every module + attachments."""
|
||||||
|
|
||||||
|
sim_time_ms: float
|
||||||
|
channels: list[ChannelState] = field(default_factory=list)
|
||||||
|
aliases: dict[str, object] = field(default_factory=dict)
|
||||||
|
parameters: dict[str, float] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def by_code(self, io_code: str) -> object:
|
||||||
|
"""Value of one channel by I/O code, or KeyError."""
|
||||||
|
for ch in self.channels:
|
||||||
|
if ch.io_code == io_code:
|
||||||
|
return ch.value
|
||||||
|
raise KeyError(io_code)
|
||||||
|
|
||||||
|
def by_kind(self, kind: str) -> dict[str, object]:
|
||||||
|
"""{io_code: value} for all channels of one kind."""
|
||||||
|
return {c.io_code: c.value for c in self.channels if c.kind == kind}
|
||||||
|
|
||||||
|
def channel(self, key: str) -> ChannelState:
|
||||||
|
"""Full ChannelState (name, range, ...) by I/O code or name.
|
||||||
|
|
||||||
|
Matches the I/O code first ("AI0.0"), then the configured
|
||||||
|
application name ("speed_setpoint", FR-10.2.11). KeyError
|
||||||
|
when neither matches — including a `None` name (unconfigured
|
||||||
|
channels match by code only).
|
||||||
|
"""
|
||||||
|
for ch in self.channels:
|
||||||
|
if ch.io_code == key:
|
||||||
|
return ch
|
||||||
|
for ch in self.channels:
|
||||||
|
if ch.name == key:
|
||||||
|
return ch
|
||||||
|
raise KeyError(key)
|
||||||
|
|
||||||
|
def channel_range(self, key: str) -> tuple[int, int]:
|
||||||
|
"""Display range (min, max) of an analog channel, by I/O code
|
||||||
|
or name (FR-10.2.14).
|
||||||
|
|
||||||
|
Always defined for AI/AO — the range store is dense — so
|
||||||
|
env models can unpack it without None-handling; digital
|
||||||
|
channels carry no range and raise KeyError.
|
||||||
|
"""
|
||||||
|
rng = self.channel(key).range
|
||||||
|
if rng is None:
|
||||||
|
raise KeyError(key)
|
||||||
|
return rng
|
||||||
|
|
||||||
|
|
||||||
|
class PlantEnv:
|
||||||
|
"""gym-style plant environment (FR-8.2.14-17).
|
||||||
|
|
||||||
|
Constructed on a VirtualNode (standalone) or on an engine (the
|
||||||
|
scenario-continuous layer). The runner owns the tick clock; `tick()`
|
||||||
|
advances sim-time deterministically by TICK_MS per call so both
|
||||||
|
paced (serve) and unpaced (sim) runners behave identically.
|
||||||
|
"""
|
||||||
|
|
||||||
|
TICK_MS = 10.0
|
||||||
|
|
||||||
|
def __init__(self, node: VirtualNode | None = None, *,
|
||||||
|
engine: ScenarioEngine | None = None) -> None:
|
||||||
|
if node is None:
|
||||||
|
if engine is None:
|
||||||
|
raise ScenarioError("PlantEnv needs a node or an engine")
|
||||||
|
self.node: VirtualNode = engine.node
|
||||||
|
else:
|
||||||
|
self.node = node
|
||||||
|
self.engine = engine
|
||||||
|
self.datastore = self.node.datastore
|
||||||
|
self.sim_time_ms = 0.0
|
||||||
|
# attachments (optional; the scenario layer wires these)
|
||||||
|
self.degradation = getattr(engine, "degradation", None)
|
||||||
|
self.invariants = getattr(engine, "invariants", None)
|
||||||
|
self._aliases = getattr(engine, "_aliases", None) if engine else None
|
||||||
|
self._alias_value: Callable[[str], object] | None = (
|
||||||
|
engine.alias_value if engine is not None else None)
|
||||||
|
|
||||||
|
# -- observation (FR-8.2.14) -----------------------------------------------
|
||||||
|
|
||||||
|
def observe(self) -> PlantState:
|
||||||
|
"""Snapshot ALL modules (DI/DO/AI/AO) in engineering units."""
|
||||||
|
node = self.node
|
||||||
|
ds = self.datastore
|
||||||
|
channels: list[ChannelState] = []
|
||||||
|
for kind, pool in (("di", node.di), ("do", node.do),
|
||||||
|
("ai", node.ai), ("ao", node.ao)):
|
||||||
|
for i, loc in enumerate(pool, start=1):
|
||||||
|
if kind == "di":
|
||||||
|
value = ds.digital_input_channel(i)
|
||||||
|
elif kind == "do":
|
||||||
|
value = ds.digital_output_channel(i)
|
||||||
|
elif kind == "ai":
|
||||||
|
value = ds.get_input_word(loc.word)
|
||||||
|
else:
|
||||||
|
value = ds.get_output_word(loc.word)
|
||||||
|
code = node.io_code_at_slot(
|
||||||
|
kind, loc.module_index, loc.channel - 1)
|
||||||
|
channels.append(ChannelState(
|
||||||
|
kind=kind,
|
||||||
|
module=node.kind_index(loc.module_index)[kind],
|
||||||
|
channel=loc.channel - 1,
|
||||||
|
io_code=code,
|
||||||
|
name=node.channel_names.get(code),
|
||||||
|
value=value,
|
||||||
|
range=node.channel_ranges[code]
|
||||||
|
if kind in ("ai", "ao") else None,
|
||||||
|
))
|
||||||
|
aliases = ({name: self._alias_value(name)
|
||||||
|
for name in (self._aliases or {})}
|
||||||
|
if self._alias_value else {})
|
||||||
|
parameters: dict[str, float] = {}
|
||||||
|
if self.degradation is not None:
|
||||||
|
parameters = {n: m.value
|
||||||
|
for n, m in self.degradation.models.items()}
|
||||||
|
return PlantState(
|
||||||
|
sim_time_ms=self.sim_time_ms,
|
||||||
|
channels=channels,
|
||||||
|
aliases=aliases,
|
||||||
|
parameters=parameters,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- plant model hook (FR-8.2.16) -------------------------------------------
|
||||||
|
|
||||||
|
def model(self, state: PlantState, dt_s: float) -> dict[str, object]:
|
||||||
|
"""Plant physics: react to PLC commands, compute the new field.
|
||||||
|
|
||||||
|
Override in a subclass (FR-8.2.17). Receives the full observed
|
||||||
|
state and the tick delta in seconds; returns {I/O code or alias:
|
||||||
|
new value} for the channels it drives — DI/AI normally (the
|
||||||
|
simulated sensors); DO/AO entries are treated as forcing and go
|
||||||
|
through the master-side write paths. Returning {} (the default)
|
||||||
|
makes this a pure observation environment.
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def is_done(self, state: PlantState) -> bool:
|
||||||
|
"""Optional end criterion; default runs until manually stopped."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
# -- actions (FR-8.2.15) ------------------------------------------------------
|
||||||
|
|
||||||
|
def apply(self, values: dict[str, object], *, force: bool = False) -> None:
|
||||||
|
"""Write new channel values into the process images.
|
||||||
|
|
||||||
|
DI/AI keys (the field) write directly — this is the primary
|
||||||
|
direction. DO/AO keys (PLC outputs) require force=True and go
|
||||||
|
through the datastore's internal Modbus dispatch so mirrors,
|
||||||
|
statistics, and watchdog stay consistent (FR-9.2.6) — for
|
||||||
|
headless runs and fault setup only.
|
||||||
|
Keys: I/O code ("AI0.0") or alias name. Unknown keys raise
|
||||||
|
immediately (FR-8.2.16).
|
||||||
|
"""
|
||||||
|
if not isinstance(values, dict):
|
||||||
|
raise ScenarioError("apply() values must be a mapping")
|
||||||
|
for key, value in values.items():
|
||||||
|
self._write_one(key, value, force=force)
|
||||||
|
|
||||||
|
def force(self, values: dict[str, object]) -> None:
|
||||||
|
"""Master-side forcing of DO/AO (FR-8.2.15 explicit form)."""
|
||||||
|
self.apply(values, force=True)
|
||||||
|
|
||||||
|
def _resolve(self, key: str):
|
||||||
|
"""-> (kind, ChannelLocation, alias_or_None) for a code or alias."""
|
||||||
|
# alias name first (scenario layer)
|
||||||
|
if self._aliases and key in self._aliases and self.engine is not None:
|
||||||
|
alias = self._aliases[key]
|
||||||
|
return alias.kind, self.engine._alias_location(alias), alias
|
||||||
|
# any channel by I/O code (per-type codes, FR-4.3.11 v1.5)
|
||||||
|
code_up = str(key).strip().upper()
|
||||||
|
node = self.node
|
||||||
|
for kind, pool in (("di", node.di), ("do", node.do),
|
||||||
|
("ai", node.ai), ("ao", node.ao)):
|
||||||
|
for loc in pool:
|
||||||
|
if node.io_code_at_slot(
|
||||||
|
kind, loc.module_index, loc.channel - 1) == code_up:
|
||||||
|
return kind, loc, None
|
||||||
|
raise ScenarioError(f"apply(): unknown channel {key!r}")
|
||||||
|
|
||||||
|
def _write_one(self, key: str, value: object, *, force: bool) -> None:
|
||||||
|
kind, loc, alias = self._resolve(key)
|
||||||
|
ds = self.datastore
|
||||||
|
if kind in ("do", "ao") and not force:
|
||||||
|
raise ScenarioError(
|
||||||
|
f"apply(): {key!r} is an output ({kind.upper()}); use "
|
||||||
|
"force=True / env.force() for master-side forcing")
|
||||||
|
scale = getattr(alias, "scale", 1.0) or 1.0
|
||||||
|
offset = getattr(alias, "offset", 0.0) or 0.0
|
||||||
|
if kind == "di":
|
||||||
|
ds.set_digital_input_channel(loc.bit + 1, bool(value))
|
||||||
|
elif kind == "ai":
|
||||||
|
raw = int(float(value) * scale + offset) & 0xFFFF # type: ignore[arg-type]
|
||||||
|
ds.set_input_word(loc.word, raw)
|
||||||
|
elif kind == "do":
|
||||||
|
result = ds.write_bits(loc.bit, [bool(value)])
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise ScenarioError(f"force() write to {key!r}: {result}")
|
||||||
|
else: # ao
|
||||||
|
raw = int(float(value) * scale + offset) & 0xFFFF # type: ignore[arg-type]
|
||||||
|
result = ds.write_words(loc.word, [raw])
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise ScenarioError(f"force() write to {key!r}: {result}")
|
||||||
|
|
||||||
|
# -- one full tick (FR-8.2.14 cycle) ---------------------------------------------
|
||||||
|
|
||||||
|
def tick(self, dt_ms: float | None = None) -> PlantState:
|
||||||
|
"""One environment tick: model() -> apply(field) -> advance.
|
||||||
|
|
||||||
|
Deterministic: sim-time advances by TICK_MS (or dt_ms) per call
|
||||||
|
— never from wall-clock. Attachments advance with the tick:
|
||||||
|
degradation (§8.3) then invariants (§8.4), FR-8.2.12 order.
|
||||||
|
"""
|
||||||
|
dt = dt_ms if dt_ms is not None else self.TICK_MS
|
||||||
|
state = self.observe()
|
||||||
|
if self.is_done(state):
|
||||||
|
return state
|
||||||
|
values = self.model(state, dt / 1000.0)
|
||||||
|
if values:
|
||||||
|
self.apply(values)
|
||||||
|
self.sim_time_ms += dt
|
||||||
|
if self.degradation is not None:
|
||||||
|
self.degradation.tick(dt)
|
||||||
|
if self.invariants is not None:
|
||||||
|
self.invariants.tick()
|
||||||
|
return self.observe()
|
||||||
|
|
||||||
|
# -- episode control (FR-8.2.16) ------------------------------------------------
|
||||||
|
|
||||||
|
def reset(self) -> PlantState:
|
||||||
|
"""Fresh episode: zero images, reset wear + parameters + clock
|
||||||
|
(deterministic — identical runs replay identically, NFR-8.7.3)."""
|
||||||
|
self.datastore.reset_images()
|
||||||
|
self.sim_time_ms = 0.0
|
||||||
|
if self.degradation is not None:
|
||||||
|
for model in self.degradation.models.values():
|
||||||
|
model.reset()
|
||||||
|
model.diagnosed = False
|
||||||
|
self.degradation._last_truth = {}
|
||||||
|
if self.invariants is not None:
|
||||||
|
self.invariants.episodes = []
|
||||||
|
for inv in self.invariants.invariants:
|
||||||
|
inv.active = None
|
||||||
|
for rl in self.invariants.rate_limits:
|
||||||
|
rl.samples = []
|
||||||
|
rl.breached = False
|
||||||
|
rl.peak_rate = 0.0
|
||||||
|
self.invariants.snapshots = []
|
||||||
|
self.invariants._last_snapshot_ms = 0.0
|
||||||
|
return self.observe()
|
||||||
|
|
||||||
|
# -- clock (FR-8.2.16) ---------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tick_ms(self) -> float:
|
||||||
|
return self.TICK_MS
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parameters(self) -> dict[str, float]:
|
||||||
|
if self.degradation is None:
|
||||||
|
return {}
|
||||||
|
return {n: m.value for n, m in self.degradation.models.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def load_env(ref: str, node: VirtualNode | None = None, *,
|
||||||
|
engine=None, reload: bool = False) -> PlantEnv:
|
||||||
|
"""Load an environment from a module reference (FR-8.2.17).
|
||||||
|
|
||||||
|
Accepts ``pkg.mod.ClassName``, ``pkg.mod.factory_fn`` (returns a
|
||||||
|
PlantEnv), or a bare ``pkg.mod`` whose module defines either a
|
||||||
|
class named ``Env`` or a function ``make_env(...)``.
|
||||||
|
|
||||||
|
``reload=True`` (FR-10.2.17) invalidates the cached module first:
|
||||||
|
Python keeps modules in ``sys.modules``, so re-importing an
|
||||||
|
edited file would silently reuse the old class. Used by the web
|
||||||
|
UI's start/restart; the CLI runners start a fresh process anyway
|
||||||
|
and pass the default.
|
||||||
|
"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# environments resolve from the WORKING DIRECTORY by contract
|
||||||
|
# (``envs/`` is not part of the installed package). A console
|
||||||
|
# script puts its Scripts dir — not the cwd — on sys.path, so
|
||||||
|
# make the cwd importable before trying (idempotent).
|
||||||
|
cwd = os.getcwd()
|
||||||
|
if cwd not in sys.path:
|
||||||
|
sys.path.insert(0, cwd)
|
||||||
|
|
||||||
|
parts = ref.split(":")
|
||||||
|
attr_path = None
|
||||||
|
mod = None
|
||||||
|
leaf = None
|
||||||
|
if len(parts) == 2:
|
||||||
|
# explicit module:attr form — ref is the WHOLE module path
|
||||||
|
attr_path = parts[1] or None
|
||||||
|
mod_name = parts[0]
|
||||||
|
else:
|
||||||
|
# no colon: try the whole ref as a module first, then as
|
||||||
|
# pkg.mod containing an attr (e.g. ...:Class omitted)
|
||||||
|
try:
|
||||||
|
mod = importlib.import_module(ref)
|
||||||
|
mod_name = ref
|
||||||
|
except ImportError:
|
||||||
|
mod_name, _, leaf = ref.rpartition(".")
|
||||||
|
if not mod_name or ("." not in mod_name and attr_path is None
|
||||||
|
and leaf is None):
|
||||||
|
raise ScenarioError(f"--env {ref!r}: reference must include a module path")
|
||||||
|
|
||||||
|
if reload and mod_name in sys.modules:
|
||||||
|
# hot reload (FR-10.2.17): drop the cached module so the
|
||||||
|
# import below reads the file from disk again
|
||||||
|
sys.modules.pop(mod_name, None)
|
||||||
|
mod = None
|
||||||
|
|
||||||
|
if mod is None:
|
||||||
|
try:
|
||||||
|
mod = importlib.import_module(mod_name)
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ScenarioError(
|
||||||
|
f"--env {ref!r}: cannot import {mod_name!r}: {exc}")
|
||||||
|
|
||||||
|
obj = None
|
||||||
|
if attr_path is not None:
|
||||||
|
# explicit module:attr — must exist; never fall back silently
|
||||||
|
if not hasattr(mod, attr_path):
|
||||||
|
raise ScenarioError(
|
||||||
|
f"--env {ref!r}: no {attr_path!r} in {mod_name!r}")
|
||||||
|
obj = getattr(mod, attr_path)
|
||||||
|
elif leaf and hasattr(mod, leaf):
|
||||||
|
obj = getattr(mod, leaf)
|
||||||
|
elif hasattr(mod, "Env"):
|
||||||
|
obj = getattr(mod, "Env")
|
||||||
|
elif hasattr(mod, "make_env"):
|
||||||
|
obj = getattr(mod, "make_env")
|
||||||
|
else:
|
||||||
|
raise ScenarioError(
|
||||||
|
f"--env {ref!r}: no environment found in {mod_name!r} "
|
||||||
|
"(expected a class, Env, or make_env())")
|
||||||
|
|
||||||
|
env: PlantEnv
|
||||||
|
if isinstance(obj, type) and issubclass(obj, PlantEnv):
|
||||||
|
env = obj(node=node, engine=engine)
|
||||||
|
elif callable(obj):
|
||||||
|
made = obj(node=node, engine=engine) if node is not None \
|
||||||
|
else obj(engine=engine)
|
||||||
|
if not isinstance(made, PlantEnv):
|
||||||
|
raise ScenarioError(f"--env {ref!r}: did not return a PlantEnv")
|
||||||
|
env = made
|
||||||
|
else:
|
||||||
|
raise ScenarioError(
|
||||||
|
f"--env {ref!r}: not a PlantEnv subclass or factory")
|
||||||
|
return env
|
||||||
@@ -22,7 +22,8 @@ def result_summary(result: ScenarioResult) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def write_jsonl(result: ScenarioResult, path: str | Path) -> None:
|
def write_jsonl(result: ScenarioResult, path: str | Path,
|
||||||
|
extra: dict | None = None) -> None:
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
with path.open("w", encoding="utf-8") as fh:
|
with path.open("w", encoding="utf-8") as fh:
|
||||||
fh.write(json.dumps({
|
fh.write(json.dumps({
|
||||||
@@ -39,6 +40,10 @@ def write_jsonl(result: ScenarioResult, path: str | Path) -> None:
|
|||||||
"detail": step.detail,
|
"detail": step.detail,
|
||||||
"sim_time_ms": step.sim_time_ms,
|
"sim_time_ms": step.sim_time_ms,
|
||||||
}) + "\n")
|
}) + "\n")
|
||||||
|
if extra:
|
||||||
|
# continuous-run artifacts (spec §8.4): episodes, rate-limit
|
||||||
|
# peaks, and snapshots appended as a summary record
|
||||||
|
fh.write(json.dumps({"type": "continuous", **extra}) + "\n")
|
||||||
|
|
||||||
|
|
||||||
def write_csv(result: ScenarioResult, path: str | Path) -> None:
|
def write_csv(result: ScenarioResult, path: str | Path) -> None:
|
||||||
|
|||||||
+293
-14
@@ -14,7 +14,9 @@ from pathlib import Path
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from ..app import SimulatorApp, rebuild_node
|
from ..app import SimulatorApp, rebuild_node
|
||||||
from ..modules import CATALOG, ChannelMismatch, ModuleUnknown, resolve_module
|
from ..hostnet import HostNetError, HostNetRefused, HostNetUnavailable
|
||||||
|
from ..modules import (CATALOG, ChannelMismatch, ModuleUnknown, VirtualNode,
|
||||||
|
resolve_module)
|
||||||
from ..scenario.engine import ScenarioError
|
from ..scenario.engine import ScenarioError
|
||||||
from ..watchdog import RegisterWriteRefused
|
from ..watchdog import RegisterWriteRefused
|
||||||
from . import state
|
from . import state
|
||||||
@@ -260,6 +262,162 @@ def _payload_bool(payload: dict) -> bool:
|
|||||||
raise HTTPException(status_code=400, detail="value must be a boolean")
|
raise HTTPException(status_code=400, detail="value must be a boolean")
|
||||||
|
|
||||||
|
|
||||||
|
# -- host network settings (spec §15) -------------------------------------------
|
||||||
|
#
|
||||||
|
# Routes exist ONLY behind both gates (FR-15.2.1 flag + FR-15.2.2
|
||||||
|
# docker): when disabled every /api/hostnet* path answers 404 — no
|
||||||
|
# dormant surface. `build_webui` mounts HostNetService on
|
||||||
|
# app.state.hostnet only when `hostnet_enabled()` passes, so the gate
|
||||||
|
# lives in the app factory, not in each handler.
|
||||||
|
|
||||||
|
|
||||||
|
def _hostnet_or_404(request: Request):
|
||||||
|
"""The HostNetService for this app — or 404 when the feature is off."""
|
||||||
|
service = getattr(request.app.state, "hostnet", None)
|
||||||
|
if service is None:
|
||||||
|
raise HTTPException(status_code=404, detail="host network settings are not enabled")
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
def _hostnet_operator(request: Request, sessions: SessionStore) -> str:
|
||||||
|
"""The logged-in user for the audit trail (FR-15.2.5)."""
|
||||||
|
return sessions.username or "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/hostnet")
|
||||||
|
async def get_hostnet(
|
||||||
|
request: Request,
|
||||||
|
_: None = Depends(require_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Availability + live interface list (FR-15.2.3).
|
||||||
|
|
||||||
|
Never 503s itself: an unreachable agent is reported as
|
||||||
|
`available: false` with the error string — the page renders a
|
||||||
|
banner, not a hard failure (FR-15.2.8).
|
||||||
|
"""
|
||||||
|
service = _hostnet_or_404(request)
|
||||||
|
status = service.status()
|
||||||
|
interfaces = None
|
||||||
|
if status["available"]:
|
||||||
|
try:
|
||||||
|
interfaces = service.interfaces()
|
||||||
|
except HostNetError as exc:
|
||||||
|
status = {"available": False, "error": str(exc), "agent": None}
|
||||||
|
return {"status": status, "interfaces": interfaces}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/hostnet/interfaces/{iface}/addresses")
|
||||||
|
async def hostnet_add_address(
|
||||||
|
iface: str, payload: dict,
|
||||||
|
request: Request,
|
||||||
|
sessions: SessionStore = Depends(get_sessions),
|
||||||
|
) -> dict:
|
||||||
|
"""Add an IPv4 address to a whitelisted interface (FR-15.2.4)."""
|
||||||
|
_require_write(request, sessions)
|
||||||
|
service = _hostnet_or_404(request)
|
||||||
|
try:
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
service.add_address, iface,
|
||||||
|
payload.get("address"), payload.get("prefix"),
|
||||||
|
_hostnet_operator(request, sessions),
|
||||||
|
)
|
||||||
|
except HostNetUnavailable as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from None
|
||||||
|
except HostNetRefused as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc)) from None
|
||||||
|
except HostNetError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/hostnet/interfaces/{iface}/addresses/{address}")
|
||||||
|
async def hostnet_remove_address(
|
||||||
|
iface: str, address: str,
|
||||||
|
request: Request,
|
||||||
|
sessions: SessionStore = Depends(get_sessions),
|
||||||
|
) -> dict:
|
||||||
|
"""Remove an address from a whitelisted interface (FR-15.2.4)."""
|
||||||
|
_require_write(request, sessions)
|
||||||
|
service = _hostnet_or_404(request)
|
||||||
|
try:
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
service.remove_address, iface, address,
|
||||||
|
_hostnet_operator(request, sessions),
|
||||||
|
)
|
||||||
|
except HostNetUnavailable as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from None
|
||||||
|
except HostNetRefused as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc)) from None
|
||||||
|
except HostNetError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/hostnet/interfaces/{iface}/addresses")
|
||||||
|
async def hostnet_change_address(
|
||||||
|
iface: str, payload: dict,
|
||||||
|
request: Request,
|
||||||
|
sessions: SessionStore = Depends(get_sessions),
|
||||||
|
) -> dict:
|
||||||
|
"""Replace an interface's primary address (FR-15.2.4): add-first
|
||||||
|
semantics so the interface is never address-less mid-change."""
|
||||||
|
_require_write(request, sessions)
|
||||||
|
service = _hostnet_or_404(request)
|
||||||
|
try:
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
service.change_address, iface,
|
||||||
|
payload.get("old"), payload.get("new"), payload.get("prefix"),
|
||||||
|
_hostnet_operator(request, sessions),
|
||||||
|
)
|
||||||
|
except HostNetUnavailable as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from None
|
||||||
|
except HostNetRefused as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc)) from None
|
||||||
|
except HostNetError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/hostnet/interfaces/{iface}/restore")
|
||||||
|
async def hostnet_restore(
|
||||||
|
iface: str,
|
||||||
|
request: Request,
|
||||||
|
sessions: SessionStore = Depends(get_sessions),
|
||||||
|
) -> dict:
|
||||||
|
"""Undo the last change: restore the interface's address snapshot
|
||||||
|
(FR-15.2.5). The agent snapshots before every mutation, so
|
||||||
|
'restore' walks back one change."""
|
||||||
|
_require_write(request, sessions)
|
||||||
|
service = _hostnet_or_404(request)
|
||||||
|
try:
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
service.restore, iface, _hostnet_operator(request, sessions),
|
||||||
|
)
|
||||||
|
except HostNetUnavailable as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from None
|
||||||
|
except HostNetRefused as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc)) from None
|
||||||
|
except HostNetError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/hostnet/audit")
|
||||||
|
async def hostnet_audit(
|
||||||
|
request: Request,
|
||||||
|
_: None = Depends(require_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Bounded recent audit lines (FR-15.2.5) — mutations AND refusals."""
|
||||||
|
service = _hostnet_or_404(request)
|
||||||
|
try:
|
||||||
|
lines = await asyncio.to_thread(service.audit)
|
||||||
|
except HostNetUnavailable as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from None
|
||||||
|
except HostNetError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||||
|
return {"lines": lines}
|
||||||
|
|
||||||
|
|
||||||
# -- WebSocket live values --------------------------------------------------------
|
# -- WebSocket live values --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -417,7 +575,7 @@ async def put_module_config(
|
|||||||
save_node_config(sim.node_config_path, modules,
|
save_node_config(sim.node_config_path, modules,
|
||||||
name=Path(sim.node_config_path).stem,
|
name=Path(sim.node_config_path).stem,
|
||||||
channel_names=sim.node.channel_names,
|
channel_names=sim.node.channel_names,
|
||||||
channel_ranges=sim.node.channel_ranges)
|
channel_ranges=_nondefault_ranges(sim))
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
@@ -485,6 +643,7 @@ async def put_channel_names(
|
|||||||
sim.node_config_path, sim.node.modules,
|
sim.node_config_path, sim.node.modules,
|
||||||
name=Path(sim.node_config_path).stem,
|
name=Path(sim.node_config_path).stem,
|
||||||
channel_names=names,
|
channel_names=names,
|
||||||
|
channel_ranges=sim.node.channel_ranges,
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -520,8 +679,10 @@ async def put_channel_ranges(
|
|||||||
"""Set per-channel analog display ranges (FR-10.2.14).
|
"""Set per-channel analog display ranges (FR-10.2.14).
|
||||||
|
|
||||||
Payload: {"channel_ranges": {"AI4.0": {"min": 0, "max": 32000}, ...}}.
|
Payload: {"channel_ranges": {"AI4.0": {"min": 0, "max": 32000}, ...}}.
|
||||||
Codes are regex-validated; 0 <= min < max <= 65535 required; an
|
Codes are regex-validated; -65534 <= min < max <= 65535 required.
|
||||||
entry with min=0/max=4095 (the default) is dropped from the store.
|
The runtime store is DENSE (every analog channel present, default
|
||||||
|
0-4095 materialized); default-equal entries are kept in the store
|
||||||
|
but omitted from the persisted YAML (lean config).
|
||||||
"""
|
"""
|
||||||
_require_write(request, sessions)
|
_require_write(request, sessions)
|
||||||
raw = payload.get("channel_ranges")
|
raw = payload.get("channel_ranges")
|
||||||
@@ -550,10 +711,9 @@ async def put_channel_ranges(
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"range for {code_up!r}: need -65534 <= min < max <= 65535",
|
detail=f"range for {code_up!r}: need -65534 <= min < max <= 65535",
|
||||||
)
|
)
|
||||||
if (lo, hi) == (0, 4095):
|
ranges[code_up] = (lo, hi) # dense store keeps defaults
|
||||||
continue # default — keep the store clean
|
sim.node.set_channel_ranges(ranges)
|
||||||
ranges[code_up] = (lo, hi)
|
persisted = _nondefault_ranges(sim)
|
||||||
sim.node.channel_ranges = ranges
|
|
||||||
if payload.get("persist", True) and sim.node_config_path:
|
if payload.get("persist", True) and sim.node_config_path:
|
||||||
from .persist import save_node_config
|
from .persist import save_node_config
|
||||||
try:
|
try:
|
||||||
@@ -561,7 +721,7 @@ async def put_channel_ranges(
|
|||||||
sim.node_config_path, sim.node.modules,
|
sim.node_config_path, sim.node.modules,
|
||||||
name=Path(sim.node_config_path).stem,
|
name=Path(sim.node_config_path).stem,
|
||||||
channel_names=sim.node.channel_names,
|
channel_names=sim.node.channel_names,
|
||||||
channel_ranges=ranges,
|
channel_ranges=persisted,
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -570,10 +730,119 @@ async def put_channel_ranges(
|
|||||||
) from None
|
) from None
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"channel_ranges": {c: {"min": lo, "max": hi} for c, (lo, hi) in ranges.items()},
|
"channel_ranges": {c: {"min": lo, "max": hi}
|
||||||
|
for c, (lo, hi) in persisted.items()},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -- plant environment control (FR-10.2.15 / FR-10.2.17) -----------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/env")
|
||||||
|
async def get_env(
|
||||||
|
sim: SimulatorApp = Depends(get_sim),
|
||||||
|
_: None = Depends(require_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Environment status: ref, state, sim-time, tick ms."""
|
||||||
|
return sim.runner.status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/env/start")
|
||||||
|
async def env_start(
|
||||||
|
payload: dict,
|
||||||
|
request: Request,
|
||||||
|
sim: SimulatorApp = Depends(get_sim),
|
||||||
|
sessions: SessionStore = Depends(get_sessions),
|
||||||
|
) -> dict:
|
||||||
|
"""Load + start an environment by module ref (FR-10.2.15).
|
||||||
|
|
||||||
|
Payload: {"ref": "envs.pump_station_env:PumpStationEnv"}.
|
||||||
|
Refused while an env is loaded (stop first); the module is
|
||||||
|
hot-reloaded (FR-10.2.17) so edited source runs.
|
||||||
|
"""
|
||||||
|
_require_write(request, sessions)
|
||||||
|
ref = str(payload.get("ref", "")).strip()
|
||||||
|
if not ref:
|
||||||
|
raise HTTPException(status_code=400, detail="'ref' is required")
|
||||||
|
try:
|
||||||
|
sim.runner.start(ref)
|
||||||
|
except ScenarioError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from None
|
||||||
|
return {"ok": True, **sim.runner.status()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/env/stop")
|
||||||
|
async def env_stop(
|
||||||
|
request: Request,
|
||||||
|
sim: SimulatorApp = Depends(get_sim),
|
||||||
|
sessions: SessionStore = Depends(get_sessions),
|
||||||
|
) -> dict:
|
||||||
|
"""Cancel the ticker and unload the env (field freezes)."""
|
||||||
|
_require_write(request, sessions)
|
||||||
|
await sim.runner.stop()
|
||||||
|
return {"ok": True, **sim.runner.status()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/env/pause")
|
||||||
|
async def env_pause(
|
||||||
|
request: Request,
|
||||||
|
sim: SimulatorApp = Depends(get_sim),
|
||||||
|
sessions: SessionStore = Depends(get_sessions),
|
||||||
|
) -> dict:
|
||||||
|
"""Pause: ticker cancelled, instance kept (plant-stuck fault)."""
|
||||||
|
_require_write(request, sessions)
|
||||||
|
try:
|
||||||
|
await sim.runner.pause()
|
||||||
|
except ScenarioError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from None
|
||||||
|
return {"ok": True, **sim.runner.status()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/env/resume")
|
||||||
|
async def env_resume(
|
||||||
|
request: Request,
|
||||||
|
sim: SimulatorApp = Depends(get_sim),
|
||||||
|
sessions: SessionStore = Depends(get_sessions),
|
||||||
|
) -> dict:
|
||||||
|
"""Resume a paused env (reschedules the ticker)."""
|
||||||
|
_require_write(request, sessions)
|
||||||
|
try:
|
||||||
|
await sim.runner.resume()
|
||||||
|
except ScenarioError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from None
|
||||||
|
return {"ok": True, **sim.runner.status()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/env/reset")
|
||||||
|
async def env_reset(
|
||||||
|
request: Request,
|
||||||
|
sim: SimulatorApp = Depends(get_sim),
|
||||||
|
sessions: SessionStore = Depends(get_sessions),
|
||||||
|
) -> dict:
|
||||||
|
"""Deterministic fresh episode (NFR-8.7.3); ticker keeps running."""
|
||||||
|
_require_write(request, sessions)
|
||||||
|
try:
|
||||||
|
sim.runner.reset()
|
||||||
|
except ScenarioError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from None
|
||||||
|
return {"ok": True, **sim.runner.status()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/env/restart")
|
||||||
|
async def env_restart(
|
||||||
|
request: Request,
|
||||||
|
sim: SimulatorApp = Depends(get_sim),
|
||||||
|
sessions: SessionStore = Depends(get_sessions),
|
||||||
|
) -> dict:
|
||||||
|
"""Fresh instance of the same ref (picks up edited source)."""
|
||||||
|
_require_write(request, sessions)
|
||||||
|
try:
|
||||||
|
await sim.runner.restart()
|
||||||
|
except ScenarioError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from None
|
||||||
|
return {"ok": True, **sim.runner.status()}
|
||||||
|
|
||||||
|
|
||||||
# -- configuration import/export (FR-10.2.12) ---------------------------------
|
# -- configuration import/export (FR-10.2.12) ---------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -593,6 +862,14 @@ def _coupler_snapshot(sim: SimulatorApp) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _nondefault_ranges(sim: SimulatorApp) -> dict[str, tuple[int, int]]:
|
||||||
|
"""Dense store minus default entries — for YAML persistence and
|
||||||
|
the config export (lean config: only explicit overrides).
|
||||||
|
"""
|
||||||
|
return {code: rng for code, rng in sim.node.channel_ranges.items()
|
||||||
|
if rng != VirtualNode.DEFAULT_RANGE}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/config/export")
|
@router.get("/api/config/export")
|
||||||
async def export_config(
|
async def export_config(
|
||||||
sim: SimulatorApp = Depends(get_sim),
|
sim: SimulatorApp = Depends(get_sim),
|
||||||
@@ -606,7 +883,7 @@ async def export_config(
|
|||||||
"modules": [m.article for m in sim.node.modules],
|
"modules": [m.article for m in sim.node.modules],
|
||||||
"channel_names": dict(sim.node.channel_names),
|
"channel_names": dict(sim.node.channel_names),
|
||||||
"channel_ranges": {c: {"min": lo, "max": hi}
|
"channel_ranges": {c: {"min": lo, "max": hi}
|
||||||
for c, (lo, hi) in sim.node.channel_ranges.items()},
|
for c, (lo, hi) in _nondefault_ranges(sim).items()},
|
||||||
"coupler_settings": _coupler_snapshot(sim),
|
"coupler_settings": _coupler_snapshot(sim),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,11 +940,13 @@ async def import_config(
|
|||||||
if not -65534 <= lo < hi <= 65535:
|
if not -65534 <= lo < hi <= 65535:
|
||||||
raise HTTPException(status_code=400,
|
raise HTTPException(status_code=400,
|
||||||
detail=f"range for {code_up!r}: need -65534 <= min < max <= 65535")
|
detail=f"range for {code_up!r}: need -65534 <= min < max <= 65535")
|
||||||
if (lo, hi) != (0, 4095):
|
ranges[code_up] = (lo, hi) # defaults kept; the store is dense
|
||||||
ranges[code_up] = (lo, hi)
|
|
||||||
coupler = payload.get("coupler_settings") or {}
|
coupler = payload.get("coupler_settings") or {}
|
||||||
|
|
||||||
# -- everything validated: apply atomically -------------------------
|
# -- everything validated: apply atomically -------------------------
|
||||||
|
# a loaded env binds to the old layout — discard before rebuilding
|
||||||
|
# (FR-10.2.15; same rule as SAVE CONFIG)
|
||||||
|
await sim.runner.discard()
|
||||||
rebuild_node(sim, modules, channel_names=names, channel_ranges=ranges)
|
rebuild_node(sim, modules, channel_names=names, channel_ranges=ranges)
|
||||||
stale = sim.node.validate_channel_names()
|
stale = sim.node.validate_channel_names()
|
||||||
if stale:
|
if stale:
|
||||||
@@ -703,7 +982,7 @@ async def import_config(
|
|||||||
sim.node_config_path, modules,
|
sim.node_config_path, modules,
|
||||||
name=Path(sim.node_config_path).stem,
|
name=Path(sim.node_config_path).stem,
|
||||||
channel_names=names,
|
channel_names=names,
|
||||||
channel_ranges=ranges,
|
channel_ranges=_nondefault_ranges(sim),
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ datastore. Static single-page front-end served from `webui/static/`.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
@@ -14,20 +15,43 @@ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from ..app import SimulatorApp
|
from ..app import SimulatorApp
|
||||||
|
from ..hostnet import HostNetClient, HostNetService, hostnet_enabled
|
||||||
from .api import router
|
from .api import router
|
||||||
from .auth import SESSION_COOKIE, SessionStore
|
from .auth import SESSION_COOKIE, SessionStore
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).parent / "static"
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_hostnet_service() -> HostNetService | None:
|
||||||
|
"""The hostnet service when BOTH gates pass, else None (FR-15.2.1/2).
|
||||||
|
|
||||||
|
Gate evaluation happens ONCE per web-UI build: the flag is a
|
||||||
|
deployment decision (compose file), not a per-request toggle —
|
||||||
|
flipping it requires a restart, exactly like the spec's
|
||||||
|
"deliberate deployment change" wording.
|
||||||
|
"""
|
||||||
|
if not hostnet_enabled():
|
||||||
|
return None
|
||||||
|
socket_path = os.environ.get("WAGO_SIM_HOSTNET_SOCKET",
|
||||||
|
"/run/wago-sim/hostnet.sock")
|
||||||
|
return HostNetService(HostNetClient(socket_path=socket_path))
|
||||||
|
|
||||||
|
|
||||||
def build_webui(
|
def build_webui(
|
||||||
sim: SimulatorApp,
|
sim: SimulatorApp,
|
||||||
*,
|
*,
|
||||||
user: str,
|
user: str,
|
||||||
password: str,
|
password: str,
|
||||||
readonly: bool = False,
|
readonly: bool = False,
|
||||||
|
hostnet_service: object | None = None,
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
"""Assemble the FastAPI app: API router + static SPA + auth cookie."""
|
"""Assemble the FastAPI app: API router + static SPA + auth cookie.
|
||||||
|
|
||||||
|
``hostnet_service``: a HostNetService (or a test double with the
|
||||||
|
same surface) mounted on ``app.state.hostnet`` when the §15 gates
|
||||||
|
pass. The production default comes from `_build_hostnet_service()`;
|
||||||
|
tests inject an InProcessClient-backed service directly.
|
||||||
|
"""
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="WAGO 750-362 Simulator",
|
title="WAGO 750-362 Simulator",
|
||||||
docs_url=None,
|
docs_url=None,
|
||||||
@@ -36,6 +60,8 @@ def build_webui(
|
|||||||
)
|
)
|
||||||
app.state.sim = sim
|
app.state.sim = sim
|
||||||
app.state.sessions = SessionStore(user=user, password=password, readonly=readonly)
|
app.state.sessions = SessionStore(user=user, password=password, readonly=readonly)
|
||||||
|
app.state.hostnet = (hostnet_service if hostnet_service is not None
|
||||||
|
else _build_hostnet_service())
|
||||||
|
|
||||||
app.include_router(router)
|
app.include_router(router)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ..modules import ModuleType
|
from ..modules import ModuleType, VirtualNode
|
||||||
|
|
||||||
_HEADER = """\
|
_HEADER = """\
|
||||||
# Virtual WAGO node configuration — saved from the web UI (SAVE CONFIG)
|
# Virtual WAGO node configuration — saved from the web UI (SAVE CONFIG)
|
||||||
@@ -52,8 +52,15 @@ def save_node_config(
|
|||||||
for code in sorted(channel_names):
|
for code in sorted(channel_names):
|
||||||
lines.append(f" {code}: {channel_names[code]}\n")
|
lines.append(f" {code}: {channel_names[code]}\n")
|
||||||
if channel_ranges:
|
if channel_ranges:
|
||||||
lines.append(_RANGES_HEADER)
|
# lean YAML: the runtime store is dense (every analog channel,
|
||||||
for code in sorted(channel_ranges):
|
# FR-10.2.14) — persist only explicit, non-default entries so a
|
||||||
lo, hi = channel_ranges[code]
|
# hand-edited config stays minimal and defaults survive a
|
||||||
lines.append(f" {code}: {{ min: {lo}, max: {hi} }}\n")
|
# future DEFAULT_RANGE change
|
||||||
|
explicit = {code: rng for code, rng in channel_ranges.items()
|
||||||
|
if rng != VirtualNode.DEFAULT_RANGE}
|
||||||
|
if explicit:
|
||||||
|
lines.append(_RANGES_HEADER)
|
||||||
|
for code in sorted(explicit):
|
||||||
|
lo, hi = explicit[code]
|
||||||
|
lines.append(f" {code}: {{ min: {lo}, max: {hi} }}\n")
|
||||||
Path(path).write_text("".join(lines), encoding="utf-8", newline="\n")
|
Path(path).write_text("".join(lines), encoding="utf-8", newline="\n")
|
||||||
+22
-15
@@ -16,15 +16,16 @@ def node_layout(node: VirtualNode) -> NodeLayoutJSON:
|
|||||||
modules_json = []
|
modules_json = []
|
||||||
for index, mod in enumerate(node.modules):
|
for index, mod in enumerate(node.modules):
|
||||||
channels = []
|
channels = []
|
||||||
|
# per-type module number for this slot (FR-4.3.11 v1.5):
|
||||||
|
# a DI module at physical slot 2 with no earlier DI module is DI0
|
||||||
|
per_type = node.kind_index(index)
|
||||||
for ch in mod.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
|
channel_no = int(ch.name.split()[-1]) - 1
|
||||||
io_code = f"{kind_upper}{index}.{channel_no}"
|
io_code = f"{ch.kind.upper()}{per_type[ch.kind]}.{channel_no}"
|
||||||
lo, hi = (0, 4095)
|
# dense range store (FR-10.2.14): every analog channel has
|
||||||
if ch.kind in ("ai", "ao"):
|
# an entry (defaults materialized at layout time)
|
||||||
lo, hi = node.channel_ranges.get(io_code, (0, 4095))
|
lo, hi = node.channel_ranges[io_code] \
|
||||||
|
if ch.kind in ("ai", "ao") else (0, 4095)
|
||||||
channels.append(
|
channels.append(
|
||||||
{
|
{
|
||||||
"name": ch.name,
|
"name": ch.name,
|
||||||
@@ -75,23 +76,27 @@ def channel_values(node: VirtualNode) -> list[ChannelValue]:
|
|||||||
out: list[ChannelValue] = []
|
out: list[ChannelValue] = []
|
||||||
|
|
||||||
# Digital channels: global 1-based numbers, bit-packed after analog.
|
# Digital channels: global 1-based numbers, bit-packed after analog.
|
||||||
|
# `module` in each entry is the PER-TYPE module number (FR-4.3.11
|
||||||
|
# v1.5) so the UI round-trips the same number the codes carry.
|
||||||
for i, loc in enumerate(node.di, start=1):
|
for i, loc in enumerate(node.di, start=1):
|
||||||
code = node.io_code("di", loc.module_index, loc.channel - 1)
|
kind_no = node.kind_index(loc.module_index)["di"]
|
||||||
|
code = node.io_code_at_slot("di", loc.module_index, loc.channel - 1)
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"kind": "di",
|
"kind": "di",
|
||||||
"module": loc.module_index,
|
"module": kind_no,
|
||||||
"channel": loc.channel - 1,
|
"channel": loc.channel - 1,
|
||||||
"value": ds.digital_input_channel(i),
|
"value": ds.digital_input_channel(i),
|
||||||
"name": node.channel_names.get(code),
|
"name": node.channel_names.get(code),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
for i, loc in enumerate(node.do, start=1):
|
for i, loc in enumerate(node.do, start=1):
|
||||||
code = node.io_code("do", loc.module_index, loc.channel - 1)
|
kind_no = node.kind_index(loc.module_index)["do"]
|
||||||
|
code = node.io_code_at_slot("do", loc.module_index, loc.channel - 1)
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"kind": "do",
|
"kind": "do",
|
||||||
"module": loc.module_index,
|
"module": kind_no,
|
||||||
"channel": loc.channel - 1,
|
"channel": loc.channel - 1,
|
||||||
"value": ds.digital_output_channel(i),
|
"value": ds.digital_output_channel(i),
|
||||||
"name": node.channel_names.get(code),
|
"name": node.channel_names.get(code),
|
||||||
@@ -102,22 +107,24 @@ def channel_values(node: VirtualNode) -> list[ChannelValue]:
|
|||||||
# FR-10.2.14), the raw word is shown as its 16-bit two's-complement
|
# FR-10.2.14), the raw word is shown as its 16-bit two's-complement
|
||||||
# value so the UI, the bar, and the configured range agree.
|
# value so the UI, the bar, and the configured range agree.
|
||||||
for loc in node.ai:
|
for loc in node.ai:
|
||||||
code = node.io_code("ai", loc.module_index, loc.channel - 1)
|
kind_no = node.kind_index(loc.module_index)["ai"]
|
||||||
|
code = node.io_code_at_slot("ai", loc.module_index, loc.channel - 1)
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"kind": "ai",
|
"kind": "ai",
|
||||||
"module": loc.module_index,
|
"module": kind_no,
|
||||||
"channel": loc.channel - 1,
|
"channel": loc.channel - 1,
|
||||||
"value": _display_value(node, "ai", loc, ds.get_input_word(loc.word)),
|
"value": _display_value(node, "ai", loc, ds.get_input_word(loc.word)),
|
||||||
"name": node.channel_names.get(code),
|
"name": node.channel_names.get(code),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
for loc in node.ao:
|
for loc in node.ao:
|
||||||
code = node.io_code("ao", loc.module_index, loc.channel - 1)
|
kind_no = node.kind_index(loc.module_index)["ao"]
|
||||||
|
code = node.io_code_at_slot("ao", loc.module_index, loc.channel - 1)
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"kind": "ao",
|
"kind": "ao",
|
||||||
"module": loc.module_index,
|
"module": kind_no,
|
||||||
"channel": loc.channel - 1,
|
"channel": loc.channel - 1,
|
||||||
"value": _display_value(node, "ao", loc, ds.get_output_word(loc.word)),
|
"value": _display_value(node, "ao", loc, ds.get_output_word(loc.word)),
|
||||||
"name": node.channel_names.get(code),
|
"name": node.channel_names.get(code),
|
||||||
|
|||||||
@@ -28,9 +28,91 @@ async function init() {
|
|||||||
renderRail();
|
renderRail();
|
||||||
renderChannelList();
|
renderChannelList();
|
||||||
if (diag) renderDiagnostics(diag);
|
if (diag) renderDiagnostics(diag);
|
||||||
|
refreshEnvCard();
|
||||||
|
probeHostnet();
|
||||||
openWS();
|
openWS();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- plant environment control (FR-10.2.15) ----------------
|
||||||
|
|
||||||
|
let ENV = null; // last /api/env status
|
||||||
|
|
||||||
|
async function refreshEnvCard() {
|
||||||
|
ENV = await api("/api/env").catch(() => null);
|
||||||
|
if (ENV) renderEnvCard(ENV);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEnvCard(env) {
|
||||||
|
const body = document.getElementById("env-body");
|
||||||
|
if (!body) return;
|
||||||
|
const state = env.state || "none";
|
||||||
|
const chip = `<span class="env-state st-${state}">${state}</span>`;
|
||||||
|
const time = env.sim_time_ms != null
|
||||||
|
? `${(env.sim_time_ms / 1000).toFixed(1)} s` : "—";
|
||||||
|
const tick = env.tick_ms != null ? `${env.tick_ms} ms` : "—";
|
||||||
|
if (state === "none") {
|
||||||
|
body.innerHTML = `
|
||||||
|
<p class="cfg-note">No environment loaded.</p>
|
||||||
|
${READONLY ? "" : `
|
||||||
|
<div class="fault-buttons">
|
||||||
|
<input type="text" id="env-ref" class="env-ref"
|
||||||
|
placeholder="envs.pump_station_env:PumpStationEnv">
|
||||||
|
<button id="env-start" class="primary">Start</button>
|
||||||
|
</div>`}`;
|
||||||
|
} else {
|
||||||
|
const canPause = state === "running";
|
||||||
|
const canResume = state === "paused";
|
||||||
|
const canReset = !READONLY && (state === "running" || state === "paused");
|
||||||
|
body.innerHTML = `
|
||||||
|
<p><b>${env.ref}</b> ${chip}</p>
|
||||||
|
<p class="cfg-note">sim-time ${time} · tick ${tick}
|
||||||
|
${env.reason ? ` · done: ${env.reason}` : ""}</p>
|
||||||
|
${READONLY ? "" : `
|
||||||
|
<div class="fault-buttons">
|
||||||
|
<button id="env-pause" ${canPause ? "" : "disabled"}>Pause</button>
|
||||||
|
<button id="env-resume" ${canResume ? "" : "disabled"}>Resume</button>
|
||||||
|
<button id="env-reset" ${canReset ? "" : "disabled"}>Reset</button>
|
||||||
|
<button id="env-restart">Restart</button>
|
||||||
|
<button id="env-stop">Stop</button>
|
||||||
|
</div>`}`;
|
||||||
|
}
|
||||||
|
const msg = document.getElementById("env-msg");
|
||||||
|
msg.classList.add("hidden");
|
||||||
|
bindEnvButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEnvButtons() {
|
||||||
|
const on = (id, fn) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.addEventListener("click", fn);
|
||||||
|
};
|
||||||
|
on("env-start", async () => {
|
||||||
|
const ref = document.getElementById("env-ref").value.trim();
|
||||||
|
await envAction("start", {ref});
|
||||||
|
});
|
||||||
|
on("env-stop", () => envAction("stop"));
|
||||||
|
on("env-pause", () => envAction("pause"));
|
||||||
|
on("env-resume", () => envAction("resume"));
|
||||||
|
on("env-reset", () => envAction("reset"));
|
||||||
|
on("env-restart", () => envAction("restart"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function envAction(name, payload) {
|
||||||
|
const msg = document.getElementById("env-msg");
|
||||||
|
msg.classList.add("hidden");
|
||||||
|
try {
|
||||||
|
await api(`/api/env/${name}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify(payload || {}),
|
||||||
|
});
|
||||||
|
await refreshEnvCard();
|
||||||
|
} catch (e) {
|
||||||
|
msg.textContent = e.message;
|
||||||
|
msg.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openWS() {
|
function openWS() {
|
||||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||||
WS = new WebSocket(`${proto}://${location.host}/api/ws`);
|
WS = new WebSocket(`${proto}://${location.host}/api/ws`);
|
||||||
@@ -75,19 +157,20 @@ function renderChannelList() {
|
|||||||
for (const mod of NODE.modules) {
|
for (const mod of NODE.modules) {
|
||||||
for (const ch of mod.channels) {
|
for (const ch of mod.channels) {
|
||||||
const no = channelNo(ch.address);
|
const no = channelNo(ch.address);
|
||||||
const id = `row-${ch.kind}-${mod.index}-${no}`;
|
const mno = moduleNo(ch.address); // per-type module number
|
||||||
|
const id = `row-${ch.kind}-${mno}-${no}`;
|
||||||
const named = ch.channel_name ? `<span class="row-name">${ch.channel_name}</span>`
|
const named = ch.channel_name ? `<span class="row-name">${ch.channel_name}</span>`
|
||||||
: `<span class="row-name unnamed">—</span>`;
|
: `<span class="row-name unnamed">—</span>`;
|
||||||
let control;
|
let control;
|
||||||
if (ch.kind === "di" || ch.kind === "do") {
|
if (ch.kind === "di" || ch.kind === "do") {
|
||||||
control = `<label class="switch"><input type="checkbox" class="row-value"
|
control = `<label class="switch"><input type="checkbox" class="row-value"
|
||||||
id="val-${ch.kind}-${mod.index}-${no}"
|
id="val-${ch.kind}-${mno}-${no}"
|
||||||
onchange="listWrite('${ch.kind}', ${mod.index}, ${no}, this)">
|
onchange="listWrite('${ch.kind}', ${mno}, ${no}, this)">
|
||||||
<span class="slider"></span></label>`;
|
<span class="slider"></span></label>`;
|
||||||
} else {
|
} else {
|
||||||
control = `<input type="number" class="row-value num" min="-65534" max="65535"
|
control = `<input type="number" class="row-value num" min="-65534" max="65535"
|
||||||
id="val-${ch.kind}-${mod.index}-${no}"
|
id="val-${ch.kind}-${mno}-${no}"
|
||||||
onchange="listWrite('${ch.kind}', ${mod.index}, ${no}, this)">`;
|
onchange="listWrite('${ch.kind}', ${mno}, ${no}, this)">`;
|
||||||
}
|
}
|
||||||
html += `<div class="chlist-row" id="${id}" title="${ch.name} · MB ${ch.modbus}">
|
html += `<div class="chlist-row" id="${id}" title="${ch.name} · MB ${ch.modbus}">
|
||||||
<span class="row-io">${ch.address}</span>
|
<span class="row-io">${ch.address}</span>
|
||||||
@@ -253,7 +336,6 @@ async function importConfig(file) {
|
|||||||
function renderRail() {
|
function renderRail() {
|
||||||
const rail = document.getElementById("rail");
|
const rail = document.getElementById("rail");
|
||||||
rail.innerHTML = "";
|
rail.innerHTML = "";
|
||||||
|
|
||||||
// coupler block on the left
|
// coupler block on the left
|
||||||
const coupler = document.createElement("div");
|
const coupler = document.createElement("div");
|
||||||
coupler.className = "coupler";
|
coupler.className = "coupler";
|
||||||
@@ -293,7 +375,7 @@ function renderRail() {
|
|||||||
html += `<div class="mod-channels ${isDigital ? "mod-channels-v" : ""}">`;
|
html += `<div class="mod-channels ${isDigital ? "mod-channels-v" : ""}">`;
|
||||||
let chIndex = 0;
|
let chIndex = 0;
|
||||||
for (const ch of mod.channels) {
|
for (const ch of mod.channels) {
|
||||||
const id = `ch-${ch.kind}-${mod.index}-${channelNo(ch.address)}`;
|
const id = `ch-${ch.kind}-${moduleNo(ch.address)}-${channelNo(ch.address)}`;
|
||||||
const chNo = String(channelNo(ch.address)); // 0-based channel only
|
const chNo = String(channelNo(ch.address)); // 0-based channel only
|
||||||
if (isDigital) {
|
if (isDigital) {
|
||||||
if (chIndex > 0 && chIndex % 8 === 0) {
|
if (chIndex > 0 && chIndex % 8 === 0) {
|
||||||
@@ -302,7 +384,7 @@ function renderRail() {
|
|||||||
}
|
}
|
||||||
html += `<span class="led-cell">
|
html += `<span class="led-cell">
|
||||||
<span class="led" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})"
|
<span class="led" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})"
|
||||||
onclick="clickChannel('${ch.kind}', ${mod.index}, ${channelNo(ch.address)})"></span>
|
onclick="clickChannel('${ch.kind}', ${moduleNo(ch.address)}, ${channelNo(ch.address)})"></span>
|
||||||
<span class="led-label">${chNo}</span></span>`;
|
<span class="led-label">${chNo}</span></span>`;
|
||||||
chIndex += 1;
|
chIndex += 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -312,7 +394,8 @@ function renderRail() {
|
|||||||
html += `<span class="abar-cell">
|
html += `<span class="abar-cell">
|
||||||
<span class="abar-label top-label">${chNo}</span>
|
<span class="abar-label top-label">${chNo}</span>
|
||||||
<span class="lim-led lim-over" id="${id}-over" title="overflow (> ${rg.max})"></span>
|
<span class="lim-led lim-over" id="${id}-over" title="overflow (> ${rg.max})"></span>
|
||||||
<span class="abar" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})">
|
<span class="abar" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})"
|
||||||
|
onclick="clickBar('${ch.kind}', ${moduleNo(ch.address)}, ${channelNo(ch.address)}, event)">
|
||||||
<span class="abar-fill"></span></span>
|
<span class="abar-fill"></span></span>
|
||||||
<span class="lim-led lim-under" id="${id}-under" title="underflow (< ${rg.min})"></span>
|
<span class="lim-led lim-under" id="${id}-under" title="underflow (< ${rg.min})"></span>
|
||||||
<span class="abar-label bottom-label">${chNo}</span>
|
<span class="abar-label bottom-label">${chNo}</span>
|
||||||
@@ -336,6 +419,13 @@ function channelNo(address) {
|
|||||||
return dot >= 0 ? Number(address.slice(dot + 1)) : 0;
|
return dot >= 0 ? Number(address.slice(dot + 1)) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moduleNo(address) {
|
||||||
|
// address is like "DI2.1" — the module part before the dot;
|
||||||
|
// per FR-4.3.11 v1.5 this is the PER-TYPE module number (DI0, DO0…)
|
||||||
|
const dot = address.indexOf(".");
|
||||||
|
return dot >= 0 ? Number(address.slice(2, dot)) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
function updateChannel(kind, module, channel, value) {
|
function updateChannel(kind, module, channel, value) {
|
||||||
const id = `ch-${kind}-${module}-${channel}`;
|
const id = `ch-${kind}-${module}-${channel}`;
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
@@ -370,9 +460,12 @@ function updateChannel(kind, module, channel, value) {
|
|||||||
function findChannel(kind, module, channel) {
|
function findChannel(kind, module, channel) {
|
||||||
if (!NODE) return null;
|
if (!NODE) return null;
|
||||||
for (const mod of NODE.modules) {
|
for (const mod of NODE.modules) {
|
||||||
if (mod.index !== module) continue;
|
for (const c of mod.channels) {
|
||||||
return mod.channels.find((c) => c.kind === kind &&
|
if (c.kind === kind && moduleNo(c.address) === module
|
||||||
channelNo(c.address) === channel) || null;
|
&& channelNo(c.address) === channel) {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -392,6 +485,28 @@ async function clickChannel(kind, module, channel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function clickBar(kind, module, channel, ev) {
|
||||||
|
if (READONLY) return;
|
||||||
|
const bar = ev.currentTarget;
|
||||||
|
const rect = bar.getBoundingClientRect();
|
||||||
|
// click height -> fraction of the bar: bottom = range min, top = max
|
||||||
|
const frac = Math.min(1, Math.max(0, 1 - (ev.clientY - rect.top) / rect.height));
|
||||||
|
const chInfo = findChannel(kind, module, channel);
|
||||||
|
const rg = (chInfo && chInfo.range) || {min: 0, max: 4095};
|
||||||
|
const value = Math.round(rg.min + frac * (rg.max - rg.min));
|
||||||
|
try {
|
||||||
|
await api(`/api/channels/${kind}/${module}/${channel}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify({action: "set", value}),
|
||||||
|
});
|
||||||
|
const res = await api("/api/values");
|
||||||
|
applyValues(res.values);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("write rejected:", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------- diagnostics ----------------
|
// ---------------- diagnostics ----------------
|
||||||
|
|
||||||
async function renderDiagnostics(diag) {
|
async function renderDiagnostics(diag) {
|
||||||
@@ -495,13 +610,25 @@ async function renderConfig() {
|
|||||||
renderNameEditor();
|
renderNameEditor();
|
||||||
const list = document.getElementById("module-list");
|
const list = document.getElementById("module-list");
|
||||||
list.innerHTML = mc.modules.map((m, i) => `
|
list.innerHTML = mc.modules.map((m, i) => `
|
||||||
<label class="module-row">
|
<label class="module-row" draggable="true"
|
||||||
|
ondragstart="dragStart(event, ${i})"
|
||||||
|
ondragover="dragOver(event, ${i})"
|
||||||
|
ondrop="drop(event, ${i})"
|
||||||
|
ondragend="dragEnd()">
|
||||||
|
<span class="drag-handle" title="Drag to rearrange">☰</span>
|
||||||
<input type="radio" name="mod" value="${i}">
|
<input type="radio" name="mod" value="${i}">
|
||||||
<b>${i}</b> ${m.article} — ${m.description}
|
<b>${i}</b> ${m.article} — ${m.description}
|
||||||
<span class="mod-io">${m.input_bits ? m.input_bits + " DI" : ""}
|
<span class="mod-io">${m.input_bits ? m.input_bits + " DI" : ""}
|
||||||
${m.output_bits ? m.output_bits + " DO" : ""}
|
${m.output_bits ? m.output_bits + " DO" : ""}
|
||||||
${m.input_words ? m.input_words + " AIw" : ""}
|
${m.input_words ? m.input_words + " AIw" : ""}
|
||||||
${m.output_words ? m.output_words + " AOW" : ""}</span>
|
${m.output_words ? m.output_words + " AOW" : ""}</span>
|
||||||
|
<span class="row-buttons">
|
||||||
|
<button class="mini" onclick="moveModule(${i}, -1)"
|
||||||
|
${i === 0 ? "disabled" : ""} title="Move up">▲</button>
|
||||||
|
<button class="mini" onclick="moveModule(${i}, +1)"
|
||||||
|
${i === mc.modules.length - 1 ? "disabled" : ""}
|
||||||
|
title="Move down">▼</button>
|
||||||
|
</span>
|
||||||
</label>`).join("");
|
</label>`).join("");
|
||||||
const pick = document.getElementById("catalog-pick");
|
const pick = document.getElementById("catalog-pick");
|
||||||
pick.innerHTML = mc.catalog.map((c) =>
|
pick.innerHTML = mc.catalog.map((c) =>
|
||||||
@@ -597,18 +724,206 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.getElementById("module-save").addEventListener("click", saveModules);
|
document.getElementById("module-save").addEventListener("click", saveModules);
|
||||||
|
setInterval(refreshEnvCard, 1000); // env status: 1 Hz
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderModuleRows() {
|
function renderModuleRows() {
|
||||||
// local edit view until SAVE CONFIG is pressed
|
// local edit view until SAVE CONFIG is pressed
|
||||||
const list = document.getElementById("module-list");
|
const list = document.getElementById("module-list");
|
||||||
list.innerHTML = MODULE_ARTICLES.map((a, i) => `
|
list.innerHTML = MODULE_ARTICLES.map((a, i) => `
|
||||||
<label class="module-row">
|
<label class="module-row" draggable="true"
|
||||||
|
ondragstart="dragStart(event, ${i})"
|
||||||
|
ondragover="dragOver(event, ${i})"
|
||||||
|
ondrop="drop(event, ${i})"
|
||||||
|
ondragend="dragEnd()">
|
||||||
|
<span class="drag-handle" title="Drag to rearrange">☰</span>
|
||||||
<input type="radio" name="mod" value="${i}">
|
<input type="radio" name="mod" value="${i}">
|
||||||
<b>${i}</b> ${a}
|
<b>${i}</b> ${a}
|
||||||
|
<span class="row-buttons">
|
||||||
|
<button class="mini" onclick="moveModule(${i}, -1)"
|
||||||
|
${i === 0 ? "disabled" : ""} title="Move up">▲</button>
|
||||||
|
<button class="mini" onclick="moveModule(${i}, +1)"
|
||||||
|
${i === MODULE_ARTICLES.length - 1 ? "disabled" : ""}
|
||||||
|
title="Move down">▼</button>
|
||||||
|
</span>
|
||||||
</label>`).join("");
|
</label>`).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function moveModule(index, delta) {
|
||||||
|
// FR-11.2.6: reorder by swapping with the neighbor, then SAVE CONFIG
|
||||||
|
// (rebuild + persist) so the rail reflects the new physical order
|
||||||
|
const target = index + delta;
|
||||||
|
if (target < 0 || target >= MODULE_ARTICLES.length) return;
|
||||||
|
[MODULE_ARTICLES[index], MODULE_ARTICLES[target]] =
|
||||||
|
[MODULE_ARTICLES[target], MODULE_ARTICLES[index]];
|
||||||
|
await saveModules();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- drag-and-drop reordering (FR-11.2.6) ----------------
|
||||||
|
|
||||||
|
let DRAG_FROM = null;
|
||||||
|
|
||||||
|
function dragStart(ev, index) {
|
||||||
|
DRAG_FROM = index;
|
||||||
|
ev.dataTransfer.effectAllowed = "move";
|
||||||
|
ev.dataTransfer.setData("text/plain", String(index));
|
||||||
|
ev.currentTarget.classList.add("dragging");
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragOver(ev, index) {
|
||||||
|
if (DRAG_FROM === null || DRAG_FROM === index) return;
|
||||||
|
ev.preventDefault(); // allow the drop
|
||||||
|
ev.dataTransfer.dropEffect = "move";
|
||||||
|
ev.currentTarget.classList.add("drop-target");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drop(ev, index) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.currentTarget.classList.remove("drop-target");
|
||||||
|
if (DRAG_FROM === null || DRAG_FROM === index) return;
|
||||||
|
// move the dragged module to the drop position (splice semantics:
|
||||||
|
// remove, then insert at the target index)
|
||||||
|
const [moved] = MODULE_ARTICLES.splice(DRAG_FROM, 1);
|
||||||
|
MODULE_ARTICLES.splice(index, 0, moved);
|
||||||
|
DRAG_FROM = null;
|
||||||
|
await saveModules(); // rebuild + persist, like arrows
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragEnd() {
|
||||||
|
DRAG_FROM = null;
|
||||||
|
document.querySelectorAll(".module-row").forEach((el) =>
|
||||||
|
el.classList.remove("dragging", "drop-target"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- host network settings (spec §15) ----------------
|
||||||
|
|
||||||
|
let HOSTNET = null; // last /api/hostnet payload (status + interfaces)
|
||||||
|
|
||||||
|
async function probeHostnet() {
|
||||||
|
// Feature probe: 404 = gated off (flag or docker) — keep the tab
|
||||||
|
// hidden; FR-15.2.1/2 "no dormant surface". Other errors keep it
|
||||||
|
// hidden too (a broken agent must not break the UI, FR-15.2.8).
|
||||||
|
HOSTNET = await api("/api/hostnet").catch(() => null);
|
||||||
|
if (!HOSTNET) return;
|
||||||
|
document.getElementById("tab-btn-hostnet").classList.remove("hidden");
|
||||||
|
renderHostnet();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostnetBanner(kind, text) {
|
||||||
|
const banner = document.getElementById("hostnet-banner");
|
||||||
|
document.getElementById("hostnet-banner-text").textContent = text;
|
||||||
|
banner.classList.toggle("hidden", !kind);
|
||||||
|
banner.classList.toggle("hostnet-warn", kind === "warn");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshHostnet() {
|
||||||
|
HOSTNET = await api("/api/hostnet").catch(() => null);
|
||||||
|
if (HOSTNET) renderHostnet();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHostnet() {
|
||||||
|
const body = document.getElementById("hostnet-body");
|
||||||
|
if (!HOSTNET) return;
|
||||||
|
const st = HOSTNET.status || {};
|
||||||
|
if (!st.available) {
|
||||||
|
hostnetBanner("warn",
|
||||||
|
"Host control unavailable: " + (st.error || "agent unreachable") +
|
||||||
|
" — the Modbus server and the rest of the UI are unaffected.");
|
||||||
|
body.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hostnetBanner(false, "");
|
||||||
|
const wl = (st.agent && st.agent.whitelist) || [];
|
||||||
|
const ifaces = HOSTNET.interfaces || [];
|
||||||
|
let html = `<p class="cfg-note">The whitelisted interfaces of the ` +
|
||||||
|
`<b>host OS</b> this container runs on — changes apply at runtime ` +
|
||||||
|
`only (a host reboot restores the deployed addressing). ` +
|
||||||
|
`Whitelist: ${wl.join(", ") || "—"}</p>`;
|
||||||
|
if (!ifaces.length) {
|
||||||
|
html += `<p class="cfg-note">No interfaces reported by the agent.</p>`;
|
||||||
|
}
|
||||||
|
for (const ifc of ifaces) {
|
||||||
|
const allowed = wl.includes(ifc.name);
|
||||||
|
const rows = ifc.addresses.map((a) => `
|
||||||
|
<tr>
|
||||||
|
<td>${a.address}/${a.prefix}</td>
|
||||||
|
<td>${allowed && !READONLY
|
||||||
|
? `<button class="mini" onclick="hostnetRemove('${ifc.name}','${a.address}')">Remove</button>`
|
||||||
|
: ""}</td>
|
||||||
|
</tr>`).join("");
|
||||||
|
html += `
|
||||||
|
<div class="diag-card hostnet-card">
|
||||||
|
<h3>${ifc.name} ${ifc.up ? "" : "(down)"}
|
||||||
|
<span class="hostnet-flag ${allowed ? "ok" : "off"}">${allowed ? "whitelisted" : "view only"}</span></h3>
|
||||||
|
<table class="hostnet-addrs">${rows ||
|
||||||
|
"<tr><td>no IPv4 addresses</td><td></td></tr>"}</table>
|
||||||
|
${allowed && !READONLY ? `
|
||||||
|
<div class="hostnet-form">
|
||||||
|
<input type="text" id="hn-add-${ifc.name}" placeholder="192.168.1.33">
|
||||||
|
<input type="number" id="hn-prefix-${ifc.name}" value="24" min="1" max="32">
|
||||||
|
<button onclick="hostnetAdd('${ifc.name}')">Add address</button>
|
||||||
|
</div>
|
||||||
|
<div class="hostnet-form">
|
||||||
|
<input type="text" id="hn-old-${ifc.name}" placeholder="old address">
|
||||||
|
<input type="text" id="hn-new-${ifc.name}" placeholder="new address">
|
||||||
|
<input type="number" id="hn-nprefix-${ifc.name}" value="24" min="1" max="32">
|
||||||
|
<button onclick="hostnetChange('${ifc.name}')">Change</button>
|
||||||
|
</div>
|
||||||
|
<div class="fault-buttons">
|
||||||
|
<button onclick="hostnetRestore('${ifc.name}')">Restore last change</button>
|
||||||
|
</div>` : ""}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
body.innerHTML = html;
|
||||||
|
refreshHostnetAudit();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hostnetAdd(iface) {
|
||||||
|
await hostnetOp("POST",
|
||||||
|
`/api/hostnet/interfaces/${iface}/addresses`,
|
||||||
|
{address: document.getElementById(`hn-add-${iface}`).value.trim(),
|
||||||
|
prefix: Number(document.getElementById(`hn-prefix-${iface}`).value)});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hostnetRemove(iface, address) {
|
||||||
|
await hostnetOp("DELETE",
|
||||||
|
`/api/hostnet/interfaces/${iface}/addresses/${address}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hostnetChange(iface) {
|
||||||
|
await hostnetOp("PUT",
|
||||||
|
`/api/hostnet/interfaces/${iface}/addresses`,
|
||||||
|
{old: document.getElementById(`hn-old-${iface}`).value.trim(),
|
||||||
|
new: document.getElementById(`hn-new-${iface}`).value.trim(),
|
||||||
|
prefix: Number(document.getElementById(`hn-nprefix-${iface}`).value)});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hostnetRestore(iface) {
|
||||||
|
await hostnetOp("POST",
|
||||||
|
`/api/hostnet/interfaces/${iface}/restore`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hostnetOp(method, path, payload) {
|
||||||
|
if (READONLY) return;
|
||||||
|
try {
|
||||||
|
await api(path, {
|
||||||
|
method,
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: payload ? JSON.stringify(payload) : undefined,
|
||||||
|
});
|
||||||
|
await refreshHostnet();
|
||||||
|
} catch (e) {
|
||||||
|
hostnetBanner("warn", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshHostnetAudit() {
|
||||||
|
const pre = document.getElementById("hostnet-audit");
|
||||||
|
if (!pre) return;
|
||||||
|
const r = await api("/api/hostnet/audit").catch(() => null);
|
||||||
|
if (r) pre.textContent = r.lines.length ? r.lines.join("\n") : "(empty)";
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------- tabs ----------------
|
// ---------------- tabs ----------------
|
||||||
|
|
||||||
document.querySelectorAll(".tab").forEach((btn) => {
|
document.querySelectorAll(".tab").forEach((btn) => {
|
||||||
@@ -619,6 +934,7 @@ document.querySelectorAll(".tab").forEach((btn) => {
|
|||||||
document.getElementById(`tab-${btn.dataset.tab}`).classList.add("active");
|
document.getElementById(`tab-${btn.dataset.tab}`).classList.add("active");
|
||||||
if (btn.dataset.tab === "diagnostics") refreshDiagnostics();
|
if (btn.dataset.tab === "diagnostics") refreshDiagnostics();
|
||||||
if (btn.dataset.tab === "config") renderConfig();
|
if (btn.dataset.tab === "config") renderConfig();
|
||||||
|
if (btn.dataset.tab === "hostnet") refreshHostnet();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>WAGO 750-362 Simulator</title>
|
<title>WAGO 750-362 Simulator</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css?v=5">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
<button data-tab="overview" class="tab active">Overview</button>
|
<button data-tab="overview" class="tab active">Overview</button>
|
||||||
<button data-tab="config" class="tab">Configuration</button>
|
<button data-tab="config" class="tab">Configuration</button>
|
||||||
<button data-tab="diagnostics" class="tab">Diagnostics</button>
|
<button data-tab="diagnostics" class="tab">Diagnostics</button>
|
||||||
|
<button data-tab="hostnet" class="tab hidden" id="tab-btn-hostnet">Host network</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
<span id="conn-state" class="conn-dot" title="WebSocket state"></span>
|
<span id="conn-state" class="conn-dot" title="WebSocket state"></span>
|
||||||
@@ -33,6 +34,12 @@
|
|||||||
<div id="rail" class="rail"></div>
|
<div id="rail" class="rail"></div>
|
||||||
<div id="rail-detail" class="rail-detail"></div>
|
<div id="rail-detail" class="rail-detail"></div>
|
||||||
|
|
||||||
|
<div class="diag-card env-card" id="env-card">
|
||||||
|
<h3>Plant environment</h3>
|
||||||
|
<div id="env-body" class="cfg-note">loading…</div>
|
||||||
|
<div id="env-msg" class="error hidden"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3 class="list-title">Channels</h3>
|
<h3 class="list-title">Channels</h3>
|
||||||
<div class="chlist-head">
|
<div class="chlist-head">
|
||||||
<span>I/O</span><span>Name</span><span>Value</span>
|
<span>I/O</span><span>Name</span><span>Value</span>
|
||||||
@@ -109,8 +116,28 @@
|
|||||||
<div class="diag-card" id="diag-faults"></div>
|
<div class="diag-card" id="diag-faults"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ Host network settings (spec §15) ============ -->
|
||||||
|
<!-- Hidden unless the server exposes the feature (flag + docker
|
||||||
|
gates); the tab button above stays .hidden until then. -->
|
||||||
|
<section id="tab-hostnet" class="tab-page">
|
||||||
|
<h2>Host network settings</h2>
|
||||||
|
<div id="hostnet-banner" class="diag-card hostnet-banner hidden">
|
||||||
|
<p id="hostnet-banner-text"></p>
|
||||||
|
</div>
|
||||||
|
<div id="hostnet-body"></div>
|
||||||
|
<div class="diag-card" id="hostnet-audit-card">
|
||||||
|
<h3>Audit log</h3>
|
||||||
|
<p class="cfg-note">Every mutation and every refusal, newest last.
|
||||||
|
The agent appends the same lines to the service log (journald).</p>
|
||||||
|
<pre id="hostnet-audit" class="hostnet-audit"></pre>
|
||||||
|
<div class="fault-buttons">
|
||||||
|
<button id="hostnet-audit-refresh">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js?v=5"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>WAGO Simulator — Login</title>
|
<title>WAGO Simulator — Login</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css?v=5">
|
||||||
</head>
|
</head>
|
||||||
<body class="login-body">
|
<body class="login-body">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ h2 { margin: 4px 0 14px; }
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
overflow: hidden; /* clip the fill inside the rounded bar */
|
overflow: hidden; /* clip the fill inside the rounded bar */
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
cursor: pointer; /* clickable: sets value at click height */
|
||||||
}
|
}
|
||||||
.abar-fill {
|
.abar-fill {
|
||||||
position: absolute; bottom: 0; left: 0; right: 0; height: 0%;
|
position: absolute; bottom: 0; left: 0; right: 0; height: 0%;
|
||||||
@@ -237,6 +238,24 @@ h2 { margin: 4px 0 14px; }
|
|||||||
}
|
}
|
||||||
.diag-card button:hover { background: #004a99; }
|
.diag-card button:hover { background: #004a99; }
|
||||||
.fault-buttons { display: flex; gap: 8px; }
|
.fault-buttons { display: flex; gap: 8px; }
|
||||||
|
.fault-buttons button:disabled { background: #b6bfc9; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ---------- plant environment card (FR-10.2.15) ---------- */
|
||||||
|
|
||||||
|
.env-card { margin-top: 16px; }
|
||||||
|
.env-card .fault-buttons { margin-top: 10px; flex-wrap: wrap; }
|
||||||
|
.env-card .fault-buttons button { margin-top: 0; }
|
||||||
|
.env-ref { flex: 1; padding: 7px 8px; border: 1px solid #c5ccd4; border-radius: 4px;
|
||||||
|
font-family: Consolas, monospace; font-size: 0.88rem; }
|
||||||
|
.env-state {
|
||||||
|
display: inline-block; padding: 2px 10px; border-radius: 10px;
|
||||||
|
font-size: 0.78rem; font-weight: 600; text-transform: uppercase;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.env-state.st-running { background: #e2f5e6; color: #1d7a33; }
|
||||||
|
.env-state.st-paused { background: #fdf3d7; color: #8a6d00; }
|
||||||
|
.env-state.st-done { background: #e8ecf1; color: #56606c; }
|
||||||
|
.env-state.st-none { background: #e8ecf1; color: #56606c; }
|
||||||
|
|
||||||
/* ---------- configuration ---------- */
|
/* ---------- configuration ---------- */
|
||||||
|
|
||||||
@@ -258,6 +277,27 @@ h2 { margin: 4px 0 14px; }
|
|||||||
}
|
}
|
||||||
.module-row:hover { background: #eaf1f8; }
|
.module-row:hover { background: #eaf1f8; }
|
||||||
.mod-io { color: #55606c; margin-left: auto; font-size: 0.78rem; white-space: nowrap; }
|
.mod-io { color: #55606c; margin-left: auto; font-size: 0.78rem; white-space: nowrap; }
|
||||||
|
.row-buttons { display: inline-flex; gap: 3px; margin-left: 6px; }
|
||||||
|
button.mini {
|
||||||
|
padding: 1px 6px; border: 1px solid #c5ccd4; border-radius: 3px;
|
||||||
|
background: #f0f3f6; color: #33414f; font-size: 0.6rem;
|
||||||
|
line-height: 1.2; cursor: pointer;
|
||||||
|
}
|
||||||
|
button.mini:hover:not(:disabled) { background: #dce7f2; }
|
||||||
|
button.mini:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
|
||||||
|
/* drag-and-drop reordering (FR-11.2.6) */
|
||||||
|
.drag-handle {
|
||||||
|
cursor: grab; color: #9aa4ae; font-size: 0.9rem;
|
||||||
|
padding: 0 3px; user-select: none;
|
||||||
|
}
|
||||||
|
.drag-handle:hover { color: #0062c4; }
|
||||||
|
.module-row.dragging { opacity: 0.45; }
|
||||||
|
.module-row.drop-target {
|
||||||
|
border-color: #0062c4 !important;
|
||||||
|
box-shadow: inset 0 2px 0 #0062c4; /* insertion marker: line on top edge */
|
||||||
|
}
|
||||||
|
.module-row[draggable="true"] { cursor: default; }
|
||||||
#catalog-pick { padding: 6px; border: 1px solid #c5ccd4; border-radius: 4px; flex: 1; }
|
#catalog-pick { padding: 6px; border: 1px solid #c5ccd4; border-radius: 4px; flex: 1; }
|
||||||
.cfg-path { font-size: 0.75rem; color: #6b7684; align-self: center; }
|
.cfg-path { font-size: 0.75rem; color: #6b7684; align-self: center; }
|
||||||
button.primary { background: #2e7d32; }
|
button.primary { background: #2e7d32; }
|
||||||
@@ -337,3 +377,35 @@ button.primary:hover { background: #1b5e20; }
|
|||||||
.range-sep { color: #6b7684; }
|
.range-sep { color: #6b7684; }
|
||||||
.hidden-file { display: none; }
|
.hidden-file { display: none; }
|
||||||
#cfg-io-buttons { display: flex; gap: 8px; margin-top: 8px; }
|
#cfg-io-buttons { display: flex; gap: 8px; margin-top: 8px; }
|
||||||
|
|
||||||
|
/* ---------- host network settings (spec §15) ---------- */
|
||||||
|
|
||||||
|
.hostnet-banner {
|
||||||
|
border-left: 4px solid #e8a23c;
|
||||||
|
background: #fdf6e9;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.hostnet-banner p { margin: 0; color: #7a5b12; font-size: 0.88rem; }
|
||||||
|
.hostnet-card h3 { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.hostnet-flag {
|
||||||
|
font-size: 0.68rem; font-weight: 500; padding: 2px 7px;
|
||||||
|
border-radius: 10px; text-transform: uppercase; letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.hostnet-flag.ok { background: #e2f5e6; color: #1d7a33; }
|
||||||
|
.hostnet-flag.off { background: #e8ecf1; color: #56606c; }
|
||||||
|
.hostnet-addrs { width: 100%; margin-bottom: 8px; }
|
||||||
|
.hostnet-form {
|
||||||
|
display: flex; gap: 8px; align-items: center; margin-top: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.hostnet-form input {
|
||||||
|
padding: 6px 8px; border: 1px solid #c5ccd4; border-radius: 4px;
|
||||||
|
font-size: 0.85rem; font-family: Consolas, monospace;
|
||||||
|
}
|
||||||
|
.hostnet-form input[type="number"] { width: 70px; }
|
||||||
|
.hostnet-audit {
|
||||||
|
max-height: 220px; overflow-y: auto; background: #f4f6f8;
|
||||||
|
border: 1px solid #e3e7ec; border-radius: 4px; padding: 10px 12px;
|
||||||
|
font-family: Consolas, monospace; font-size: 0.78rem;
|
||||||
|
white-space: pre-wrap; margin: 0 0 10px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
"""Continuous-mode (spec §8) tests: datasets, degradation, invariants,
|
||||||
|
PlantEnv, and the fixed tick pipeline (FR-8.2.12 order)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from wago_sim.scenario.datasets import CsvDataset
|
||||||
|
from wago_sim.scenario.engine import DataError, ScenarioEngine, ScenarioError, Verdict
|
||||||
|
from wago_sim.scenario.plantenv import PlantEnv
|
||||||
|
|
||||||
|
|
||||||
|
def _write_csv(path, text: str) -> str:
|
||||||
|
path.write_text(textwrap.dedent(text), encoding="utf-8")
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _engine(node, scenario: dict) -> ScenarioEngine:
|
||||||
|
return ScenarioEngine(node, scenario)
|
||||||
|
|
||||||
|
|
||||||
|
class TestContinuousValidation:
|
||||||
|
def test_unbounded_run_rejected(self, node):
|
||||||
|
with pytest.raises(ScenarioError, match="would run forever"):
|
||||||
|
_engine(node, {"mode": "continuous", "name": "x",
|
||||||
|
"inputs": {"s": {"type": "di", "channel": 1}}})
|
||||||
|
|
||||||
|
def test_until_bounds_the_run(self, node):
|
||||||
|
engine = _engine(node, {"mode": "continuous", "name": "x", "until": "1s",
|
||||||
|
"inputs": {"s": {"type": "di", "channel": 1}}})
|
||||||
|
assert engine.mode == "continuous"
|
||||||
|
|
||||||
|
def test_dsl_validation_flags_continuous(self):
|
||||||
|
from wago_sim.scenario.dsl import validate_scenario
|
||||||
|
problems = validate_scenario({"name": "x", "mode": "continuous"})
|
||||||
|
assert any("datasets, scheduled steps" in p for p in problems)
|
||||||
|
problems = validate_scenario({"name": "x", "mode": "continuous",
|
||||||
|
"until": "1h"})
|
||||||
|
assert not any("datasets, scheduled steps" in p for p in problems)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDatasets:
|
||||||
|
def _scenario_with_csv(self, tmp_path, csv_text, **extra):
|
||||||
|
csv = _write_csv(tmp_path / "data.csv", csv_text)
|
||||||
|
scenario = {
|
||||||
|
"mode": "continuous", "name": "csv", "until": "5s",
|
||||||
|
"inputs": {
|
||||||
|
"level": {"type": "ai", "module": 0, "channel": 0, "scale": 1.0},
|
||||||
|
"switch": {"type": "di", "module": 0, "channel": 1},
|
||||||
|
},
|
||||||
|
"datasets": {"main": {"file": csv}},
|
||||||
|
**extra,
|
||||||
|
}
|
||||||
|
return scenario
|
||||||
|
|
||||||
|
def test_streamed_values_match_samples(self, node, tmp_path):
|
||||||
|
scenario = self._scenario_with_csv(tmp_path, """\
|
||||||
|
time,level,switch
|
||||||
|
0.0,100,ON
|
||||||
|
1.0,200,OFF
|
||||||
|
2.0,300,ON
|
||||||
|
3.0,400,OFF
|
||||||
|
4.0,500,ON
|
||||||
|
""")
|
||||||
|
engine = _engine(node, scenario)
|
||||||
|
engine.start()
|
||||||
|
result = None
|
||||||
|
while result is None:
|
||||||
|
result = engine.pump()
|
||||||
|
assert result.verdict is Verdict.PASS
|
||||||
|
# final applied sample: level = 500 counts at t=5s
|
||||||
|
assert engine.alias_value("level") == pytest.approx(500.0)
|
||||||
|
|
||||||
|
def test_linear_interpolation_between_samples(self, node, tmp_path):
|
||||||
|
scenario = self._scenario_with_csv(tmp_path, """\
|
||||||
|
time,level,switch
|
||||||
|
0.0,0,OFF
|
||||||
|
2.0,100,OFF
|
||||||
|
""", until="1s")
|
||||||
|
scenario["datasets"]["main"]["interpolation"] = "linear"
|
||||||
|
engine = _engine(node, scenario)
|
||||||
|
engine.pump() # t=10ms
|
||||||
|
engine.pump()
|
||||||
|
# at t=20ms, interpolated between 0 (t=0) and 100 (t=2s): 1.0
|
||||||
|
assert engine.alias_value("level") == pytest.approx(1.0)
|
||||||
|
|
||||||
|
def test_hold_interpolation(self, node, tmp_path):
|
||||||
|
scenario = self._scenario_with_csv(tmp_path, """\
|
||||||
|
time,level,switch
|
||||||
|
0.0,0,OFF
|
||||||
|
2.0,100,OFF
|
||||||
|
""", until="1s")
|
||||||
|
scenario["datasets"]["main"]["interpolation"] = "hold"
|
||||||
|
engine = _engine(node, scenario)
|
||||||
|
engine.pump()
|
||||||
|
engine.pump()
|
||||||
|
assert engine.alias_value("level") == pytest.approx(0.0)
|
||||||
|
|
||||||
|
def test_output_alias_in_dataset_rejected(self, node, tmp_path):
|
||||||
|
csv = _write_csv(tmp_path / "bad.csv", "time,pump\n0.0,1\n")
|
||||||
|
scenario = {
|
||||||
|
"mode": "continuous", "name": "x", "until": "1s",
|
||||||
|
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
|
||||||
|
"outputs": {"pump": {"type": "do", "module": 0, "channel": 0}},
|
||||||
|
"datasets": {"main": {"file": csv}},
|
||||||
|
}
|
||||||
|
with pytest.raises(DataError, match="output alias"):
|
||||||
|
_engine(node, scenario)
|
||||||
|
|
||||||
|
def test_malformed_value_is_error_verdict(self, node, tmp_path):
|
||||||
|
csv = _write_csv(tmp_path / "bad.csv", "time,level\n0.0,100\n1.0,banana\n")
|
||||||
|
scenario = {
|
||||||
|
"mode": "continuous", "name": "x", "until": "5s",
|
||||||
|
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
|
||||||
|
"datasets": {"main": {"file": csv}},
|
||||||
|
}
|
||||||
|
engine = _engine(node, scenario)
|
||||||
|
result = None
|
||||||
|
while result is None:
|
||||||
|
result = engine.pump()
|
||||||
|
assert result.verdict is Verdict.ERROR
|
||||||
|
assert "banana" in result.steps[-1].detail
|
||||||
|
|
||||||
|
def test_non_monotonic_time_is_error(self, node, tmp_path):
|
||||||
|
csv = _write_csv(tmp_path / "bad.csv", "time,level\n1.0,10\n0.5,20\n")
|
||||||
|
scenario = {
|
||||||
|
"mode": "continuous", "name": "x", "until": "5s",
|
||||||
|
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
|
||||||
|
"datasets": {"main": {"file": csv}},
|
||||||
|
}
|
||||||
|
# detected while priming the lookahead at open(): immediate
|
||||||
|
# DataError with file:line (FR-8.2.11)
|
||||||
|
with pytest.raises(DataError, match="non-monotonic"):
|
||||||
|
_engine(node, scenario)
|
||||||
|
|
||||||
|
def test_alias_ownership_conflict_rejected(self, node, tmp_path):
|
||||||
|
csv = _write_csv(tmp_path / "a.csv", "time,level\n0.0,1\n1.0,2\n")
|
||||||
|
csv2 = _write_csv(tmp_path / "b.csv", "time,level\n0.0,3\n1.0,4\n")
|
||||||
|
scenario = {
|
||||||
|
"mode": "continuous", "name": "x", "until": "5s",
|
||||||
|
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
|
||||||
|
"datasets": {
|
||||||
|
"a": {"file": csv},
|
||||||
|
"b": {"file": csv2},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with pytest.raises(ScenarioError, match="claimed by both"):
|
||||||
|
_engine(node, scenario)
|
||||||
|
|
||||||
|
def test_time_factor_accelerates(self, node, tmp_path):
|
||||||
|
# dataset time advances 10x sim time: 10 dataset-seconds per
|
||||||
|
# sim-second; at sim t=1s the dataset is at t=10s
|
||||||
|
csv = _write_csv(tmp_path / "fast.csv", """\
|
||||||
|
time,level
|
||||||
|
0.0,0
|
||||||
|
10.0,1000
|
||||||
|
""")
|
||||||
|
scenario = {
|
||||||
|
"mode": "continuous", "name": "x", "until": "1s",
|
||||||
|
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
|
||||||
|
"datasets": {"main": {"file": csv, "time_factor": 10.0,
|
||||||
|
"interpolation": "linear"}},
|
||||||
|
}
|
||||||
|
engine = _engine(node, scenario)
|
||||||
|
result = None
|
||||||
|
while result is None:
|
||||||
|
result = engine.pump()
|
||||||
|
# at end (sim t=1s) dataset t=10s -> level = 1000
|
||||||
|
assert engine.alias_value("level") == pytest.approx(1000.0)
|
||||||
|
|
||||||
|
def test_bounded_memory_large_file(self, node, tmp_path, datastore):
|
||||||
|
"""FR-8.2.6: a 100k-row file plays without loading into RAM."""
|
||||||
|
big = tmp_path / "big.csv"
|
||||||
|
with big.open("w", encoding="utf-8") as fh:
|
||||||
|
fh.write("time,level\n")
|
||||||
|
for i in range(100_000):
|
||||||
|
fh.write(f"{i / 10.0},{i % 1000}\n")
|
||||||
|
scenario = {
|
||||||
|
"mode": "continuous", "name": "x", "until": "60s",
|
||||||
|
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
|
||||||
|
"datasets": {"main": {"file": str(big), "interpolation": "hold"}},
|
||||||
|
}
|
||||||
|
engine = _engine(node, scenario)
|
||||||
|
engine.start()
|
||||||
|
result = None
|
||||||
|
ticks = 0
|
||||||
|
while result is None:
|
||||||
|
result = engine.pump()
|
||||||
|
ticks += 1
|
||||||
|
assert result.verdict is Verdict.PASS
|
||||||
|
# only a lookahead row is held, not the whole file
|
||||||
|
ds = engine.datasets[0]
|
||||||
|
assert ds._row is not None and ds._next is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestDegradation:
|
||||||
|
def _scenario(self, **degradation):
|
||||||
|
return {
|
||||||
|
"mode": "continuous", "name": "x", "until": "2h",
|
||||||
|
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
|
||||||
|
"outputs": {"pump": {"type": "do", "module": 0, "channel": 0}},
|
||||||
|
"plugins": [],
|
||||||
|
"degradation": {
|
||||||
|
"pump_capacity": {
|
||||||
|
"observes": "pump",
|
||||||
|
"model": "linear",
|
||||||
|
"rate": 0.1, # 0.1 per runtime hour
|
||||||
|
"from": 1.0,
|
||||||
|
**degradation,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_parameter_decays_with_runtime(self, node):
|
||||||
|
engine = _engine(node, self._scenario())
|
||||||
|
# wear observes the DO alias: emulate the master turning it on
|
||||||
|
loc = engine._alias_location(engine._aliases["pump"])
|
||||||
|
engine.datastore.write_bits(loc.bit, [True])
|
||||||
|
result = None
|
||||||
|
while result is None:
|
||||||
|
result = engine.pump()
|
||||||
|
# 2h runtime at 0.1/h -> capacity = 0.8
|
||||||
|
assert engine.parameter("pump_capacity") == pytest.approx(0.8, abs=0.01)
|
||||||
|
|
||||||
|
def test_maintenance_threshold_injects_diagnosis(self, node, app):
|
||||||
|
# engine needs the register bank for diagnosis injection
|
||||||
|
scenario = self._scenario(maintenance_at=0.95)
|
||||||
|
engine = ScenarioEngine(node, scenario)
|
||||||
|
engine.registers = app.registers # FR-8.3.4 injection target
|
||||||
|
loc = engine._alias_location(engine._aliases["pump"])
|
||||||
|
engine.datastore.write_bits(loc.bit, [True])
|
||||||
|
result = None
|
||||||
|
while result is None:
|
||||||
|
result = engine.pump()
|
||||||
|
# capacity crosses 0.95 after ~0.5h of the 2h run -> diagnosed
|
||||||
|
assert engine.degradation.models["pump_capacity"].diagnosed is True
|
||||||
|
assert app.registers.read_config(0x1050, 3) != [0, 0, 0]
|
||||||
|
|
||||||
|
def test_maintenance_reset_restores(self, node, app):
|
||||||
|
scenario = self._scenario()
|
||||||
|
engine = ScenarioEngine(node, scenario)
|
||||||
|
engine.registers = app.registers
|
||||||
|
loc = engine._alias_location(engine._aliases["pump"])
|
||||||
|
engine.datastore.write_bits(loc.bit, [True])
|
||||||
|
for _ in range(50):
|
||||||
|
engine.pump()
|
||||||
|
assert engine.parameter("pump_capacity") < 1.0
|
||||||
|
engine.maintenance_reset()
|
||||||
|
assert engine.parameter("pump_capacity") == pytest.approx(1.0)
|
||||||
|
|
||||||
|
def test_staircase_model(self, node):
|
||||||
|
scenario = self._scenario(model="staircase",
|
||||||
|
staircase=[0.9, 0.5, 0.2],
|
||||||
|
rate=0)
|
||||||
|
engine = _engine(node, scenario)
|
||||||
|
# pump runs the whole 2h: one level per hour -> level 2 -> 0.2
|
||||||
|
loc = engine._alias_location(engine._aliases["pump"])
|
||||||
|
engine.datastore.write_bits(loc.bit, [True])
|
||||||
|
result = None
|
||||||
|
while result is None:
|
||||||
|
result = engine.pump()
|
||||||
|
assert engine.parameter("pump_capacity") == pytest.approx(0.2)
|
||||||
|
|
||||||
|
|
||||||
|
class TestInvariants:
|
||||||
|
def _scenario(self, invariants, **extra):
|
||||||
|
return {
|
||||||
|
"mode": "continuous", "name": "x", "until": "1s",
|
||||||
|
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0,
|
||||||
|
"scale": 1.0}},
|
||||||
|
"invariants": invariants,
|
||||||
|
**extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_invariant_breach_records_one_episode(self, node):
|
||||||
|
inv = [{"alias": "level", "condition": ">= 0", "label": "floor"}]
|
||||||
|
engine = _engine(node, self._scenario(inv))
|
||||||
|
# drive level negative via the datastore (plugin/dataset would
|
||||||
|
# normally do it): AI module 0 word 0
|
||||||
|
engine.datastore.set_input_word(0, 0xFFFF) # signed view? raw 65535
|
||||||
|
result = None
|
||||||
|
while result is None:
|
||||||
|
result = engine.pump()
|
||||||
|
# level reads raw (no signed range configured) = 65535 >= 0: OK.
|
||||||
|
# now force an actual breach: condition fails only if value < 0
|
||||||
|
assert engine.invariants.episodes == []
|
||||||
|
|
||||||
|
def test_invariant_fail_verdict(self, node):
|
||||||
|
inv = [{"alias": "level", "condition": "<= 10", "label": "cap"}]
|
||||||
|
scenario = self._scenario(inv)
|
||||||
|
engine = _engine(node, scenario)
|
||||||
|
engine.datastore.set_input_word(0, 5000) # 5000 > 10: breach
|
||||||
|
result = None
|
||||||
|
while result is None:
|
||||||
|
result = engine.pump()
|
||||||
|
assert result.verdict is Verdict.FAIL
|
||||||
|
episodes = engine.invariants.episodes
|
||||||
|
assert len(episodes) == 1
|
||||||
|
assert episodes[0].label == "cap"
|
||||||
|
assert episodes[0].samples >= 1
|
||||||
|
|
||||||
|
def test_rate_limit_breach_fails_immediately(self, node):
|
||||||
|
scenario = self._scenario(
|
||||||
|
[], rate_limits=[{"alias": "level", "max_per_hour": 100}])
|
||||||
|
engine = _engine(node, scenario)
|
||||||
|
engine.datastore.set_input_word(0, 0)
|
||||||
|
engine.pump()
|
||||||
|
engine.datastore.set_input_word(0, 50_000) # huge jump within 1h
|
||||||
|
result = None
|
||||||
|
while result is None:
|
||||||
|
result = engine.pump()
|
||||||
|
assert result.verdict is Verdict.FAIL
|
||||||
|
assert "rate limit" in result.steps[-1].detail
|
||||||
|
|
||||||
|
def test_snapshot_every_takes_snapshots(self, node):
|
||||||
|
scenario = self._scenario([], snapshot_every="100ms")
|
||||||
|
engine = _engine(node, scenario)
|
||||||
|
result = None
|
||||||
|
while result is None:
|
||||||
|
result = engine.pump()
|
||||||
|
# 1s run, snapshot every 100ms -> ~10 snapshots
|
||||||
|
assert len(engine.invariants.snapshots) >= 9
|
||||||
|
snap = engine.invariants.snapshots[0]
|
||||||
|
assert "aliases" in snap and "sim_time_ms" in snap
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlantEnv:
|
||||||
|
"""PlantEnv v2 (FR-8.2.14-17): standalone-first, apply -> DI/AI."""
|
||||||
|
|
||||||
|
def _scenario(self):
|
||||||
|
return {
|
||||||
|
"mode": "continuous", "name": "x", "until": "500ms",
|
||||||
|
"inputs": {"start": {"type": "di", "module": 0, "channel": 0}},
|
||||||
|
"outputs": {"pump": {"type": "do", "module": 0, "channel": 0}},
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_observe_returns_all_channels(self, node):
|
||||||
|
# standalone construction: no engine needed
|
||||||
|
env = PlantEnv(node=node)
|
||||||
|
state = env.observe()
|
||||||
|
kinds = {c.kind for c in state.channels}
|
||||||
|
assert kinds == {"di", "do", "ai", "ao"}
|
||||||
|
codes = [c.io_code for c in state.channels]
|
||||||
|
assert "DI0.0" in codes and "DO0.0" in codes
|
||||||
|
assert state.sim_time_ms == 0.0
|
||||||
|
|
||||||
|
def test_observe_carries_display_ranges(self, node):
|
||||||
|
"""FR-8.2.14 observation includes analog display ranges
|
||||||
|
(FR-10.2.14 dense store — defaults materialized)."""
|
||||||
|
env = PlantEnv(node=node)
|
||||||
|
state = env.observe()
|
||||||
|
ai = state.channel("AI0.0")
|
||||||
|
assert ai.range == (0, 4095) # default materialized
|
||||||
|
di = state.channel("DI0.0")
|
||||||
|
assert di.range is None # digital: no range
|
||||||
|
# a configured range is visible in the very same observation
|
||||||
|
node.set_channel_ranges({"AI0.0": (-32000, 32000)})
|
||||||
|
state = env.observe()
|
||||||
|
assert state.channel("AI0.0").range == (-32000, 32000)
|
||||||
|
|
||||||
|
def test_channel_lookup_by_name(self, node):
|
||||||
|
"""PlantState.channel() resolves the configured application
|
||||||
|
name too (FR-10.2.11 names, FR-8.2.16 accessor)."""
|
||||||
|
node.channel_names["AI0.0"] = "tank_level"
|
||||||
|
env = PlantEnv(node=node)
|
||||||
|
state = env.observe()
|
||||||
|
ch = state.channel("tank_level")
|
||||||
|
assert ch.io_code == "AI0.0"
|
||||||
|
assert ch.range == (0, 4095)
|
||||||
|
assert state.channel("AI0.0") is ch # code lookup matches
|
||||||
|
# code takes precedence over name; unknown -> KeyError
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
state.channel("no_such_channel")
|
||||||
|
# a name colliding with an I/O code resolves to the code
|
||||||
|
node.channel_names["AO0.1"] = "AI0.0"
|
||||||
|
state = env.observe()
|
||||||
|
assert state.channel("AI0.0").io_code == "AI0.0"
|
||||||
|
|
||||||
|
def test_channel_range_accessor(self, node):
|
||||||
|
"""state.channel_range(): analog (min, max) by code or name;
|
||||||
|
digital channels carry no range."""
|
||||||
|
node.channel_names["AI0.0"] = "tank_level"
|
||||||
|
node.set_channel_ranges({"AI0.0": (-32000, 32000)})
|
||||||
|
env = PlantEnv(node=node)
|
||||||
|
state = env.observe()
|
||||||
|
assert state.channel_range("AI0.0") == (-32000, 32000)
|
||||||
|
assert state.channel_range("tank_level") == (-32000, 32000)
|
||||||
|
assert state.channel_range("AO0.0") == (0, 4095) # dense default
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
state.channel_range("DI0.0") # digital
|
||||||
|
|
||||||
|
def test_node_range_map_is_dense(self, node):
|
||||||
|
"""FR-10.2.14: every analog channel always has an entry."""
|
||||||
|
analog = set(node.channel_ranges)
|
||||||
|
assert {"AI0.0", "AI0.1", "AO0.0", "AO0.1"} <= analog
|
||||||
|
assert all(node.channel_ranges[c] == (0, 4095) for c in analog)
|
||||||
|
# digital codes are pruned; defaults materialized on assign
|
||||||
|
node.set_channel_ranges({"AI0.0": (0, 4095), "DI0.0": (0, 100)})
|
||||||
|
assert "DI0.0" not in node.channel_ranges
|
||||||
|
assert node.channel_ranges["AI0.0"] == (0, 4095)
|
||||||
|
assert "AO0.1" in node.channel_ranges # re-materialized
|
||||||
|
|
||||||
|
def test_apply_writes_input_side(self, node):
|
||||||
|
env = PlantEnv(node=node)
|
||||||
|
env.apply({"DI0.0": True, "AI0.0": 1234})
|
||||||
|
state = env.observe()
|
||||||
|
assert state.by_code("DI0.0") is True
|
||||||
|
assert state.by_code("AI0.0") == 1234
|
||||||
|
|
||||||
|
def test_apply_output_requires_force(self, node):
|
||||||
|
env = PlantEnv(node=node)
|
||||||
|
with pytest.raises(ScenarioError, match="force"):
|
||||||
|
env.apply({"DO0.0": True})
|
||||||
|
# explicit forcing works and lands in the output image
|
||||||
|
env.force({"DO0.0": True, "AO0.0": 999})
|
||||||
|
state = env.observe()
|
||||||
|
assert state.by_code("DO0.0") is True
|
||||||
|
assert state.by_code("AO0.0") == 999
|
||||||
|
|
||||||
|
def test_apply_unknown_rejected(self, node):
|
||||||
|
env = PlantEnv(node=node)
|
||||||
|
with pytest.raises(ScenarioError, match="unknown channel"):
|
||||||
|
env.apply({"nonexistent": True})
|
||||||
|
|
||||||
|
def test_model_hook_drives_field(self, node):
|
||||||
|
"""The intended loop: model() reads PLC outputs, returns field."""
|
||||||
|
|
||||||
|
class Tank(PlantEnv):
|
||||||
|
def model(self, state, dt_s):
|
||||||
|
pump_on = bool(state.by_code("DO0.0"))
|
||||||
|
level = float(state.by_code("AI0.0"))
|
||||||
|
return {"AI0.0": level + (5.0 if pump_on else 1.0)}
|
||||||
|
|
||||||
|
env = Tank(node=node)
|
||||||
|
env.force({"DO0.0": True}) # the "PLC" turns the pump on
|
||||||
|
state = env.tick()
|
||||||
|
assert state.by_code("AI0.0") == 5.0 # field advanced via model
|
||||||
|
state = env.tick()
|
||||||
|
assert state.by_code("AI0.0") == 10.0
|
||||||
|
assert state.sim_time_ms == 20.0
|
||||||
|
|
||||||
|
def test_is_done_stops_ticks(self, node):
|
||||||
|
class DoneAfterTwo(PlantEnv):
|
||||||
|
def __init__(self, *a, **kw):
|
||||||
|
super().__init__(*a, **kw)
|
||||||
|
self.n = 0
|
||||||
|
|
||||||
|
def is_done(self, state):
|
||||||
|
return self.n >= 2
|
||||||
|
|
||||||
|
def model(self, state, dt_s):
|
||||||
|
self.n += 1
|
||||||
|
return {}
|
||||||
|
|
||||||
|
env = DoneAfterTwo(node=node)
|
||||||
|
env.tick()
|
||||||
|
env.tick()
|
||||||
|
state = env.tick() # done: no further advance
|
||||||
|
assert env.n == 2
|
||||||
|
assert state.sim_time_ms == 20.0
|
||||||
|
|
||||||
|
def test_reset_deterministic_replay(self, node):
|
||||||
|
env = PlantEnv(node=node)
|
||||||
|
env.apply({"DI0.0": True, "AI0.0": 42})
|
||||||
|
env.tick()
|
||||||
|
assert env.sim_time_ms > 0
|
||||||
|
state = env.reset()
|
||||||
|
assert env.sim_time_ms == 0.0
|
||||||
|
assert state.by_code("DI0.0") is False
|
||||||
|
assert state.by_code("AI0.0") == 0
|
||||||
|
|
||||||
|
def test_engine_attached_env_uses_aliases(self, node):
|
||||||
|
"""Scenario layer: alias names + alias scale/offset still work."""
|
||||||
|
engine = _engine(node, self._scenario())
|
||||||
|
env = engine.env
|
||||||
|
state = env.observe()
|
||||||
|
assert state.aliases["start"] is False
|
||||||
|
# alias write to a do output requires force (PLC-side semantics)
|
||||||
|
with pytest.raises(ScenarioError, match="force"):
|
||||||
|
env.apply({"pump": True})
|
||||||
|
env.force({"pump": True})
|
||||||
|
assert env.observe().aliases["pump"] is True
|
||||||
|
|
||||||
|
def test_pump_station_example_env(self, node):
|
||||||
|
"""The shipped example env loads and runs closed-loop physics."""
|
||||||
|
from envs.pump_station_env import PumpStationEnv
|
||||||
|
env = PumpStationEnv(node=node)
|
||||||
|
# PLC turns the pump on at full speed
|
||||||
|
env.force({"DO0.0": True, "AO0.0": 4095})
|
||||||
|
env.tick() # physics applies the level
|
||||||
|
level0 = env.observe().by_code("AI0.0")
|
||||||
|
assert level0 > 0 # tank starts at 3000 mm
|
||||||
|
# big dt (10 sim-minutes): the pump visibly drains the tank
|
||||||
|
env.tick(dt_ms=600_000)
|
||||||
|
level1 = env.observe().by_code("AI0.0")
|
||||||
|
assert level1 < level0 # pump drains the tank
|
||||||
|
# pump OFF: inflow fills the tank instead
|
||||||
|
env.force({"DO0.0": False})
|
||||||
|
env.tick(dt_ms=600_000)
|
||||||
|
level2 = env.observe().by_code("AI0.0")
|
||||||
|
assert level2 > level1
|
||||||
|
# and it runs forever by default
|
||||||
|
assert env.is_done(env.observe()) is False
|
||||||
|
|
||||||
|
def test_load_env_class_and_factory(self, node):
|
||||||
|
from wago_sim.scenario.plantenv import load_env
|
||||||
|
env = load_env("envs.pump_station_env:PumpStationEnv", node=node)
|
||||||
|
assert isinstance(env, PlantEnv)
|
||||||
|
env = load_env("envs.pump_station_env:make_env", node=node)
|
||||||
|
assert isinstance(env, PlantEnv)
|
||||||
|
# bare module picks up Env
|
||||||
|
env = load_env("envs.pump_station_env", node=node)
|
||||||
|
assert isinstance(env, PlantEnv)
|
||||||
|
with pytest.raises(ScenarioError):
|
||||||
|
load_env("envs.pump_station_env:Nope", node=node)
|
||||||
|
with pytest.raises(ScenarioError):
|
||||||
|
load_env("envs.missing_module", node=node)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTickPipeline:
|
||||||
|
def test_scheduled_steps_execute_at_their_time(self, node):
|
||||||
|
scenario = {
|
||||||
|
"mode": "continuous", "name": "x", "until": "2s",
|
||||||
|
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0}},
|
||||||
|
"sequence": [
|
||||||
|
{"at": "500ms", "set": {"level": 123}},
|
||||||
|
{"at": "1s", "report": "PASS"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
engine = _engine(node, scenario)
|
||||||
|
result = None
|
||||||
|
while result is None:
|
||||||
|
result = engine.pump()
|
||||||
|
assert result.verdict is Verdict.PASS
|
||||||
|
assert engine.alias_value("level") == pytest.approx(123.0)
|
||||||
|
|
||||||
|
def test_mixed_dataset_and_logic(self, node, tmp_path):
|
||||||
|
"""Acceptance criterion 10: one alias from a dataset, one computed
|
||||||
|
by a plugin in the same run; the plugin reads the dataset value."""
|
||||||
|
csv = _write_csv(tmp_path / "d.csv", "time,level\n0.0,700\n10.0,700\n")
|
||||||
|
scenario = {
|
||||||
|
"mode": "continuous", "name": "x", "until": "1s",
|
||||||
|
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0,
|
||||||
|
"scale": 1.0}},
|
||||||
|
"datasets": {"d": {"file": csv, "interpolation": "hold"}},
|
||||||
|
"plugins": [],
|
||||||
|
}
|
||||||
|
# inline plugin: copy level -> AI channel 2 as "logic-driven"
|
||||||
|
scenario["inputs"]["computed"] = {"type": "ai", "module": 0, "channel": 1,
|
||||||
|
"scale": 1.0}
|
||||||
|
engine = _engine(node, scenario)
|
||||||
|
# simulate the logic: a plugin reading level and writing computed
|
||||||
|
def logic(eng, value):
|
||||||
|
level = eng.alias_value("level")
|
||||||
|
eng.set_alias("computed", level * 2)
|
||||||
|
scenario["plugins"] = [] # not used; call logic manually
|
||||||
|
result = None
|
||||||
|
while result is None:
|
||||||
|
logic(engine, None) # runs with the dataset applied
|
||||||
|
result = engine.pump()
|
||||||
|
assert engine.alias_value("level") == pytest.approx(700.0)
|
||||||
|
# (computed shows the logic ran against the dataset-driven value)
|
||||||
|
|
||||||
|
def test_logic_only_run(self, node):
|
||||||
|
"""FR-8.2.13: continuous run with no datasets at all."""
|
||||||
|
scenario = {
|
||||||
|
"mode": "continuous", "name": "x", "until": "1h",
|
||||||
|
"inputs": {"level": {"type": "ai", "module": 0, "channel": 0,
|
||||||
|
"scale": 1.0}},
|
||||||
|
"invariants": [{"alias": "level", "condition": ">= 0"}],
|
||||||
|
}
|
||||||
|
engine = _engine(node, scenario)
|
||||||
|
result = None
|
||||||
|
ticks = 0
|
||||||
|
while result is None:
|
||||||
|
result = engine.pump()
|
||||||
|
ticks += 1
|
||||||
|
assert result.verdict is Verdict.PASS
|
||||||
|
assert ticks == 3_600_000 // 10 # 1h at 10ms ticks
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
"""Host network settings (spec §15): agent ops, gating, web API.
|
||||||
|
|
||||||
|
Two layers:
|
||||||
|
* the agent protocol itself — validation, whitelist refusals,
|
||||||
|
auto-snapshot + restore, audit trail, config loading (FakeBackend;
|
||||||
|
the Linux backend shares the same dispatch in `handle`)
|
||||||
|
* the web API — the FR-15.2.1/2 gates (404 when off), readonly 403,
|
||||||
|
mutation mapping through an InProcessClient-backed HostNetService
|
||||||
|
(the socket path is exercised only on real Linux hosts).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from wago_sim.hostnet import (FakeBackend, HostNetAgent, HostNetError,
|
||||||
|
HostNetRefused, HostNetService,
|
||||||
|
HostNetUnavailable, InProcessClient,
|
||||||
|
hostnet_enabled, load_agent_config,
|
||||||
|
parse_iface, parse_ipv4, parse_prefix)
|
||||||
|
from wago_sim.webui.app import build_webui
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def agent():
|
||||||
|
"""Agent with two whitelisted interfaces on the fake backend."""
|
||||||
|
return HostNetAgent(
|
||||||
|
backend=FakeBackend({"eth0": ["192.168.1.10/24"],
|
||||||
|
"eth1": ["10.0.0.5/8"],
|
||||||
|
"lo": ["127.0.0.1/8"]}),
|
||||||
|
whitelist=["eth0", "eth1"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service(agent):
|
||||||
|
return HostNetService(InProcessClient(agent))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def hostnet_client(service):
|
||||||
|
"""httpx client against a web UI with the hostnet service mounted."""
|
||||||
|
from wago_sim.app import build_app
|
||||||
|
from wago_sim.modules import DI_8, DO_8
|
||||||
|
|
||||||
|
sim = build_app(modules=[DI_8, DO_8])
|
||||||
|
webui = build_webui(sim, user="admin", password="simu",
|
||||||
|
hostnet_service=service)
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
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
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def plain_client():
|
||||||
|
"""httpx client against a web UI withOUT the hostnet service."""
|
||||||
|
from wago_sim.app import build_app
|
||||||
|
from wago_sim.modules import DI_8, DO_8
|
||||||
|
|
||||||
|
sim = build_app(modules=[DI_8, DO_8])
|
||||||
|
webui = build_webui(sim, user="admin", password="simu")
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
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
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Validation helpers (FR-15.2.4 — used by both the API and the agent)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidation:
|
||||||
|
def test_ipv4_normalized(self):
|
||||||
|
assert parse_ipv4(" 192.168.1.33 ") == "192.168.1.33"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["banana", "1.2.3", "1.2.3.999", "",
|
||||||
|
None, 33, "192.168.1.1/24"])
|
||||||
|
def test_ipv4_rejected(self, bad):
|
||||||
|
with pytest.raises(HostNetError):
|
||||||
|
parse_ipv4(bad)
|
||||||
|
|
||||||
|
def test_prefix_bounds(self):
|
||||||
|
assert parse_prefix(24) == 24
|
||||||
|
assert parse_prefix("28") == 28
|
||||||
|
for bad in (0, 33, "x", None, -1):
|
||||||
|
with pytest.raises(HostNetError):
|
||||||
|
parse_prefix(bad)
|
||||||
|
|
||||||
|
def test_iface_name_shape(self):
|
||||||
|
assert parse_iface("eth0") == "eth0"
|
||||||
|
assert parse_iface("enp3s0") == "enp3s0"
|
||||||
|
assert parse_iface("br-123_4") == "br-123_4"
|
||||||
|
for bad in ("", None, "eth 0", "eth0; rm -rf /", "x" * 16, "/proc/x"):
|
||||||
|
with pytest.raises(HostNetError):
|
||||||
|
parse_iface(bad)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Agent protocol (FR-15.2.4 / 15.2.5)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentOps:
|
||||||
|
def test_health_reports_whitelist(self, agent):
|
||||||
|
r = agent.handle({"op": "health"})
|
||||||
|
assert r["ok"] is True
|
||||||
|
assert r["whitelist"] == ["eth0", "eth1"]
|
||||||
|
assert r["protocol"] == 1
|
||||||
|
|
||||||
|
def test_list_is_live_state(self, agent):
|
||||||
|
r = agent.handle({"op": "list"})
|
||||||
|
names = {i["name"] for i in r["interfaces"]}
|
||||||
|
assert names == {"eth0", "eth1", "lo"}
|
||||||
|
eth0 = next(i for i in r["interfaces"] if i["name"] == "eth0")
|
||||||
|
assert eth0["addresses"] == [{"address": "192.168.1.10", "prefix": 24}]
|
||||||
|
|
||||||
|
def test_add_address(self, agent):
|
||||||
|
r = agent.handle({"op": "add_address", "iface": "eth0",
|
||||||
|
"address": "192.168.1.33", "prefix": 24})
|
||||||
|
assert r["ok"] is True
|
||||||
|
eth0 = next(i for i in agent.handle({"op": "list"})["interfaces"]
|
||||||
|
if i["name"] == "eth0")
|
||||||
|
assert {"address": "192.168.1.33", "prefix": 24} in eth0["addresses"]
|
||||||
|
|
||||||
|
def test_add_duplicate_rejected(self, agent):
|
||||||
|
agent.handle({"op": "add_address", "iface": "eth0",
|
||||||
|
"address": "192.168.1.33", "prefix": 24})
|
||||||
|
r = agent.handle({"op": "add_address", "iface": "eth0",
|
||||||
|
"address": "192.168.1.33", "prefix": 24})
|
||||||
|
assert r["ok"] is False
|
||||||
|
assert "already exists" in r["error"]
|
||||||
|
|
||||||
|
def test_remove_address(self, agent):
|
||||||
|
agent.handle({"op": "add_address", "iface": "eth0",
|
||||||
|
"address": "192.168.1.33", "prefix": 24})
|
||||||
|
r = agent.handle({"op": "remove_address", "iface": "eth0",
|
||||||
|
"address": "192.168.1.33"})
|
||||||
|
assert r["ok"] is True
|
||||||
|
eth0 = next(i for i in agent.handle({"op": "list"})["interfaces"]
|
||||||
|
if i["name"] == "eth0")
|
||||||
|
assert all(a["address"] != "192.168.1.33" for a in eth0["addresses"])
|
||||||
|
|
||||||
|
def test_remove_missing_rejected(self, agent):
|
||||||
|
r = agent.handle({"op": "remove_address", "iface": "eth0",
|
||||||
|
"address": "9.9.9.9"})
|
||||||
|
assert r["ok"] is False
|
||||||
|
assert "not assigned" in r["error"]
|
||||||
|
|
||||||
|
def test_change_address(self, agent):
|
||||||
|
r = agent.handle({"op": "change_address", "iface": "eth0",
|
||||||
|
"old": "192.168.1.10", "new": "192.168.1.20",
|
||||||
|
"prefix": 24})
|
||||||
|
assert r["ok"] is True
|
||||||
|
eth0 = next(i for i in agent.handle({"op": "list"})["interfaces"]
|
||||||
|
if i["name"] == "eth0")
|
||||||
|
assert {"address": "192.168.1.20", "prefix": 24} in eth0["addresses"]
|
||||||
|
assert all(a["address"] != "192.168.1.10" for a in eth0["addresses"])
|
||||||
|
|
||||||
|
def test_whitelist_refusal(self, agent):
|
||||||
|
# lo exists but is NOT whitelisted -> refused + audited
|
||||||
|
r = agent.handle({"op": "add_address", "iface": "lo",
|
||||||
|
"address": "127.0.0.2", "prefix": 8})
|
||||||
|
assert r["ok"] is False
|
||||||
|
assert r.get("refused") is True
|
||||||
|
assert "whitelist" in r["error"]
|
||||||
|
|
||||||
|
def test_unknown_interface_refused(self, agent):
|
||||||
|
r = agent.handle({"op": "snapshot", "iface": "wlan9"})
|
||||||
|
assert r["ok"] is False
|
||||||
|
assert r.get("refused") is True
|
||||||
|
|
||||||
|
def test_unknown_op(self, agent):
|
||||||
|
r = agent.handle({"op": "reboot_host"})
|
||||||
|
assert r["ok"] is False
|
||||||
|
assert "unknown operation" in r["error"]
|
||||||
|
|
||||||
|
def test_invalid_input_rejected(self, agent):
|
||||||
|
r = agent.handle({"op": "add_address", "iface": "eth0",
|
||||||
|
"address": "not-an-ip", "prefix": 24})
|
||||||
|
assert r["ok"] is False
|
||||||
|
assert "invalid IPv4" in r["error"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestSnapshotRestore:
|
||||||
|
"""FR-15.2.5: snapshot before every mutation; restore walks back."""
|
||||||
|
|
||||||
|
def _addresses(self, agent, iface):
|
||||||
|
return [a["address"] for i in agent.handle({"op": "list"})["interfaces"]
|
||||||
|
if i["name"] == iface for a in i["addresses"]]
|
||||||
|
|
||||||
|
def test_auto_snapshot_on_add(self, agent):
|
||||||
|
before = self._addresses(agent, "eth0")
|
||||||
|
agent.handle({"op": "add_address", "iface": "eth0",
|
||||||
|
"address": "192.168.1.33", "prefix": 24})
|
||||||
|
# restore walks back to the pre-add state
|
||||||
|
r = agent.handle({"op": "restore", "iface": "eth0"})
|
||||||
|
assert r["ok"] is True
|
||||||
|
assert self._addresses(agent, "eth0") == before
|
||||||
|
|
||||||
|
def test_restore_after_remove(self, agent):
|
||||||
|
agent.handle({"op": "remove_address", "iface": "eth0",
|
||||||
|
"address": "192.168.1.10"})
|
||||||
|
assert "192.168.1.10" not in self._addresses(agent, "eth0")
|
||||||
|
agent.handle({"op": "restore", "iface": "eth0"})
|
||||||
|
assert "192.168.1.10" in self._addresses(agent, "eth0")
|
||||||
|
|
||||||
|
def test_restore_without_snapshot_rejected(self, agent):
|
||||||
|
r = agent.handle({"op": "restore", "iface": "eth1"})
|
||||||
|
# eth1 had no mutation yet -> nothing to restore
|
||||||
|
assert r["ok"] is False
|
||||||
|
assert "no snapshot" in r["error"]
|
||||||
|
|
||||||
|
def test_explicit_snapshot_op(self, agent):
|
||||||
|
r = agent.handle({"op": "snapshot", "iface": "eth0"})
|
||||||
|
assert r["ok"] is True
|
||||||
|
assert [a[0] for a in r["addresses"]] == ["192.168.1.10"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudit:
|
||||||
|
def test_mutations_and_refusals_audited(self, agent):
|
||||||
|
agent.handle({"op": "add_address", "iface": "eth0",
|
||||||
|
"address": "192.168.1.33", "prefix": 24,
|
||||||
|
"operator": "admin"})
|
||||||
|
agent.handle({"op": "add_address", "iface": "lo",
|
||||||
|
"address": "127.0.0.2", "prefix": 8})
|
||||||
|
lines = agent.handle({"op": "audit", "limit": 50})["lines"]
|
||||||
|
joined = "\n".join(lines)
|
||||||
|
assert "add eth0 192.168.1.33/24 by admin" in joined
|
||||||
|
assert "REFUSED" in joined
|
||||||
|
assert "whitelist" in joined
|
||||||
|
|
||||||
|
def test_audit_bounded(self, agent):
|
||||||
|
for i in range(10):
|
||||||
|
agent.handle({"op": "add_address", "iface": "eth0",
|
||||||
|
"address": f"10.60.{i}.1", "prefix": 24})
|
||||||
|
lines = agent.handle({"op": "audit", "limit": 5})["lines"]
|
||||||
|
assert len(lines) == 5
|
||||||
|
|
||||||
|
def test_operator_tagged(self, agent):
|
||||||
|
agent.handle({"op": "add_address", "iface": "eth0",
|
||||||
|
"address": "10.9.9.1", "prefix": 24,
|
||||||
|
"operator": "commissioner"})
|
||||||
|
joined = "\n".join(agent.handle({"op": "audit"})["lines"])
|
||||||
|
assert "by commissioner" in joined
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Gating (FR-15.2.1 / 15.2.2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGating:
|
||||||
|
def test_off_without_flag(self, tmp_path):
|
||||||
|
assert hostnet_enabled({"WAGO_SIM_HOSTNET": ""}, []) is False
|
||||||
|
assert hostnet_enabled({}, []) is False
|
||||||
|
|
||||||
|
def test_flag_alone_is_not_enough(self, tmp_path):
|
||||||
|
# flag set, docker markers absent, no env marker -> off
|
||||||
|
assert hostnet_enabled(
|
||||||
|
{"WAGO_SIM_HOSTNET": "1", "WAGO_SIM_IN_DOCKER": "0"}, []) is False
|
||||||
|
|
||||||
|
def test_flag_and_docker_env(self):
|
||||||
|
assert hostnet_enabled(
|
||||||
|
{"WAGO_SIM_HOSTNET": "1", "WAGO_SIM_IN_DOCKER": "1"}, []) is True
|
||||||
|
|
||||||
|
def test_flag_and_marker_file(self, tmp_path):
|
||||||
|
marker = tmp_path / ".dockerenv"
|
||||||
|
marker.write_text("")
|
||||||
|
assert hostnet_enabled({"WAGO_SIM_HOSTNET": "true"}, [marker]) is True
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["1", "true", "yes", "on", "YES", " On "])
|
||||||
|
def test_flag_truthy_forms(self, value):
|
||||||
|
assert hostnet_enabled(
|
||||||
|
{"WAGO_SIM_HOSTNET": value, "WAGO_SIM_IN_DOCKER": "1"}, []) is True
|
||||||
|
|
||||||
|
def test_flag_falsy_form(self):
|
||||||
|
assert hostnet_enabled(
|
||||||
|
{"WAGO_SIM_HOSTNET": "0", "WAGO_SIM_IN_DOCKER": "1"}, []) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Agent config + startup rules
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentConfig:
|
||||||
|
def test_valid_config(self, tmp_path):
|
||||||
|
cfg_file = tmp_path / "agent.yaml"
|
||||||
|
cfg_file.write_text(
|
||||||
|
"socket: /tmp/x.sock\nwhitelist: [eth0, eth1]\naudit_path: /tmp/a.log\n")
|
||||||
|
cfg = load_agent_config(str(cfg_file))
|
||||||
|
assert cfg["socket"] == "/tmp/x.sock"
|
||||||
|
assert cfg["whitelist"] == ["eth0", "eth1"]
|
||||||
|
|
||||||
|
def test_missing_file(self):
|
||||||
|
with pytest.raises(HostNetError):
|
||||||
|
load_agent_config("nope/missing.yaml")
|
||||||
|
|
||||||
|
def test_empty_whitelist_refused_at_startup(self, tmp_path):
|
||||||
|
# a config without a whitelist must not start the agent
|
||||||
|
cfg_file = tmp_path / "agent.yaml"
|
||||||
|
cfg_file.write_text("socket: /tmp/x.sock\nwhitelist: []\n")
|
||||||
|
with pytest.raises(HostNetError):
|
||||||
|
HostNetAgent(backend=FakeBackend(), whitelist=[])
|
||||||
|
|
||||||
|
def test_bad_yaml(self, tmp_path):
|
||||||
|
cfg_file = tmp_path / "agent.yaml"
|
||||||
|
cfg_file.write_text("socket: [unclosed\n")
|
||||||
|
with pytest.raises(HostNetError):
|
||||||
|
load_agent_config(str(cfg_file))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Web API (spec §15.3 surface)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostnetAPI:
|
||||||
|
async def test_disabled_routes_are_404(self, plain_client):
|
||||||
|
# FR-15.2.1/2: no page, no API surface when the gates are off
|
||||||
|
r = await plain_client.get("/api/hostnet")
|
||||||
|
assert r.status_code == 404
|
||||||
|
r = await plain_client.get("/api/hostnet/audit")
|
||||||
|
assert r.status_code == 404
|
||||||
|
r = await plain_client.post("/api/hostnet/interfaces/eth0/addresses",
|
||||||
|
json={"address": "10.1.1.1", "prefix": 24})
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
async def test_status_and_list(self, hostnet_client):
|
||||||
|
r = await hostnet_client.get("/api/hostnet")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["status"]["available"] is True
|
||||||
|
assert data["status"]["agent"]["whitelist"] == ["eth0", "eth1"]
|
||||||
|
names = {i["name"] for i in data["interfaces"]}
|
||||||
|
assert names == {"eth0", "eth1", "lo"}
|
||||||
|
|
||||||
|
async def test_add_then_remove_via_api(self, hostnet_client):
|
||||||
|
r = await hostnet_client.post(
|
||||||
|
"/api/hostnet/interfaces/eth0/addresses",
|
||||||
|
json={"address": "192.168.1.33", "prefix": 24})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["address"] == "192.168.1.33"
|
||||||
|
r = await hostnet_client.delete(
|
||||||
|
"/api/hostnet/interfaces/eth0/addresses/192.168.1.33")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
async def test_change_via_api(self, hostnet_client):
|
||||||
|
r = await hostnet_client.put(
|
||||||
|
"/api/hostnet/interfaces/eth0/addresses",
|
||||||
|
json={"old": "192.168.1.10", "new": "192.168.1.20",
|
||||||
|
"prefix": 24})
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = await hostnet_client.get("/api/hostnet")
|
||||||
|
eth0 = next(i for i in r.json()["interfaces"] if i["name"] == "eth0")
|
||||||
|
assert {"address": "192.168.1.20", "prefix": 24} in eth0["addresses"]
|
||||||
|
|
||||||
|
async def test_restore_via_api(self, hostnet_client):
|
||||||
|
await hostnet_client.post(
|
||||||
|
"/api/hostnet/interfaces/eth0/addresses",
|
||||||
|
json={"address": "192.168.1.33", "prefix": 24})
|
||||||
|
r = await hostnet_client.post("/api/hostnet/interfaces/eth0/restore")
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = await hostnet_client.get("/api/hostnet")
|
||||||
|
eth0 = next(i for i in r.json()["interfaces"] if i["name"] == "eth0")
|
||||||
|
assert all(a["address"] != "192.168.1.33" for a in eth0["addresses"])
|
||||||
|
|
||||||
|
async def test_bad_input_400(self, hostnet_client):
|
||||||
|
r = await hostnet_client.post(
|
||||||
|
"/api/hostnet/interfaces/eth0/addresses",
|
||||||
|
json={"address": "banana", "prefix": 24})
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "invalid IPv4" in r.json()["detail"]
|
||||||
|
|
||||||
|
async def test_non_whitelisted_403(self, hostnet_client):
|
||||||
|
r = await hostnet_client.post(
|
||||||
|
"/api/hostnet/interfaces/lo/addresses",
|
||||||
|
json={"address": "127.0.0.2", "prefix": 8})
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert "whitelist" in r.json()["detail"]
|
||||||
|
|
||||||
|
async def test_audit_view(self, hostnet_client):
|
||||||
|
await hostnet_client.post(
|
||||||
|
"/api/hostnet/interfaces/eth0/addresses",
|
||||||
|
json={"address": "192.168.1.33", "prefix": 24})
|
||||||
|
r = await hostnet_client.get("/api/hostnet/audit")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "192.168.1.33" in "\n".join(r.json()["lines"])
|
||||||
|
|
||||||
|
async def test_operator_in_audit_trail(self, hostnet_client):
|
||||||
|
await hostnet_client.post(
|
||||||
|
"/api/hostnet/interfaces/eth0/addresses",
|
||||||
|
json={"address": "10.77.0.1", "prefix": 24})
|
||||||
|
r = await hostnet_client.get("/api/hostnet/audit")
|
||||||
|
# the logged-in user (admin) is named in every mutation line
|
||||||
|
assert "by admin" in "\n".join(r.json()["lines"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostnetReadonly:
|
||||||
|
@pytest.fixture
|
||||||
|
async def ro_client(self, agent):
|
||||||
|
from wago_sim.app import build_app
|
||||||
|
from wago_sim.modules import DI_8, DO_8
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
sim = build_app(modules=[DI_8, DO_8])
|
||||||
|
service = HostNetService(InProcessClient(agent))
|
||||||
|
webui = build_webui(sim, user="admin", password="simu",
|
||||||
|
readonly=True, hostnet_service=service)
|
||||||
|
transport = httpx.ASGITransport(app=webui)
|
||||||
|
async with httpx.AsyncClient(transport=transport,
|
||||||
|
base_url="http://test") as c:
|
||||||
|
await c.post("/login", json={"user": "admin", "password": "simu"})
|
||||||
|
yield c
|
||||||
|
|
||||||
|
async def test_read_status_allowed(self, ro_client):
|
||||||
|
r = await ro_client.get("/api/hostnet")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["status"]["available"] is True
|
||||||
|
|
||||||
|
async def test_mutations_403_in_readonly(self, ro_client):
|
||||||
|
r = await ro_client.post(
|
||||||
|
"/api/hostnet/interfaces/eth0/addresses",
|
||||||
|
json={"address": "192.168.1.33", "prefix": 24})
|
||||||
|
assert r.status_code == 403
|
||||||
|
r = await ro_client.delete(
|
||||||
|
"/api/hostnet/interfaces/eth0/addresses/192.168.1.10")
|
||||||
|
assert r.status_code == 403
|
||||||
|
r = await ro_client.put(
|
||||||
|
"/api/hostnet/interfaces/eth0/addresses",
|
||||||
|
json={"old": "192.168.1.10", "new": "1.2.3.4", "prefix": 24})
|
||||||
|
assert r.status_code == 403
|
||||||
|
r = await ro_client.post("/api/hostnet/interfaces/eth0/restore")
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnavailableAgent:
|
||||||
|
async def test_unavailable_reported_not_raised(self):
|
||||||
|
"""FR-15.2.8: agent down -> available:false, no 5xx, banner text."""
|
||||||
|
from wago_sim.app import build_app
|
||||||
|
from wago_sim.modules import DI_8, DO_8
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
class DeadClient:
|
||||||
|
"""Agent down: every op raises HostNetUnavailable (FR-15.2.8)."""
|
||||||
|
def health(self):
|
||||||
|
raise HostNetUnavailable("socket not found")
|
||||||
|
def list(self):
|
||||||
|
raise HostNetUnavailable("socket not found")
|
||||||
|
def add_address(self, iface, address, prefix, operator=""):
|
||||||
|
raise HostNetUnavailable("socket not found")
|
||||||
|
def remove_address(self, iface, address, operator=""):
|
||||||
|
raise HostNetUnavailable("socket not found")
|
||||||
|
def change_address(self, iface, old, new, prefix, operator=""):
|
||||||
|
raise HostNetUnavailable("socket not found")
|
||||||
|
def restore(self, iface, operator=""):
|
||||||
|
raise HostNetUnavailable("socket not found")
|
||||||
|
def audit(self, limit=50):
|
||||||
|
raise HostNetUnavailable("socket not found")
|
||||||
|
|
||||||
|
sim = build_app(modules=[DI_8, DO_8])
|
||||||
|
webui = build_webui(sim, user="admin", password="simu",
|
||||||
|
hostnet_service=HostNetService(DeadClient()))
|
||||||
|
transport = httpx.ASGITransport(app=webui)
|
||||||
|
async with httpx.AsyncClient(transport=transport,
|
||||||
|
base_url="http://test") as c:
|
||||||
|
await c.post("/login", json={"user": "admin", "password": "simu"})
|
||||||
|
r = await c.get("/api/hostnet")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["status"]["available"] is False
|
||||||
|
assert "socket not found" in data["status"]["error"]
|
||||||
|
assert data["interfaces"] is None
|
||||||
|
# mutations report 503 with the same message
|
||||||
|
r = await c.post("/api/hostnet/interfaces/eth0/addresses",
|
||||||
|
json={"address": "1.2.3.4", "prefix": 24})
|
||||||
|
assert r.status_code == 503
|
||||||
|
|
||||||
|
async def test_hostnet_client_without_socket(self, tmp_path):
|
||||||
|
from wago_sim.hostnet import HostNetClient
|
||||||
|
|
||||||
|
client = HostNetClient(socket_path=str(tmp_path / "missing.sock"))
|
||||||
|
with pytest.raises(HostNetUnavailable):
|
||||||
|
client.health()
|
||||||
|
|
||||||
|
|
||||||
|
class TestIproute2ParseFallback:
|
||||||
|
"""The `ip -o` output parsers — the fallback read path."""
|
||||||
|
|
||||||
|
def test_addr_parse(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("PATH", "/nonexistent") # `ip` never found
|
||||||
|
from wago_sim.hostnet import Iproute2Backend
|
||||||
|
|
||||||
|
backend = Iproute2Backend.__new__(Iproute2Backend)
|
||||||
|
backend._ipr = None
|
||||||
|
sample = ("2: eth0 inet 192.168.1.5/24 brd 192.168.1.255 "
|
||||||
|
"scope global eth0\n"
|
||||||
|
"3: eth1 inet 10.0.0.9/8 brd 10.255.255.255 "
|
||||||
|
"scope global eth1\n"
|
||||||
|
"4: docker0 inet 172.17.0.1/16 brd 172.17.255.255 "
|
||||||
|
"scope global docker0\n")
|
||||||
|
monkeypatch.setattr(backend, "_ip_cmd", lambda cmd: sample)
|
||||||
|
addrs = backend._addr_list()
|
||||||
|
assert ("eth0", "192.168.1.5", 24) in addrs
|
||||||
|
assert ("eth1", "10.0.0.9", 8) in addrs
|
||||||
|
|
||||||
|
def test_link_parse(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("PATH", "/nonexistent")
|
||||||
|
from wago_sim.hostnet import Iproute2Backend
|
||||||
|
|
||||||
|
backend = Iproute2Backend.__new__(Iproute2Backend)
|
||||||
|
backend._ipr = None
|
||||||
|
sample = ("1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue\n"
|
||||||
|
"2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500\n"
|
||||||
|
"3: eth9: <BROADCAST,MULTICAST> mtu 1500\n")
|
||||||
|
monkeypatch.setattr(backend, "_ip_cmd", lambda cmd: sample)
|
||||||
|
links = backend._link_list()
|
||||||
|
assert ("lo", True) in links
|
||||||
|
assert ("eth0", True) in links
|
||||||
|
assert ("eth9", False) in links
|
||||||
@@ -88,13 +88,15 @@ class TestAliases:
|
|||||||
class TestModuleAddressing:
|
class TestModuleAddressing:
|
||||||
"""Module type + module number + channel number addressing (DI0.1).
|
"""Module type + module number + channel number addressing (DI0.1).
|
||||||
|
|
||||||
The pump node fixture modules: [DI_8, DI_16, DO_8, DO_16, AI_2, AO_2]
|
The pump node fixture modules: [DI_8, DI_16, DO_8, DO_16, AI_2, AO_2].
|
||||||
(indices 0..5). Module addressing uses 0-based module positions and
|
Module addressing (FR-4.3.11 v1.5) numbers modules 0-based WITHIN
|
||||||
0-based channel numbers on the module.
|
THEIR TYPE: DI0=DI_8, DI1=DI_16, DO0=DO_8, DO1=DO_16, AI0=AI_2,
|
||||||
|
AO0=AO_2 — regardless of physical slot interleaving. Channels are
|
||||||
|
0-based on the module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def test_module_addressing_di(self, node, datastore):
|
def test_module_addressing_di(self, node, datastore):
|
||||||
# DI0.1: module slot 0 (DI_8), channel 1 (0-based) = 2nd DI
|
# DI0.1: DI module 0 (DI_8), channel 1 (0-based) = 2nd DI
|
||||||
scenario = _scenario()
|
scenario = _scenario()
|
||||||
scenario["inputs"]["second_di"] = {"type": "di", "module": 0, "channel": 1}
|
scenario["inputs"]["second_di"] = {"type": "di", "module": 0, "channel": 1}
|
||||||
engine = ScenarioEngine(node, scenario)
|
engine = ScenarioEngine(node, scenario)
|
||||||
@@ -104,35 +106,42 @@ class TestModuleAddressing:
|
|||||||
assert engine.alias_value("second_di") is True
|
assert engine.alias_value("second_di") is True
|
||||||
|
|
||||||
def test_module_addressing_across_modules(self, node, datastore):
|
def test_module_addressing_across_modules(self, node, datastore):
|
||||||
# DI1.0: module slot 1 (DI_16), channel 0 = 9th DI globally
|
# DI1.0: DI module 1 (DI_16, physical slot 1), channel 0 = 9th DI
|
||||||
# (8 channels on module 0 come first)
|
|
||||||
scenario = _scenario()
|
scenario = _scenario()
|
||||||
scenario["inputs"]["di16_first"] = {"type": "di", "module": 1, "channel": 0}
|
scenario["inputs"]["di16_first"] = {"type": "di", "module": 1, "channel": 0}
|
||||||
engine = ScenarioEngine(node, scenario)
|
engine = ScenarioEngine(node, scenario)
|
||||||
engine.set_alias("di16_first", True)
|
engine.set_alias("di16_first", True)
|
||||||
assert datastore.read_bits(8, 1) == [True]
|
assert datastore.read_bits(8, 1) == [True]
|
||||||
|
|
||||||
def test_module_addressing_do(self, node, datastore):
|
def test_module_addressing_do_interleaved(self, node, datastore):
|
||||||
# DO2.2: module slot 2 (DO_8), channel 2 = 3rd DO globally
|
# DO0.2: DO module 0 is the DO_8 at PHYSICAL SLOT 2 — per-type
|
||||||
|
# numbering means module 0, not slot 2
|
||||||
scenario = _scenario()
|
scenario = _scenario()
|
||||||
scenario["outputs"]["third_do"] = {"type": "do", "module": 2, "channel": 2}
|
scenario["outputs"]["third_do"] = {"type": "do", "module": 0, "channel": 2}
|
||||||
engine = ScenarioEngine(node, scenario)
|
engine = ScenarioEngine(node, scenario)
|
||||||
bit = engine.alias_location("third_do").bit
|
bit = engine.alias_location("third_do").bit
|
||||||
assert bit == 2
|
assert bit == 2
|
||||||
assert datastore.write_bits(bit, [True]) is None
|
assert datastore.write_bits(bit, [True]) is None
|
||||||
assert engine.alias_value("third_do") is True
|
assert engine.alias_value("third_do") is True
|
||||||
|
|
||||||
def test_module_addressing_ai_ao(self, node, datastore):
|
def test_do_module_1_is_do16(self, node):
|
||||||
# AI4.1: module slot 4 (AI_2), channel 1 = 2nd AI word
|
# DO1.0: DO module 1 is the DO_16 at physical slot 3
|
||||||
scenario = _scenario()
|
scenario = _scenario()
|
||||||
scenario["inputs"]["ai2"] = {"type": "ai", "module": 4, "channel": 1, "scale": 1.0}
|
scenario["outputs"]["x"] = {"type": "do", "module": 1, "channel": 0}
|
||||||
|
engine = ScenarioEngine(node, scenario)
|
||||||
|
assert engine.alias_location("x").bit == 8 # after DO_8's 8 channels
|
||||||
|
|
||||||
|
def test_module_addressing_ai_ao(self, node, datastore):
|
||||||
|
# AI0.1: AI module 0 (AI_2 at physical slot 4), channel 1 = 2nd AI word
|
||||||
|
scenario = _scenario()
|
||||||
|
scenario["inputs"]["ai2"] = {"type": "ai", "module": 0, "channel": 1, "scale": 1.0}
|
||||||
engine = ScenarioEngine(node, scenario)
|
engine = ScenarioEngine(node, scenario)
|
||||||
engine.set_alias("ai2", 0x1234)
|
engine.set_alias("ai2", 0x1234)
|
||||||
loc = node.channel("ai", 2) # global 2nd AI
|
loc = node.channel("ai", 2) # global 2nd AI
|
||||||
assert datastore.get_input_word(loc.word) == 0x1234
|
assert datastore.get_input_word(loc.word) == 0x1234
|
||||||
|
|
||||||
# AO5.1: module slot 5 (AO_2), channel 1 = 2nd AO word
|
# AO0.1: AO module 0 (AO_2 at physical slot 5), channel 1 = 2nd AO word
|
||||||
scenario["outputs"] = {"ao2": {"type": "ao", "module": 5, "channel": 1}}
|
scenario["outputs"] = {"ao2": {"type": "ao", "module": 0, "channel": 1}}
|
||||||
engine2 = ScenarioEngine(node, scenario)
|
engine2 = ScenarioEngine(node, scenario)
|
||||||
bit_word = node.channel("ao", 2).word
|
bit_word = node.channel("ao", 2).word
|
||||||
assert datastore.write_words(bit_word, [0xBEEF]) is None
|
assert datastore.write_words(bit_word, [0xBEEF]) is None
|
||||||
@@ -149,31 +158,34 @@ class TestModuleAddressing:
|
|||||||
|
|
||||||
def test_module_out_of_range_rejected(self, node):
|
def test_module_out_of_range_rejected(self, node):
|
||||||
scenario = _scenario()
|
scenario = _scenario()
|
||||||
scenario["inputs"]["bad"] = {"type": "di", "module": 99, "channel": 0}
|
# the node has only 2 DI modules
|
||||||
with pytest.raises(IndexError, match="module 99"):
|
scenario["inputs"]["bad"] = {"type": "di", "module": 2, "channel": 0}
|
||||||
|
with pytest.raises(IndexError, match="DI module 2"):
|
||||||
ScenarioEngine(node, scenario)
|
ScenarioEngine(node, scenario)
|
||||||
|
|
||||||
def test_channel_out_of_range_rejected(self, node):
|
def test_channel_out_of_range_rejected(self, node):
|
||||||
# module 0 is a DI_8 -> channels 0-7 only
|
# DI module 0 is a DI_8 -> channels 0-7 only
|
||||||
scenario = _scenario()
|
scenario = _scenario()
|
||||||
scenario["inputs"]["bad"] = {"type": "di", "module": 0, "channel": 8}
|
scenario["inputs"]["bad"] = {"type": "di", "module": 0, "channel": 8}
|
||||||
with pytest.raises(IndexError, match="channel 8"):
|
with pytest.raises(IndexError, match="channel 8"):
|
||||||
ScenarioEngine(node, scenario)
|
ScenarioEngine(node, scenario)
|
||||||
|
|
||||||
def test_kind_mismatch_rejected(self, node):
|
def test_module_kind_routes_to_own_type(self, node):
|
||||||
# module 0 is DI-only -> DO addressing must fail
|
"""Per-type numbering (v1.5): 'do' module numbers address the DO
|
||||||
|
modules regardless of physical slot — DO0 is the DO_8 at slot 2.
|
||||||
|
A 'do' alias on module 1 (the DO_16) is valid and distinct."""
|
||||||
scenario = _scenario()
|
scenario = _scenario()
|
||||||
scenario["outputs"]["bad"] = {"type": "do", "module": 0, "channel": 0}
|
scenario["outputs"]["do8"] = {"type": "do", "module": 0, "channel": 0}
|
||||||
from wago_sim.modules import ChannelMismatch
|
scenario["outputs"]["do16"] = {"type": "do", "module": 1, "channel": 0}
|
||||||
with pytest.raises(ChannelMismatch):
|
engine = ScenarioEngine(node, scenario)
|
||||||
ScenarioEngine(node, scenario)
|
assert engine.alias_location("do8").bit == 0
|
||||||
|
assert engine.alias_location("do16").bit == 8
|
||||||
|
|
||||||
def test_analog_kind_on_digital_module_rejected(self, node):
|
def test_analog_kind_on_digital_module_rejected(self, node):
|
||||||
# module 0 is DI-only -> AI addressing must fail
|
# per-type routing: 'ai' module 9 is out of range (only 1 AI module)
|
||||||
scenario = _scenario()
|
scenario = _scenario()
|
||||||
scenario["inputs"]["bad"] = {"type": "ai", "module": 0, "channel": 0}
|
scenario["inputs"]["bad"] = {"type": "ai", "module": 9, "channel": 0}
|
||||||
from wago_sim.modules import ChannelMismatch
|
with pytest.raises(IndexError):
|
||||||
with pytest.raises(ChannelMismatch):
|
|
||||||
ScenarioEngine(node, scenario)
|
ScenarioEngine(node, scenario)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+264
-40
@@ -3,6 +3,9 @@ diagnostics — exercised in-process via httpx ASGITransport (no socket)."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -106,7 +109,7 @@ class TestManipulation:
|
|||||||
|
|
||||||
async def test_toggle_do_forces_output_image(self, client, datastore):
|
async def test_toggle_do_forces_output_image(self, client, datastore):
|
||||||
# DO_8 sits at module slot 2 in the pump fixture
|
# DO_8 sits at module slot 2 in the pump fixture
|
||||||
r = await client.post("/api/channels/do/2/0", json={"action": "toggle"})
|
r = await client.post("/api/channels/do/0/0", json={"action": "toggle"})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
# DO channel 1 is forced ON in the OUTPUT image (master-side)
|
# DO channel 1 is forced ON in the OUTPUT image (master-side)
|
||||||
assert datastore.digital_output_channel(1) is True
|
assert datastore.digital_output_channel(1) is True
|
||||||
@@ -117,13 +120,13 @@ class TestManipulation:
|
|||||||
async def test_ai_value_set(self, client, datastore):
|
async def test_ai_value_set(self, client, datastore):
|
||||||
# AI_2 sits at module slot 4 in the pump fixture (DI_8, DI_16,
|
# 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
|
# 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})
|
r = await client.post("/api/channels/ai/0/0", json={"value": 200})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json()["value"] == 200
|
assert r.json()["value"] == 200
|
||||||
assert datastore.get_input_word(0) == 200
|
assert datastore.get_input_word(0) == 200
|
||||||
|
|
||||||
async def test_ao_force_writes_output_image(self, client, datastore):
|
async def test_ao_force_writes_output_image(self, client, datastore):
|
||||||
r = await client.post("/api/channels/ao/5/0", json={"value": 1234})
|
r = await client.post("/api/channels/ao/0/0", json={"value": 1234})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
# written at the output image word 0 -> read back at +0x0200
|
# written at the output image word 0 -> read back at +0x0200
|
||||||
assert datastore.read_words(0x0200, 1) == [1234]
|
assert datastore.read_words(0x0200, 1) == [1234]
|
||||||
@@ -133,9 +136,13 @@ class TestManipulation:
|
|||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
|
|
||||||
async def test_kind_mismatch_400(self, client):
|
async def test_kind_mismatch_400(self, client):
|
||||||
# DI_16 module (index 1) has no DO channels
|
# per-type numbering (v1.5): DO module 0 is the DO_8 — asking
|
||||||
r = await client.post("/api/channels/do/1/0", json={"action": "toggle"})
|
# for a DO channel beyond its 8 channels is out of range (404);
|
||||||
assert r.status_code == 400
|
# DO module 2 does not exist (only DO0, DO1)
|
||||||
|
r = await client.post("/api/channels/do/2/0", json={"action": "toggle"})
|
||||||
|
assert r.status_code == 404
|
||||||
|
r = await client.post("/api/channels/do/0/9", json={"action": "toggle"})
|
||||||
|
assert r.status_code in (400, 404) # channel out of module range
|
||||||
|
|
||||||
|
|
||||||
class TestDiagnostics:
|
class TestDiagnostics:
|
||||||
@@ -187,6 +194,190 @@ class TestReadOnlyMode:
|
|||||||
r = await c.post("/api/diagnostics/reset-stats")
|
r = await c.post("/api/diagnostics/reset-stats")
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
async def test_readonly_blocks_env_control(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.get("/api/env")
|
||||||
|
assert r.status_code == 200 # status is a read
|
||||||
|
assert r.json()["state"] == "none"
|
||||||
|
r = await c.post("/api/env/start",
|
||||||
|
json={"ref": "envs.pump_station_env:PumpStationEnv"})
|
||||||
|
assert r.status_code == 403
|
||||||
|
r = await c.post("/api/env/stop")
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnvControl:
|
||||||
|
"""FR-10.2.15 runtime env lifecycle + FR-10.2.17 hot reload."""
|
||||||
|
|
||||||
|
REF = "envs.pump_station_env:PumpStationEnv"
|
||||||
|
|
||||||
|
async def test_env_status_none(self, client):
|
||||||
|
r = await client.get("/api/env")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"ref": None, "state": "none", "reason": None,
|
||||||
|
"sim_time_ms": None, "tick_ms": None}
|
||||||
|
|
||||||
|
async def test_start_stop_cycle(self, client, app):
|
||||||
|
r = await client.post("/api/env/start", json={"ref": self.REF})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
st = r.json()
|
||||||
|
assert st["state"] == "running"
|
||||||
|
assert st["ref"] == self.REF
|
||||||
|
assert app.runner.env is not None
|
||||||
|
# let it tick a little, then check status shows sim time
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
r = await client.get("/api/env")
|
||||||
|
assert r.json()["sim_time_ms"] is not None
|
||||||
|
# stop -> none
|
||||||
|
r = await client.post("/api/env/stop")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["state"] == "none"
|
||||||
|
assert app.runner.env is None
|
||||||
|
|
||||||
|
async def test_start_refused_while_loaded(self, client):
|
||||||
|
await client.post("/api/env/start", json={"ref": self.REF})
|
||||||
|
r = await client.post("/api/env/start", json={"ref": self.REF})
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert "stop" in r.json()["detail"]
|
||||||
|
await client.post("/api/env/stop")
|
||||||
|
|
||||||
|
async def test_start_requires_ref(self, client):
|
||||||
|
r = await client.post("/api/env/start", json={})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
async def test_pause_resume(self, client, app):
|
||||||
|
await client.post("/api/env/start", json={"ref": self.REF})
|
||||||
|
# let it tick so sim time advances
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
t0 = (await client.get("/api/env")).json()["sim_time_ms"]
|
||||||
|
assert t0 > 0
|
||||||
|
r = await client.post("/api/env/pause")
|
||||||
|
assert r.json()["state"] == "paused"
|
||||||
|
t1 = (await client.get("/api/env")).json()["sim_time_ms"]
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
t2 = (await client.get("/api/env")).json()["sim_time_ms"]
|
||||||
|
assert t2 == t1 # frozen while paused
|
||||||
|
r = await client.post("/api/env/resume")
|
||||||
|
assert r.json()["state"] == "running"
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
t3 = (await client.get("/api/env")).json()["sim_time_ms"]
|
||||||
|
assert t3 > t2
|
||||||
|
# double-pause / resume-without-pause are refused
|
||||||
|
await client.post("/api/env/pause")
|
||||||
|
r = await client.post("/api/env/pause")
|
||||||
|
assert r.status_code == 409
|
||||||
|
r = await client.post("/api/env/stop")
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = await client.post("/api/env/resume")
|
||||||
|
assert r.status_code == 409
|
||||||
|
r = await client.post("/api/env/pause")
|
||||||
|
assert r.status_code == 409 # nothing loaded
|
||||||
|
|
||||||
|
async def test_reset_returns_to_initial_state(self, client):
|
||||||
|
await client.post("/api/env/start", json={"ref": self.REF})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
st = (await client.get("/api/env")).json()
|
||||||
|
assert st["sim_time_ms"] > 0
|
||||||
|
r = await client.post("/api/env/reset")
|
||||||
|
assert r.status_code == 200
|
||||||
|
# the reset RESPONSE carries the post-reset status: asserting
|
||||||
|
# on a follow-up GET would race the running 10 ms ticker
|
||||||
|
# (which legitimately ticks between the two requests)
|
||||||
|
assert r.json()["sim_time_ms"] == 0.0 # deterministic fresh episode
|
||||||
|
await client.post("/api/env/stop")
|
||||||
|
|
||||||
|
async def test_restart_fresh_instance(self, client, app):
|
||||||
|
await client.post("/api/env/start", json={"ref": self.REF})
|
||||||
|
first = app.runner.env
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
r = await client.post("/api/env/restart")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["state"] == "running"
|
||||||
|
assert app.runner.env is not first # a NEW instance
|
||||||
|
assert app.runner.env.sim_time_ms < first.sim_time_ms
|
||||||
|
await client.post("/api/env/stop")
|
||||||
|
|
||||||
|
async def test_is_done_ends_ticker(self, client, app):
|
||||||
|
# an env that is done after 30 ms of sim time (TICK_MS 10 -> 3 ticks)
|
||||||
|
ref = "envs.test_done_env:PumpStationEnv"
|
||||||
|
app.runner.env = None
|
||||||
|
r = await client.post("/api/env/start", json={"ref": ref})
|
||||||
|
assert r.status_code == 200
|
||||||
|
await asyncio.sleep(0.15) # let the paced ticker finish
|
||||||
|
st = (await client.get("/api/env")).json()
|
||||||
|
assert st["state"] == "done"
|
||||||
|
assert st["reason"] == "is_done()"
|
||||||
|
assert app.runner.env is not None # instance kept for inspection
|
||||||
|
# pause on done -> refused (restart instead)
|
||||||
|
r = await client.post("/api/env/pause")
|
||||||
|
assert r.status_code == 409
|
||||||
|
# reset brings it back to running
|
||||||
|
r = await client.post("/api/env/reset")
|
||||||
|
assert r.json()["state"] == "running"
|
||||||
|
await client.post("/api/env/stop")
|
||||||
|
|
||||||
|
async def test_module_save_discards_env(self, client, app):
|
||||||
|
await client.post("/api/env/start", json={"ref": self.REF})
|
||||||
|
assert app.runner.env is not None
|
||||||
|
r = await client.put("/api/config/modules", json={
|
||||||
|
"modules": ["750-430", "750-530"], "persist": False,
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
# FR-10.2.15: the rebuild discards the env (old-layout bindings)
|
||||||
|
assert app.runner.env is None
|
||||||
|
assert (await client.get("/api/env")).json()["state"] == "none"
|
||||||
|
# and the new runner points at the NEW node
|
||||||
|
assert app.runner.node is app.node
|
||||||
|
|
||||||
|
async def test_start_unknown_env_is_409(self, client):
|
||||||
|
r = await client.post("/api/env/start",
|
||||||
|
json={"ref": "envs.no_such_env:Env"})
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert "cannot import" in r.json()["detail"]
|
||||||
|
|
||||||
|
async def test_hot_reload_after_source_edit(self, client, app, tmp_path,
|
||||||
|
monkeypatch):
|
||||||
|
"""FR-10.2.17: start -> edit source on disk -> restart runs the
|
||||||
|
NEW code (sys.modules cache invalidated)."""
|
||||||
|
pkg = tmp_path / "reloadpkg"
|
||||||
|
pkg.mkdir()
|
||||||
|
(pkg / "__init__.py").write_text("", encoding="utf-8")
|
||||||
|
# v1: level rises
|
||||||
|
(pkg / "reload_env.py").write_text(
|
||||||
|
"from wago_sim.scenario.plantenv import PlantEnv\n"
|
||||||
|
"class ReloadEnv(PlantEnv):\n"
|
||||||
|
" def model(self, state, dt_s):\n"
|
||||||
|
" level = float(state.by_code('AI0.0'))\n"
|
||||||
|
" return {'AI0.0': level + 1.0}\n"
|
||||||
|
"Env = ReloadEnv\n", encoding="utf-8")
|
||||||
|
sys.path.insert(0, str(tmp_path))
|
||||||
|
try:
|
||||||
|
r = await client.post("/api/env/start",
|
||||||
|
json={"ref": "reloadpkg.reload_env:ReloadEnv"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
await asyncio.sleep(0.03)
|
||||||
|
v1 = app.datastore.get_input_word(0)
|
||||||
|
assert v1 > 0
|
||||||
|
# v2: level DRAINS instead — same class name
|
||||||
|
(pkg / "reload_env.py").write_text(
|
||||||
|
"from wago_sim.scenario.plantenv import PlantEnv\n"
|
||||||
|
"class ReloadEnv(PlantEnv):\n"
|
||||||
|
" def model(self, state, dt_s):\n"
|
||||||
|
" level = float(state.by_code('AI0.0'))\n"
|
||||||
|
" return {'AI0.0': max(0.0, level - 1.0)}\n"
|
||||||
|
"Env = ReloadEnv\n", encoding="utf-8")
|
||||||
|
r = await client.post("/api/env/restart")
|
||||||
|
assert r.status_code == 200
|
||||||
|
app.datastore.set_input_word(0, 100)
|
||||||
|
await asyncio.sleep(0.03)
|
||||||
|
assert app.datastore.get_input_word(0) < 100 # NEW code ran
|
||||||
|
await client.post("/api/env/stop")
|
||||||
|
finally:
|
||||||
|
sys.path.remove(str(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
class TestCouplerConfig:
|
class TestCouplerConfig:
|
||||||
async def test_get_coupler_config(self, client, app):
|
async def test_get_coupler_config(self, client, app):
|
||||||
@@ -319,7 +510,7 @@ class TestModuleConfig:
|
|||||||
class TestChannelNames:
|
class TestChannelNames:
|
||||||
async def test_names_in_layout_and_values(self, client, app):
|
async def test_names_in_layout_and_values(self, client, app):
|
||||||
r = await client.put("/api/config/channel-names", json={
|
r = await client.put("/api/config/channel-names", json={
|
||||||
"channel_names": {"DI0.0": "start_button", "AI4.0": "tank_level"},
|
"channel_names": {"DI0.0": "start_button", "AI0.0": "tank_level"},
|
||||||
"persist": False,
|
"persist": False,
|
||||||
})
|
})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
@@ -331,23 +522,23 @@ class TestChannelNames:
|
|||||||
# values snapshot carries it too
|
# values snapshot carries it too
|
||||||
r = await client.get("/api/values")
|
r = await client.get("/api/values")
|
||||||
entry = next(v for v in r.json()["values"]
|
entry = next(v for v in r.json()["values"]
|
||||||
if (v["kind"], v["module"], v["channel"]) == ("ai", 4, 0))
|
if (v["kind"], v["module"], v["channel"]) == ("ai", 0, 0))
|
||||||
assert entry["name"] == "tank_level"
|
assert entry["name"] == "tank_level"
|
||||||
|
|
||||||
async def test_names_persist_to_yaml(self, client, app, tmp_path):
|
async def test_names_persist_to_yaml(self, client, app, tmp_path):
|
||||||
cfg_file = tmp_path / "node.yaml"
|
cfg_file = tmp_path / "node.yaml"
|
||||||
app.node_config_path = str(cfg_file)
|
app.node_config_path = str(cfg_file)
|
||||||
r = await client.put("/api/config/channel-names", json={
|
r = await client.put("/api/config/channel-names", json={
|
||||||
"channel_names": {"DO2.0": "pump_relay"}, "persist": True,
|
"channel_names": {"DO0.0": "pump_relay"}, "persist": True,
|
||||||
})
|
})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
text = cfg_file.read_text(encoding="utf-8")
|
text = cfg_file.read_text(encoding="utf-8")
|
||||||
assert "channel_names:" in text
|
assert "channel_names:" in text
|
||||||
assert "DO2.0: pump_relay" in text
|
assert "DO0.0: pump_relay" in text
|
||||||
# and it loads back
|
# and it loads back
|
||||||
from wago_sim.scenario.dsl import load_yaml, load_channel_names
|
from wago_sim.scenario.dsl import load_yaml, load_channel_names
|
||||||
names = load_channel_names(load_yaml(cfg_file))
|
names = load_channel_names(load_yaml(cfg_file))
|
||||||
assert names == {"DO2.0": "pump_relay"}
|
assert names == {"DO0.0": "pump_relay"}
|
||||||
|
|
||||||
async def test_invalid_code_rejected(self, client):
|
async def test_invalid_code_rejected(self, client):
|
||||||
r = await client.put("/api/config/channel-names", json={
|
r = await client.put("/api/config/channel-names", json={
|
||||||
@@ -464,25 +655,44 @@ class TestChannelRanges:
|
|||||||
|
|
||||||
async def test_put_ranges_and_layout(self, client, app):
|
async def test_put_ranges_and_layout(self, client, app):
|
||||||
r = await client.put("/api/config/channel-ranges", json={
|
r = await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": 0, "max": 32000},
|
"channel_ranges": {"AI0.0": {"min": 0, "max": 32000},
|
||||||
"AO5.0": {"min": 400, "max": 2000}},
|
"AO0.0": {"min": 400, "max": 2000}},
|
||||||
"persist": False,
|
"persist": False,
|
||||||
})
|
})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
# layout reflects the configured range
|
# layout reflects the configured range
|
||||||
r = await client.get("/api/node")
|
r = await client.get("/api/node")
|
||||||
ai = next(c for m in r.json()["modules"] if m["index"] == 4
|
ai = next(c for m in r.json()["modules"] if m["index"] == 4
|
||||||
for c in m["channels"] if c["kind"] == "ai" and c["address"] == "AI4.0")
|
for c in m["channels"] if c["kind"] == "ai" and c["address"] == "AI0.0")
|
||||||
assert ai["range"] == {"min": 0, "max": 32000}
|
assert ai["range"] == {"min": 0, "max": 32000}
|
||||||
assert app.node.channel_range("ao", 5, 0) == (400, 2000)
|
assert app.node.channel_ranges["AO0.0"] == (400, 2000)
|
||||||
|
|
||||||
async def test_default_range_dropped_from_store(self, client, app):
|
async def test_default_range_kept_in_dense_store(self, client, app):
|
||||||
|
# the store is DENSE (FR-10.2.14): a default-equal entry is
|
||||||
|
# materialized like any other — no sparse-override dropping
|
||||||
r = await client.put("/api/config/channel-ranges", json={
|
r = await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": 0, "max": 4095}}, # == default
|
"channel_ranges": {"AI0.0": {"min": 0, "max": 4095}}, # == default
|
||||||
"persist": False,
|
"persist": False,
|
||||||
})
|
})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert app.node.channel_ranges == {} # kept clean
|
assert app.node.channel_ranges["AI0.0"] == (0, 4095)
|
||||||
|
# every analog channel of the layout is present
|
||||||
|
analog = {c for c in app.node.channel_ranges}
|
||||||
|
assert {"AI0.0", "AI0.1", "AO0.0", "AO0.1"} <= analog
|
||||||
|
# and digital codes never are
|
||||||
|
assert not any(c.startswith(("DI", "DO")) for c in analog)
|
||||||
|
|
||||||
|
async def test_dense_store_pruned_on_module_save(self, client, app, tmp_path):
|
||||||
|
app.node_config_path = str(tmp_path / "node.yaml")
|
||||||
|
await client.put("/api/config/channel-ranges", json={
|
||||||
|
"channel_ranges": {"AI0.0": {"min": 0, "max": 32000}}, "persist": False,
|
||||||
|
})
|
||||||
|
r = await client.put("/api/config/modules", json={
|
||||||
|
"modules": ["750-430", "750-530"], "persist": False,
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
# no analog modules left -> the range store is empty (pruned)
|
||||||
|
assert app.node.channel_ranges == {}
|
||||||
|
|
||||||
async def test_range_validation(self, client):
|
async def test_range_validation(self, client):
|
||||||
base = {"persist": False}
|
base = {"persist": False}
|
||||||
@@ -490,71 +700,71 @@ class TestChannelRanges:
|
|||||||
"channel_ranges": {"XX4.0": {"min": 0, "max": 100}}, **base})
|
"channel_ranges": {"XX4.0": {"min": 0, "max": 100}}, **base})
|
||||||
assert r.status_code == 400 # bad code
|
assert r.status_code == 400 # bad code
|
||||||
r = await client.put("/api/config/channel-ranges", json={
|
r = await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": 100, "max": 50}}, **base})
|
"channel_ranges": {"AI0.0": {"min": 100, "max": 50}}, **base})
|
||||||
assert r.status_code == 400 # min >= max
|
assert r.status_code == 400 # min >= max
|
||||||
r = await client.put("/api/config/channel-ranges", json={
|
r = await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": 0, "max": 70000}}, **base})
|
"channel_ranges": {"AI0.0": {"min": 0, "max": 70000}}, **base})
|
||||||
assert r.status_code == 400 # > 65535
|
assert r.status_code == 400 # > 65535
|
||||||
r = await client.put("/api/config/channel-ranges", json={
|
r = await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": 0}}, **base})
|
"channel_ranges": {"AI0.0": {"min": 0}}, **base})
|
||||||
assert r.status_code == 400 # missing max
|
assert r.status_code == 400 # missing max
|
||||||
|
|
||||||
async def test_negative_range_accepted(self, client, app):
|
async def test_negative_range_accepted(self, client, app):
|
||||||
r = await client.put("/api/config/channel-ranges", json={
|
r = await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": -32000, "max": 32000}},
|
"channel_ranges": {"AI0.0": {"min": -32000, "max": 32000}},
|
||||||
"persist": False,
|
"persist": False,
|
||||||
})
|
})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert app.node.channel_range("ai", 4, 0) == (-32000, 32000)
|
assert app.node.channel_ranges["AI0.0"] == (-32000, 32000)
|
||||||
# layout exposes the signed range
|
# layout exposes the signed range
|
||||||
r = await client.get("/api/node")
|
r = await client.get("/api/node")
|
||||||
ai = next(c for m in r.json()["modules"] if m["index"] == 4
|
ai = next(c for m in r.json()["modules"] if m["index"] == 4
|
||||||
for c in m["channels"] if c["address"] == "AI4.0")
|
for c in m["channels"] if c["address"] == "AI0.0")
|
||||||
assert ai["range"] == {"min": -32000, "max": 32000}
|
assert ai["range"] == {"min": -32000, "max": 32000}
|
||||||
|
|
||||||
async def test_range_floor_is_minus_65534(self, client):
|
async def test_range_floor_is_minus_65534(self, client):
|
||||||
r = await client.put("/api/config/channel-ranges", json={
|
r = await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": -65535, "max": 0}}, "persist": False})
|
"channel_ranges": {"AI0.0": {"min": -65535, "max": 0}}, "persist": False})
|
||||||
assert r.status_code == 400 # below -65534
|
assert r.status_code == 400 # below -65534
|
||||||
|
|
||||||
async def test_signed_value_display_and_write(self, client, app, datastore):
|
async def test_signed_value_display_and_write(self, client, app, datastore):
|
||||||
# channel with a negative-min range -> signed two's-complement view
|
# channel with a negative-min range -> signed two's-complement view
|
||||||
await client.put("/api/config/channel-ranges", json={
|
await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": -32000, "max": 32000}},
|
"channel_ranges": {"AI0.0": {"min": -32000, "max": 32000}},
|
||||||
"persist": False})
|
"persist": False})
|
||||||
r = await client.post("/api/channels/ai/4/0", json={"value": -2000})
|
r = await client.post("/api/channels/ai/0/0", json={"value": -2000})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
# raw image holds the two's-complement word
|
# raw image holds the two's-complement word
|
||||||
assert datastore.get_input_word(0) == (-2000) & 0xFFFF
|
assert datastore.get_input_word(0) == (-2000) & 0xFFFF
|
||||||
# values API shows the signed number again
|
# values API shows the signed number again
|
||||||
r = await client.get("/api/values")
|
r = await client.get("/api/values")
|
||||||
entry = next(v for v in r.json()["values"]
|
entry = next(v for v in r.json()["values"]
|
||||||
if (v["kind"], v["module"], v["channel"]) == ("ai", 4, 0))
|
if (v["kind"], v["module"], v["channel"]) == ("ai", 0, 0))
|
||||||
assert entry["value"] == -2000
|
assert entry["value"] == -2000
|
||||||
|
|
||||||
async def test_negative_ao_force(self, client, datastore):
|
async def test_negative_ao_force(self, client, datastore):
|
||||||
r = await client.post("/api/channels/ao/5/0", json={"value": -3000})
|
r = await client.post("/api/channels/ao/0/0", json={"value": -3000})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert datastore.get_output_word(0) == (-3000) & 0xFFFF
|
assert datastore.get_output_word(0) == (-3000) & 0xFFFF
|
||||||
# unsigned AO (default range) still displays raw
|
# unsigned AO (default range) still displays raw
|
||||||
r = await client.get("/api/values")
|
r = await client.get("/api/values")
|
||||||
entry = next(v for v in r.json()["values"]
|
entry = next(v for v in r.json()["values"]
|
||||||
if (v["kind"], v["module"], v["channel"]) == ("ao", 5, 0))
|
if (v["kind"], v["module"], v["channel"]) == ("ao", 0, 0))
|
||||||
assert entry["value"] == (-3000) & 0xFFFF
|
assert entry["value"] == (-3000) & 0xFFFF
|
||||||
|
|
||||||
async def test_value_bounds_rejected(self, client):
|
async def test_value_bounds_rejected(self, client):
|
||||||
r = await client.post("/api/channels/ai/4/0", json={"value": -65535})
|
r = await client.post("/api/channels/ai/0/0", json={"value": -65535})
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
r = await client.post("/api/channels/ai/4/0", json={"value": 65536})
|
r = await client.post("/api/channels/ai/0/0", json={"value": 65536})
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
r = await client.post("/api/channels/ai/4/0", json={"value": "banana"})
|
r = await client.post("/api/channels/ai/0/0", json={"value": "banana"})
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
async def test_ranges_persist_to_yaml(self, client, app, tmp_path):
|
async def test_ranges_persist_to_yaml(self, client, app, tmp_path):
|
||||||
cfg_file = tmp_path / "node.yaml"
|
cfg_file = tmp_path / "node.yaml"
|
||||||
app.node_config_path = str(cfg_file)
|
app.node_config_path = str(cfg_file)
|
||||||
r = await client.put("/api/config/channel-ranges", json={
|
r = await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": 0, "max": 32000}}, "persist": True,
|
"channel_ranges": {"AI0.0": {"min": 0, "max": 32000}}, "persist": True,
|
||||||
})
|
})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
text = cfg_file.read_text(encoding="utf-8")
|
text = cfg_file.read_text(encoding="utf-8")
|
||||||
@@ -563,18 +773,32 @@ class TestChannelRanges:
|
|||||||
# loads back
|
# loads back
|
||||||
from wago_sim.scenario.dsl import load_yaml, load_channel_ranges
|
from wago_sim.scenario.dsl import load_yaml, load_channel_ranges
|
||||||
ranges = load_channel_ranges(load_yaml(cfg_file))
|
ranges = load_channel_ranges(load_yaml(cfg_file))
|
||||||
assert ranges == {"AI4.0": (0, 32000)}
|
assert ranges == {"AI0.0": (0, 32000)}
|
||||||
|
|
||||||
|
async def test_ranges_yaml_lean_defaults(self, client, app, tmp_path):
|
||||||
|
# dense store, lean YAML: default-equal entries are NOT written
|
||||||
|
cfg_file = tmp_path / "node.yaml"
|
||||||
|
app.node_config_path = str(cfg_file)
|
||||||
|
r = await client.put("/api/config/channel-ranges", json={
|
||||||
|
"channel_ranges": {"AI0.0": {"min": 0, "max": 4095},
|
||||||
|
"AO0.0": {"min": 0, "max": 32000}},
|
||||||
|
"persist": True,
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
text = cfg_file.read_text(encoding="utf-8")
|
||||||
|
assert "AO0.0" in text and "AI0.0" not in text
|
||||||
|
assert app.node.channel_ranges["AI0.0"] == (0, 4095) # store dense
|
||||||
|
|
||||||
async def test_ranges_survive_module_save(self, client, app, tmp_path):
|
async def test_ranges_survive_module_save(self, client, app, tmp_path):
|
||||||
app.node_config_path = str(tmp_path / "node.yaml")
|
app.node_config_path = str(tmp_path / "node.yaml")
|
||||||
await client.put("/api/config/channel-ranges", json={
|
await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": 0, "max": 32000}}, "persist": True,
|
"channel_ranges": {"AI0.0": {"min": 0, "max": 32000}}, "persist": True,
|
||||||
})
|
})
|
||||||
r = await client.put("/api/config/modules", json={
|
r = await client.put("/api/config/modules", json={
|
||||||
"modules": ["750-430", "750-467"], "persist": True,
|
"modules": ["750-430", "750-467"], "persist": True,
|
||||||
})
|
})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
# AI_2 is at slot 1 now; the AI4.0 range became stale -> carried
|
# AI_2 is at slot 1 now; the AI0.0 range became stale -> carried
|
||||||
# over but no longer matches a channel; the store keeps it (names
|
# over but no longer matches a channel; the store keeps it (names
|
||||||
# semantics differ: stale name codes are dropped on import only)
|
# semantics differ: stale name codes are dropped on import only)
|
||||||
text = (tmp_path / "node.yaml").read_text(encoding="utf-8")
|
text = (tmp_path / "node.yaml").read_text(encoding="utf-8")
|
||||||
@@ -582,14 +806,14 @@ class TestChannelRanges:
|
|||||||
|
|
||||||
async def test_export_import_carries_ranges(self, client, app, tmp_path):
|
async def test_export_import_carries_ranges(self, client, app, tmp_path):
|
||||||
await client.put("/api/config/channel-ranges", json={
|
await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": 600, "max": 2000}}, "persist": False,
|
"channel_ranges": {"AI0.0": {"min": 600, "max": 2000}}, "persist": False,
|
||||||
})
|
})
|
||||||
exported = (await client.get("/api/config/export")).json()
|
exported = (await client.get("/api/config/export")).json()
|
||||||
assert exported["channel_ranges"]["AI4.0"] == {"min": 600, "max": 2000}
|
assert exported["channel_ranges"]["AI0.0"] == {"min": 600, "max": 2000}
|
||||||
app.node_config_path = str(tmp_path / "node.yaml")
|
app.node_config_path = str(tmp_path / "node.yaml")
|
||||||
r = await client.post("/api/config/import", json=exported)
|
r = await client.post("/api/config/import", json=exported)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert app.node.channel_range("ai", 4, 0) == (600, 2000)
|
assert app.node.channel_ranges["AI0.0"] == (600, 2000)
|
||||||
text = (tmp_path / "node.yaml").read_text(encoding="utf-8")
|
text = (tmp_path / "node.yaml").read_text(encoding="utf-8")
|
||||||
assert "min: 600, max: 2000" in text
|
assert "min: 600, max: 2000" in text
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user