Compare commits

..
13 Commits
Author SHA1 Message Date
Jonas Bengtsson 9f3272094e 🚧 WIP 2026-10-07 07:40:56 +02:00
Jonas Bengtsson c8b655111e 📝 Update README & specifications. 2026-10-06 19:21:41 +02:00
Jonas Bengtsson 597b5bf73c 🚧 Add requirements files 2026-10-05 07:13:04 +02:00
Jonas Bengtsson 647c209e89 🚧 Add environment maneuverability 2026-10-04 22:48:10 +02:00
Jonas Bengtsson 68d3c42d79 🚧 WIP 2026-10-04 20:59:22 +02:00
Jonas Bengtsson f82e8d4be9 🚧 Add debug env 2026-10-04 16:36:45 +02:00
Jonas Bengtsson 66b7e12f0f 🚧 Rewrite continous simulation. 2026-10-04 15:29:57 +02:00
Jonas Bengtsson 263afc9c1e 🚧 Implement continous simulation 2026-10-04 14:50:57 +02:00
Jonas Bengtsson eb36a2d947 🚧 WIP 2026-10-04 14:15:44 +02:00
Jonas Bengtsson c30b444ef1 📝 Update spec and README 2026-10-04 14:15:26 +02:00
Jonas Bengtsson 59679b96ee 🚧 WIP 2026-10-04 14:15:07 +02:00
Jonas Bengtsson e5df0b04ed 🚧 Change module numbering 2026-10-04 13:05:47 +02:00
Jonas Bengtsson 7948b794f5 🚧 Make analog indicators clickable 2026-10-04 11:22:55 +02:00
45 changed files with 7170 additions and 220 deletions
+22
View File
@@ -22,6 +22,28 @@
],
"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.
"name": "wago-sim: run (pump_station_fat)",
+980
View File
@@ -913,6 +913,986 @@
],
"isBackground": false,
"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
}
]
}
+196 -21
View File
@@ -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
and Python action plugins for complex plant models.
- **Two I/O addressing schemes** for aliases: *module-based* —
`{ type: di, module: 2, channel: 1 }` = `DI2.1` (module slot and
channel both 0-based) — and *pool-based* — `{ type: di, channel: 3 }`
(1-based index across the node's DI pool). Both may be mixed.
`{ type: di, module: 2, channel: 1 }` = `DI2.1` (module **numbered
0-based within its type** — `DI0`, `DO0`, `AI0`, `AO0` start each
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
without a scenario (`wago-sim serve --node ...` already works);
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
settings, Modbus Mapping, I/O Config), and device-state persistence
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
`docs/spec/software-spec.md` §8)*: endurance/soak testing over days or
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
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
- `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–
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
- [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
(0x1020/0x1021) and module-diagnosis (0x1050) fault injection
and clear; SF LED on the coupler reflects injected LED errors.
- [x] M5: configuration — coupler settings forms (watchdog time/masks/type/close-on-timeout via 0x1000–0x100B, response delay 0x1037, idle timeout 0x1030) with the device's own validation surfaced (writes refused while the watchdog runs → field error); module list editor over the 13-article catalog with SAVE CONFIG → live process-image rebuild (`rebuild_node`: datastore survives, values clear like a KBUS reset, 0x1022–0x1025 and 0x2030+ updated) + persistence to the node-config YAML via a comment-preserving custom emitter.
- [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) —
node-config persisted (`channel_names:` mapping, I/O code →
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
complement), for signed plant quantities. DI/DO channels render
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,
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
`ASGITransport` (in-process, no socket) + live verification:
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 —
see Completed above.)*
### Phase 3 — Long-duration (continuous) simulation (spec §8)
The soak-testing feature requested 2026-10-03 — specified in §8,
not yet implemented. Datasets are **optional**: signal logic
(plugins, scheduled steps, degradation) runs in parallel with CSV
### Phase 3 — Long-duration (continuous) simulation (spec §8) — IMPLEMENTED 2026-10-04
The soak-testing feature specified in §8 is implemented: continuous
mode with the fixed tick pipeline, optional streaming CSV datasets,
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.
- [ ] `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,
`interpolation: linear|hold`, `repeat` with seamless seams,
`time_factor` acceleration, `until` limits, bounded memory, ERROR
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
limits → scheduled steps → snapshots; plugins may read
dataset-driven and logic-driven aliases in the same tick
(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
run; degradation/invariants/rate limits/snapshots apply
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
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`,
`exponential`, `staircase`, plugin shapes) computing plant
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
(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`
action restoring capacity and clearing diagnoses (FR-8.3.4–8.3.5,
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
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).
- [ ] 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).
- [ ] 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).
- [ ] 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,
alias-ownership validation, degradation/recovery round-trip,
episode recording, full 28-day soak, logic-only run, mixed
+22
View File
@@ -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
+18
View File
@@ -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
+6 -2
View File
@@ -14,5 +14,9 @@ modules:
# Application names for module channels (I/O code -> name)
channel_names:
AO5.0: speed_setpoint
DI0.0: start_button
AI0.0: NG1
AO0.0: P!_BORV
DI0.0: P1_DI
DI0.1: P1_AUTO
DI0.2: P1_FEL
DO0.0: P!_START
+32
View File
@@ -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
+52
View File
@@ -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
+14
View File
@@ -22,12 +22,26 @@ services:
WAGO_SIM_PORT: "502"
# Uncomment to trace every Modbus transaction in plain text:
# 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:
- ./config:/app/config:ro
- ./scenarios:/app/scenarios:ro
- ./plugins:/app/plugins:ro
# Report output for headless `run` invocations (spec §8 soak reports):
- ./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
deploy:
resources:
+398 -33
View File
@@ -7,7 +7,7 @@
| Project | `wago-modbustcp-simulator-py` |
| Version | 1.4 (Draft for review) |
| Date | 2026-10-03 |
| Status | Framework phase; module addressing implemented (FR-4.3.11); service/container deployment implemented (§12); plain-text request tracing implemented (NFR-6.4); clean CLI error handling implemented (NFR-6.5 CLI part); 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 |
| 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
as *module type + module number + channel number*, all components
explicitly configurable: `{ type: di, module: 2, channel: 1 }` selects
digital input channel 1 (0-based) on module slot 2 (0-based position in
the node's module list), i.e. `DI2.1`. Both numbers are 0-based to match
physical slot/channel labeling. The channel kind must match the module
(`DO` on a DI-only module is a validation error). This is an alternative
to pool addressing (`{ type: di, channel: 3 }` = 3rd DI channel across
the whole node); both schemes are supported and may be mixed, but an
alias uses exactly one. **Implemented** in v1.1 (`channel_by_module`,
`Alias.module`, `alias_location`).
digital input channel 1 (0-based) on DI-module 2 (0-based **number
among the modules of the same type**, i.e. the third DI module in the
node), i.e. `DI2.1`. Channel numbers are 0-based. The channel kind must
match the module (`DO` on a DI-only module is a validation error).
This is an alternative to addressing by physical slot; both pool
addressing (`{ type: di, channel: 3 }` = 3rd DI channel across the
whole node) and module addressing are supported and may be mixed,
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
@@ -469,8 +478,8 @@ stateDiagram-v2
## 8. Feature Request — Long-Duration (Continuous) Simulation
> **Status: requested 2026-10-03, planned (roadmap phase 3), not yet implemented.**
> Extends the scenario engine (§4) with a `continuous` mode for endurance
> **Status: implemented 2026-10-04** (roadmap phase 3 complete). Extends
> the scenario engine (§4) with a `continuous` mode for endurance
> (“soak”) testing: the facility runs for hours, days or weeks of simulated
> 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,
(7) snapshots are taken if due.
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
all, continuous mode shall work unchanged — demand profiles and other
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,
invariants, rate limits, snapshots) applies identically, whether or not
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)
@@ -573,8 +639,11 @@ unchanged whether or not CSV streaming is used.
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.
`pump_capacity`, range 0…1) from wear counters and sim-time. Built-in
shapes: `linear` (per runtime hour, per start cycle), `exponential`,
`staircase` (threshold table); custom shapes via Python plugin.
shapes: `linear` (per runtime hour via `rate:`, or per start cycle with
`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
`engine.parameter("pump_capacity")` and shall use them in the loop physics
(e.g., actual pump flow = nominal flow × capacity), so the PLC must
@@ -649,31 +718,30 @@ datasets:
repeat: 4
degradation:
pump_wear:
pump_capacity:
observes: pump_relay # wear counts from this alias
model: linear
parameter: pump_capacity # consumed by the plant model
per_runtime_hour: 0.0004 # 0.04 %/h → ~10 % after ~10.4 sim-days
per_start_cycle: 0.0002 # 0.02 % per start
floor: 0.80
maintenance_at: 0.90
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 }
rate: 0.0004 # -0.04 %/h runtime → ~10 % after ~10.4 sim-days
from: 1.0
maintenance_at: 0.90 # threshold crossing injects diagnosis
diagnosis: { module: 4, channel: 1, value: 0x0001 }
# the PLC raises maintenance_alarm; the scenario asserts it:
sequence: # scheduled events (sim-time)
- 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)
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:
- { alias: pump_relay, edge: rising, max_per_hour: 6, label: "pump start cycles" }
- { alias: speed_setpoint, max_per_hour: 3000 } # ramp-rate bound
report:
snapshot_every: 1h
@@ -735,15 +803,20 @@ src/wago_sim/scenario/
│ # time_factor — OPTIONAL driver in continuous mode
├── degradation.py # WearCounters, DegradationModel registry, maintenance actions
├── 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
# scheduler; fixed tick pipeline per FR-8.2.12:
# datasets -> edges -> plugins -> degradation ->
# datasets -> edges -> plugins/env -> degradation ->
# invariants -> scheduled steps -> snapshots
```
```mermaid
erDiagram
ContinuousScenario ||--o{ Dataset : "datasets (optional)"
ContinuousScenario ||--|| PlantEnv : "environment (FR-8.2.14)"
PlantEnv ||--o{ DecisionSource : "plugins / external agents"
Dataset ||--|{ ColumnMapping : "alias columns"
ContinuousScenario ||--|{ PlantPlugin : "signal logic (always runs)"
ContinuousScenario ||--|{ DegradationModel : degradation
@@ -755,6 +828,7 @@ erDiagram
ContinuousScenario ||--o{ Snapshot : "periodic dumps"
PlantPlugin }o--|| DegradationModel : "reads parameters"
PlantPlugin }o--o{ Alias : "reads dataset-driven + computes logic-driven"
PlantEnv }o--|| ProcessImage : "observe/apply via datastore"
```
### 8.7 Non-functional requirements (long-duration)
@@ -800,6 +874,18 @@ erDiagram
tick-order test.
11. A continuous scenario with no datasets, no scheduled steps, and no
`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
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.
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
block (0x1029, with reset button), module diagnosis (0x1050), LED error
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).
- FR-10.2.10: The UI's **configuration** pages (Ethernet/port settings,
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.
- FR-10.2.11: **Channel names** (requested 2026-10-03): every module
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
numbers within the same window; values are stored in the image
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)
@@ -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 /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-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
@@ -1047,7 +1188,10 @@ src/wago_sim/
| M4 | Diagnostics — watchdog/statistics/diagnosis view, reset, fault injection |
| 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) |
| M5b | Analog display ranges (FR-10.2.14) |
| 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
@@ -1063,6 +1207,13 @@ src/wago_sim/
with a visible message; the scenario continues unaffected.
6. Two simultaneous browsers stay consistent with each other and with
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
description, new image sizes) and visible in the web UI's I/O Config
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
@@ -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 |
|---|---|
@@ -1444,11 +1809,11 @@ the same process-image core.
| 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 |
| 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 I/O System 750 system description `m075xxxxx_xxxxxxxx_0en.pdf`
+8
View File
@@ -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
"""
+94
View File
@@ -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
+28
View File
@@ -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
+67
View File
@@ -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
+14
View File
@@ -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
+45
View File
@@ -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
+11
View File
@@ -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
+59
View File
@@ -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
+54
View File
@@ -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
+7 -6
View File
@@ -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
inputs: # simulated field signals (what the PLC reads)
# module-based addressing: module slot and
# channel number on the module, both 0-based
# 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: 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
pump_relay: { type: do, module: 2, channel: 0 } # DO2.0
valve_open: { type: do, module: 2, channel: 1 } # DO2.1
speed_setpoint: { type: ao, module: 5, channel: 0, scale: 0.1 } # AO5.0
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:
+100
View File
@@ -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())
+14 -3
View File
@@ -3,9 +3,10 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from dataclasses import dataclass, field
from .datastore import WagoDatastore
from .envrunner import EnvRunner
from .modules import ModuleType, VirtualNode
from .registers import ConfigRegisterBank
from .server import WagoSimServer
@@ -36,6 +37,8 @@ class SimulatorApp:
server: WagoSimServer
web: WebUIConfig | None = None
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
def modules(self) -> list[ModuleType]:
@@ -60,9 +63,15 @@ def rebuild_node(app: SimulatorApp, modules: list[ModuleType],
datastore = app.datastore
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
# 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()
app.node = VirtualNode(datastore, modules,
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(
analog_in=app.node.analog_in_bits,
analog_out=app.node.analog_out_bits,
@@ -145,7 +154,7 @@ def build_app(
idle_timeout_provider=lambda: registers.idle_timeout_ms,
)
server_ref: dict[str, WagoSimServer] = {"server": server}
return SimulatorApp(
app = SimulatorApp(
datastore=datastore,
node=node,
stats=stats,
@@ -154,4 +163,6 @@ def build_app(
server=server,
web=web,
node_config_path=node_config_path,
)
)
app.runner = EnvRunner(node)
return app
+91 -3
View File
@@ -35,6 +35,7 @@ import os
import signal
import sys
from pathlib import Path
from typing import NoReturn
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.
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,
node_config_path=args.node, channel_names=channel_names,
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()
web_server = 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})")
print(f"Simulating WAGO 750-362 with {len(modules)} modules "
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).
# 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:
pass # SIGINT via asyncio.run's handler
finally:
if app.runner.env is not None:
await app.runner.stop()
if web_server is not None:
task = getattr(web_server, "_serve_task", None)
web_server.should_exit = True
@@ -231,6 +246,34 @@ async def _serve(args: argparse.Namespace) -> int:
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:
node_config = _load_yaml_file(args.node, "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}")
engine.start()
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
while result is None and ticks < max_ticks:
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)
return 3
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)
if report_csv:
write_csv(result, report_csv)
@@ -285,6 +337,18 @@ def _validate(args: argparse.Namespace) -> int:
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:
parser = argparse.ArgumentParser(
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),
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)
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.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)
_build_logger(args.verbose)
# `--node` may come from the environment when omitted (service mode);
+184
View File
@@ -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
+966
View File
@@ -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
View File
@@ -196,28 +196,77 @@ class VirtualNode:
self.digital_in_bits = digital_in_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:
"""Text for firmware-info register 0x2030 (module description)."""
return ", ".join(f"{i}:{m.article}" for i, m in enumerate(self.modules))
# -- channel naming (spec FR-10.2.11) ----------------------------------
def io_code(self, kind: str, module: int, channel: int) -> str:
"""I/O code for a module channel, e.g. ('di', 0, 0) -> 'DI0.0'."""
return f"{kind.upper()}{module}.{channel}"
def io_code_at_slot(self, kind: str, module_slot: int, channel: int) -> str:
"""I/O code for a channel identified by PHYSICAL slot, e.g.
('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."""
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.
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(
self.io_code(kind, module, channel), self.DEFAULT_RANGE
)
if kind not in ("ai", "ao"):
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]:
"""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),
("ai", self._ai_channels), ("ao", self._ao_channels)):
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)
# -- named channel access (scenario engine API) -----------------------------
@@ -256,24 +305,57 @@ class VirtualNode:
raise IndexError(f"no {kind} channel {index} (have {len(pool)})")
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:
"""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
number*, e.g. DI0.1 = digital input, module slot 0, channel 1.
The module number is the position in the configured module list;
the channel number is the channel on that module (0-based), i.e.
DI0.1 selects the module's 2nd digital input.
number*, e.g. DI1.0 = digital inputs, the 2nd DI module, channel 0
(0-based) — **the module number counts within the module's type**
(FR-4.3.11 v1.5): DI modules are numbered DI0, DI1, … regardless
of their physical slots among DO/AI/AO modules.
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(
f"no module {module} (node has {len(self.modules)} modules, "
f"numbered 0-{len(self.modules) - 1})"
f"no {kind.upper()} module {module} "
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 = {
"di": (self._di_channels, mod.input_bits),
"do": (self._do_channels, mod.output_bits),
@@ -285,24 +367,41 @@ class VirtualNode:
pool, module_channel_count = pool_map[kind]
if module_channel_count == 0:
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:
raise IndexError(
f"module {module} ({mod.article}) has no {kind.upper()} "
f"channel {channel} (has {module_channel_count}: 0-"
f"{module_channel_count - 1})"
f"{kind.upper()} module {module} ({mod.article}) has no "
f"{kind.upper()} channel {channel} (has {module_channel_count}: "
f"0-{module_channel_count - 1})"
)
# find the location: channels of one module are contiguous in the
# kind pool; locate the first channel of this module in the pool
# channels of one module are contiguous in the kind pool; find the
# 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:
if loc.module_index == module and loc.channel == channel + 1:
if (loc.module_index == physical_slot
and loc.channel == channel + 1):
return loc
raise ChannelMismatch(
f"module {module} ({mod.article}) channel {channel} is not a "
f"{kind.upper()} channel"
f"{kind.upper()} module {module} ({mod.article}) 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) -------------------------------------
def set_di(self, channel: int, value: bool) -> None:
+260
View File
@@ -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
+195
View File
@@ -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()},
}
+45 -1
View File
@@ -136,6 +136,50 @@ def validate_scenario(scenario: dict) -> list[str]:
f"'module' is given"
)
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")
# -- 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
+239
View File
@@ -155,6 +155,122 @@ class ScenarioEngine:
}
self._macro_return_wait: float | None = None
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 ---------------------------------------------------------------
@@ -328,6 +444,8 @@ class ScenarioEngine:
return self._result
dt = dt_ms if dt_ms is not None else float(self.TICK_MS)
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
# written by the master/plant)
self._detect_edges()
@@ -377,6 +495,118 @@ class ScenarioEngine:
if fn is not 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:
"""Execute one queued step.
@@ -531,6 +761,15 @@ class ScenarioError(ValueError):
"""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:
"""Parse '250ms', '30s', '2m', or a raw number (milliseconds)."""
if isinstance(value, (int, float)):
+210
View File
@@ -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)
+403
View File
@@ -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
+6 -1
View File
@@ -22,7 +22,8 @@ def result_summary(result: ScenarioResult) -> str:
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)
with path.open("w", encoding="utf-8") as fh:
fh.write(json.dumps({
@@ -39,6 +40,10 @@ def write_jsonl(result: ScenarioResult, path: str | Path) -> None:
"detail": step.detail,
"sim_time_ms": step.sim_time_ms,
}) + "\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:
+293 -14
View File
@@ -14,7 +14,9 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
from ..app import SimulatorApp, rebuild_node
from ..modules import CATALOG, ChannelMismatch, ModuleUnknown, resolve_module
from ..hostnet import HostNetError, HostNetRefused, HostNetUnavailable
from ..modules import (CATALOG, ChannelMismatch, ModuleUnknown, VirtualNode,
resolve_module)
from ..scenario.engine import ScenarioError
from ..watchdog import RegisterWriteRefused
from . import state
@@ -260,6 +262,162 @@ def _payload_bool(payload: dict) -> bool:
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 --------------------------------------------------------
@@ -417,7 +575,7 @@ async def put_module_config(
save_node_config(sim.node_config_path, modules,
name=Path(sim.node_config_path).stem,
channel_names=sim.node.channel_names,
channel_ranges=sim.node.channel_ranges)
channel_ranges=_nondefault_ranges(sim))
except OSError as exc:
raise HTTPException(
status_code=500,
@@ -485,6 +643,7 @@ async def put_channel_names(
sim.node_config_path, sim.node.modules,
name=Path(sim.node_config_path).stem,
channel_names=names,
channel_ranges=sim.node.channel_ranges,
)
except OSError as exc:
raise HTTPException(
@@ -520,8 +679,10 @@ async def put_channel_ranges(
"""Set per-channel analog display ranges (FR-10.2.14).
Payload: {"channel_ranges": {"AI4.0": {"min": 0, "max": 32000}, ...}}.
Codes are regex-validated; 0 <= min < max <= 65535 required; an
entry with min=0/max=4095 (the default) is dropped from the store.
Codes are regex-validated; -65534 <= min < max <= 65535 required.
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)
raw = payload.get("channel_ranges")
@@ -550,10 +711,9 @@ async def put_channel_ranges(
status_code=400,
detail=f"range for {code_up!r}: need -65534 <= min < max <= 65535",
)
if (lo, hi) == (0, 4095):
continue # default — keep the store clean
ranges[code_up] = (lo, hi)
sim.node.channel_ranges = ranges
ranges[code_up] = (lo, hi) # dense store keeps defaults
sim.node.set_channel_ranges(ranges)
persisted = _nondefault_ranges(sim)
if payload.get("persist", True) and sim.node_config_path:
from .persist import save_node_config
try:
@@ -561,7 +721,7 @@ async def put_channel_ranges(
sim.node_config_path, sim.node.modules,
name=Path(sim.node_config_path).stem,
channel_names=sim.node.channel_names,
channel_ranges=ranges,
channel_ranges=persisted,
)
except OSError as exc:
raise HTTPException(
@@ -570,10 +730,119 @@ async def put_channel_ranges(
) from None
return {
"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) ---------------------------------
@@ -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")
async def export_config(
sim: SimulatorApp = Depends(get_sim),
@@ -606,7 +883,7 @@ async def export_config(
"modules": [m.article for m in sim.node.modules],
"channel_names": dict(sim.node.channel_names),
"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),
}
@@ -663,11 +940,13 @@ async def import_config(
if not -65534 <= lo < hi <= 65535:
raise HTTPException(status_code=400,
detail=f"range for {code_up!r}: need -65534 <= min < max <= 65535")
if (lo, hi) != (0, 4095):
ranges[code_up] = (lo, hi)
ranges[code_up] = (lo, hi) # defaults kept; the store is dense
coupler = payload.get("coupler_settings") or {}
# -- 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)
stale = sim.node.validate_channel_names()
if stale:
@@ -703,7 +982,7 @@ async def import_config(
sim.node_config_path, modules,
name=Path(sim.node_config_path).stem,
channel_names=names,
channel_ranges=ranges,
channel_ranges=_nondefault_ranges(sim),
)
except OSError as exc:
raise HTTPException(
+27 -1
View File
@@ -7,6 +7,7 @@ datastore. Static single-page front-end served from `webui/static/`.
from __future__ import annotations
import os
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
@@ -14,20 +15,43 @@ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from ..app import SimulatorApp
from ..hostnet import HostNetClient, HostNetService, hostnet_enabled
from .api import router
from .auth import SESSION_COOKIE, SessionStore
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(
sim: SimulatorApp,
*,
user: str,
password: str,
readonly: bool = False,
hostnet_service: object | None = None,
) -> 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(
title="WAGO 750-362 Simulator",
docs_url=None,
@@ -36,6 +60,8 @@ def build_webui(
)
app.state.sim = sim
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)
+12 -5
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
from pathlib import Path
from ..modules import ModuleType
from ..modules import ModuleType, VirtualNode
_HEADER = """\
# 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):
lines.append(f" {code}: {channel_names[code]}\n")
if channel_ranges:
lines.append(_RANGES_HEADER)
for code in sorted(channel_ranges):
lo, hi = channel_ranges[code]
lines.append(f" {code}: {{ min: {lo}, max: {hi} }}\n")
# lean YAML: the runtime store is dense (every analog channel,
# FR-10.2.14) — persist only explicit, non-default entries so a
# hand-edited config stays minimal and defaults survive a
# 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")
+22 -15
View File
@@ -16,15 +16,16 @@ def node_layout(node: VirtualNode) -> NodeLayoutJSON:
modules_json = []
for index, mod in enumerate(node.modules):
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:
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
io_code = f"{kind_upper}{index}.{channel_no}"
lo, hi = (0, 4095)
if ch.kind in ("ai", "ao"):
lo, hi = node.channel_ranges.get(io_code, (0, 4095))
io_code = f"{ch.kind.upper()}{per_type[ch.kind]}.{channel_no}"
# dense range store (FR-10.2.14): every analog channel has
# an entry (defaults materialized at layout time)
lo, hi = node.channel_ranges[io_code] \
if ch.kind in ("ai", "ao") else (0, 4095)
channels.append(
{
"name": ch.name,
@@ -75,23 +76,27 @@ def channel_values(node: VirtualNode) -> list[ChannelValue]:
out: list[ChannelValue] = []
# 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):
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(
{
"kind": "di",
"module": loc.module_index,
"module": kind_no,
"channel": loc.channel - 1,
"value": ds.digital_input_channel(i),
"name": node.channel_names.get(code),
}
)
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(
{
"kind": "do",
"module": loc.module_index,
"module": kind_no,
"channel": loc.channel - 1,
"value": ds.digital_output_channel(i),
"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
# value so the UI, the bar, and the configured range agree.
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(
{
"kind": "ai",
"module": loc.module_index,
"module": kind_no,
"channel": loc.channel - 1,
"value": _display_value(node, "ai", loc, ds.get_input_word(loc.word)),
"name": node.channel_names.get(code),
}
)
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(
{
"kind": "ao",
"module": loc.module_index,
"module": kind_no,
"channel": loc.channel - 1,
"value": _display_value(node, "ao", loc, ds.get_output_word(loc.word)),
"name": node.channel_names.get(code),
+330 -14
View File
@@ -28,9 +28,91 @@ async function init() {
renderRail();
renderChannelList();
if (diag) renderDiagnostics(diag);
refreshEnvCard();
probeHostnet();
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> &nbsp;${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() {
const proto = location.protocol === "https:" ? "wss" : "ws";
WS = new WebSocket(`${proto}://${location.host}/api/ws`);
@@ -75,19 +157,20 @@ function renderChannelList() {
for (const mod of NODE.modules) {
for (const ch of mod.channels) {
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>`
: `<span class="row-name unnamed">—</span>`;
let control;
if (ch.kind === "di" || ch.kind === "do") {
control = `<label class="switch"><input type="checkbox" class="row-value"
id="val-${ch.kind}-${mod.index}-${no}"
onchange="listWrite('${ch.kind}', ${mod.index}, ${no}, this)">
id="val-${ch.kind}-${mno}-${no}"
onchange="listWrite('${ch.kind}', ${mno}, ${no}, this)">
<span class="slider"></span></label>`;
} else {
control = `<input type="number" class="row-value num" min="-65534" max="65535"
id="val-${ch.kind}-${mod.index}-${no}"
onchange="listWrite('${ch.kind}', ${mod.index}, ${no}, this)">`;
id="val-${ch.kind}-${mno}-${no}"
onchange="listWrite('${ch.kind}', ${mno}, ${no}, this)">`;
}
html += `<div class="chlist-row" id="${id}" title="${ch.name} · MB ${ch.modbus}">
<span class="row-io">${ch.address}</span>
@@ -253,7 +336,6 @@ async function importConfig(file) {
function renderRail() {
const rail = document.getElementById("rail");
rail.innerHTML = "";
// coupler block on the left
const coupler = document.createElement("div");
coupler.className = "coupler";
@@ -293,7 +375,7 @@ function renderRail() {
html += `<div class="mod-channels ${isDigital ? "mod-channels-v" : ""}">`;
let chIndex = 0;
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
if (isDigital) {
if (chIndex > 0 && chIndex % 8 === 0) {
@@ -302,7 +384,7 @@ function renderRail() {
}
html += `<span class="led-cell">
<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>`;
chIndex += 1;
} else {
@@ -312,7 +394,8 @@ function renderRail() {
html += `<span class="abar-cell">
<span class="abar-label top-label">${chNo}</span>
<span class="lim-led lim-over" id="${id}-over" title="overflow (&gt; ${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="lim-led lim-under" id="${id}-under" title="underflow (&lt; ${rg.min})"></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;
}
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) {
const id = `ch-${kind}-${module}-${channel}`;
const el = document.getElementById(id);
@@ -370,9 +460,12 @@ function updateChannel(kind, module, channel, value) {
function findChannel(kind, module, channel) {
if (!NODE) return null;
for (const mod of NODE.modules) {
if (mod.index !== module) continue;
return mod.channels.find((c) => c.kind === kind &&
channelNo(c.address) === channel) || null;
for (const c of mod.channels) {
if (c.kind === kind && moduleNo(c.address) === module
&& channelNo(c.address) === channel) {
return c;
}
}
}
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 ----------------
async function renderDiagnostics(diag) {
@@ -495,13 +610,25 @@ async function renderConfig() {
renderNameEditor();
const list = document.getElementById("module-list");
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">&#9776;</span>
<input type="radio" name="mod" value="${i}">
<b>${i}</b>&nbsp; ${m.article} — ${m.description}
<span class="mod-io">${m.input_bits ? m.input_bits + " DI" : ""}
${m.output_bits ? m.output_bits + " DO" : ""}
${m.input_words ? m.input_words + " AIw" : ""}
${m.output_words ? m.output_words + " AOW" : ""}</span>
<span class="row-buttons">
<button class="mini" onclick="moveModule(${i}, -1)"
${i === 0 ? "disabled" : ""} title="Move up">&#9650;</button>
<button class="mini" onclick="moveModule(${i}, +1)"
${i === mc.modules.length - 1 ? "disabled" : ""}
title="Move down">&#9660;</button>
</span>
</label>`).join("");
const pick = document.getElementById("catalog-pick");
pick.innerHTML = mc.catalog.map((c) =>
@@ -597,18 +724,206 @@ document.addEventListener("DOMContentLoaded", () => {
}
});
document.getElementById("module-save").addEventListener("click", saveModules);
setInterval(refreshEnvCard, 1000); // env status: 1 Hz
});
function renderModuleRows() {
// local edit view until SAVE CONFIG is pressed
const list = document.getElementById("module-list");
list.innerHTML = MODULE_ARTICLES.map((a, i) => `
<label class="module-row">
<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">&#9776;</span>
<input type="radio" name="mod" value="${i}">
<b>${i}</b>&nbsp; ${a}
<span class="row-buttons">
<button class="mini" onclick="moveModule(${i}, -1)"
${i === 0 ? "disabled" : ""} title="Move up">&#9650;</button>
<button class="mini" onclick="moveModule(${i}, +1)"
${i === MODULE_ARTICLES.length - 1 ? "disabled" : ""}
title="Move down">&#9660;</button>
</span>
</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 ----------------
document.querySelectorAll(".tab").forEach((btn) => {
@@ -619,6 +934,7 @@ document.querySelectorAll(".tab").forEach((btn) => {
document.getElementById(`tab-${btn.dataset.tab}`).classList.add("active");
if (btn.dataset.tab === "diagnostics") refreshDiagnostics();
if (btn.dataset.tab === "config") renderConfig();
if (btn.dataset.tab === "hostnet") refreshHostnet();
});
});
+29 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<title>WAGO 750-362 Simulator</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/style.css?v=5">
</head>
<body>
<header class="topbar">
@@ -19,6 +19,7 @@
<button data-tab="overview" class="tab active">Overview</button>
<button data-tab="config" class="tab">Configuration</button>
<button data-tab="diagnostics" class="tab">Diagnostics</button>
<button data-tab="hostnet" class="tab hidden" id="tab-btn-hostnet">Host network</button>
</nav>
<div class="topbar-right">
<span id="conn-state" class="conn-dot" title="WebSocket state"></span>
@@ -33,6 +34,12 @@
<div id="rail" class="rail"></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>
<div class="chlist-head">
<span>I/O</span><span>Name</span><span>Value</span>
@@ -109,8 +116,28 @@
<div class="diag-card" id="diag-faults"></div>
</div>
</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>
<script src="/static/app.js"></script>
<script src="/static/app.js?v=5"></script>
</body>
</html>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<title>WAGO Simulator — Login</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/style.css?v=5">
</head>
<body class="login-body">
<div class="login-card">
+73 -1
View File
@@ -190,6 +190,7 @@ h2 { margin: 4px 0 14px; }
border-radius: 3px;
overflow: hidden; /* clip the fill inside the rounded bar */
display: inline-block;
cursor: pointer; /* clickable: sets value at click height */
}
.abar-fill {
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; }
.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 ---------- */
@@ -258,6 +277,27 @@ h2 { margin: 4px 0 14px; }
}
.module-row:hover { background: #eaf1f8; }
.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; }
.cfg-path { font-size: 0.75rem; color: #6b7684; align-self: center; }
button.primary { background: #2e7d32; }
@@ -336,4 +376,36 @@ button.primary:hover { background: #1b5e20; }
}
.range-sep { color: #6b7684; }
.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;
}
+581
View File
@@ -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
+530
View File
@@ -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
+39 -27
View File
@@ -88,13 +88,15 @@ class TestAliases:
class TestModuleAddressing:
"""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]
(indices 0..5). Module addressing uses 0-based module positions and
0-based channel numbers on the module.
The pump node fixture modules: [DI_8, DI_16, DO_8, DO_16, AI_2, AO_2].
Module addressing (FR-4.3.11 v1.5) numbers modules 0-based WITHIN
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):
# 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["inputs"]["second_di"] = {"type": "di", "module": 0, "channel": 1}
engine = ScenarioEngine(node, scenario)
@@ -104,35 +106,42 @@ class TestModuleAddressing:
assert engine.alias_value("second_di") is True
def test_module_addressing_across_modules(self, node, datastore):
# DI1.0: module slot 1 (DI_16), channel 0 = 9th DI globally
# (8 channels on module 0 come first)
# DI1.0: DI module 1 (DI_16, physical slot 1), channel 0 = 9th DI
scenario = _scenario()
scenario["inputs"]["di16_first"] = {"type": "di", "module": 1, "channel": 0}
engine = ScenarioEngine(node, scenario)
engine.set_alias("di16_first", True)
assert datastore.read_bits(8, 1) == [True]
def test_module_addressing_do(self, node, datastore):
# DO2.2: module slot 2 (DO_8), channel 2 = 3rd DO globally
def test_module_addressing_do_interleaved(self, node, datastore):
# 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["outputs"]["third_do"] = {"type": "do", "module": 2, "channel": 2}
scenario["outputs"]["third_do"] = {"type": "do", "module": 0, "channel": 2}
engine = ScenarioEngine(node, scenario)
bit = engine.alias_location("third_do").bit
assert bit == 2
assert datastore.write_bits(bit, [True]) is None
assert engine.alias_value("third_do") is True
def test_module_addressing_ai_ao(self, node, datastore):
# AI4.1: module slot 4 (AI_2), channel 1 = 2nd AI word
def test_do_module_1_is_do16(self, node):
# DO1.0: DO module 1 is the DO_16 at physical slot 3
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.set_alias("ai2", 0x1234)
loc = node.channel("ai", 2) # global 2nd AI
assert datastore.get_input_word(loc.word) == 0x1234
# AO5.1: module slot 5 (AO_2), channel 1 = 2nd AO word
scenario["outputs"] = {"ao2": {"type": "ao", "module": 5, "channel": 1}}
# AO0.1: AO module 0 (AO_2 at physical slot 5), channel 1 = 2nd AO word
scenario["outputs"] = {"ao2": {"type": "ao", "module": 0, "channel": 1}}
engine2 = ScenarioEngine(node, scenario)
bit_word = node.channel("ao", 2).word
assert datastore.write_words(bit_word, [0xBEEF]) is None
@@ -149,31 +158,34 @@ class TestModuleAddressing:
def test_module_out_of_range_rejected(self, node):
scenario = _scenario()
scenario["inputs"]["bad"] = {"type": "di", "module": 99, "channel": 0}
with pytest.raises(IndexError, match="module 99"):
# the node has only 2 DI modules
scenario["inputs"]["bad"] = {"type": "di", "module": 2, "channel": 0}
with pytest.raises(IndexError, match="DI module 2"):
ScenarioEngine(node, scenario)
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["inputs"]["bad"] = {"type": "di", "module": 0, "channel": 8}
with pytest.raises(IndexError, match="channel 8"):
ScenarioEngine(node, scenario)
def test_kind_mismatch_rejected(self, node):
# module 0 is DI-only -> DO addressing must fail
def test_module_kind_routes_to_own_type(self, node):
"""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["outputs"]["bad"] = {"type": "do", "module": 0, "channel": 0}
from wago_sim.modules import ChannelMismatch
with pytest.raises(ChannelMismatch):
ScenarioEngine(node, scenario)
scenario["outputs"]["do8"] = {"type": "do", "module": 0, "channel": 0}
scenario["outputs"]["do16"] = {"type": "do", "module": 1, "channel": 0}
engine = 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):
# 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["inputs"]["bad"] = {"type": "ai", "module": 0, "channel": 0}
from wago_sim.modules import ChannelMismatch
with pytest.raises(ChannelMismatch):
scenario["inputs"]["bad"] = {"type": "ai", "module": 9, "channel": 0}
with pytest.raises(IndexError):
ScenarioEngine(node, scenario)
+264 -40
View File
@@ -3,6 +3,9 @@ diagnostics — exercised in-process via httpx ASGITransport (no socket)."""
from __future__ import annotations
import asyncio
import sys
import httpx
import pytest
@@ -106,7 +109,7 @@ class TestManipulation:
async def test_toggle_do_forces_output_image(self, client, datastore):
# DO_8 sits at module slot 2 in the pump fixture
r = await client.post("/api/channels/do/2/0", json={"action": "toggle"})
r = await client.post("/api/channels/do/0/0", json={"action": "toggle"})
assert r.status_code == 200
# DO channel 1 is forced ON in the OUTPUT image (master-side)
assert datastore.digital_output_channel(1) is True
@@ -117,13 +120,13 @@ class TestManipulation:
async def test_ai_value_set(self, client, datastore):
# AI_2 sits at module slot 4 in the pump fixture (DI_8, DI_16,
# DO_8, DO_16, AI_2, AO_2); its first word is input word 0
r = await client.post("/api/channels/ai/4/0", json={"value": 200})
r = await client.post("/api/channels/ai/0/0", json={"value": 200})
assert r.status_code == 200
assert r.json()["value"] == 200
assert datastore.get_input_word(0) == 200
async def test_ao_force_writes_output_image(self, client, datastore):
r = await client.post("/api/channels/ao/5/0", json={"value": 1234})
r = await client.post("/api/channels/ao/0/0", json={"value": 1234})
assert r.status_code == 200
# written at the output image word 0 -> read back at +0x0200
assert datastore.read_words(0x0200, 1) == [1234]
@@ -133,9 +136,13 @@ class TestManipulation:
assert r.status_code == 404
async def test_kind_mismatch_400(self, client):
# DI_16 module (index 1) has no DO channels
r = await client.post("/api/channels/do/1/0", json={"action": "toggle"})
assert r.status_code == 400
# per-type numbering (v1.5): DO module 0 is the DO_8 — asking
# for a DO channel beyond its 8 channels is out of range (404);
# 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:
@@ -187,6 +194,190 @@ class TestReadOnlyMode:
r = await c.post("/api/diagnostics/reset-stats")
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:
async def test_get_coupler_config(self, client, app):
@@ -319,7 +510,7 @@ class TestModuleConfig:
class TestChannelNames:
async def test_names_in_layout_and_values(self, client, app):
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,
})
assert r.status_code == 200
@@ -331,23 +522,23 @@ class TestChannelNames:
# values snapshot carries it too
r = await client.get("/api/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"
async def test_names_persist_to_yaml(self, client, app, tmp_path):
cfg_file = tmp_path / "node.yaml"
app.node_config_path = str(cfg_file)
r = await client.put("/api/config/channel-names", json={
"channel_names": {"DO2.0": "pump_relay"}, "persist": True,
"channel_names": {"DO0.0": "pump_relay"}, "persist": True,
})
assert r.status_code == 200
text = cfg_file.read_text(encoding="utf-8")
assert "channel_names:" in text
assert "DO2.0: pump_relay" in text
assert "DO0.0: pump_relay" in text
# and it loads back
from wago_sim.scenario.dsl import load_yaml, load_channel_names
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):
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):
r = await client.put("/api/config/channel-ranges", json={
"channel_ranges": {"AI4.0": {"min": 0, "max": 32000},
"AO5.0": {"min": 400, "max": 2000}},
"channel_ranges": {"AI0.0": {"min": 0, "max": 32000},
"AO0.0": {"min": 400, "max": 2000}},
"persist": False,
})
assert r.status_code == 200
# layout reflects the configured range
r = await client.get("/api/node")
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 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={
"channel_ranges": {"AI4.0": {"min": 0, "max": 4095}}, # == default
"channel_ranges": {"AI0.0": {"min": 0, "max": 4095}}, # == default
"persist": False,
})
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):
base = {"persist": False}
@@ -490,71 +700,71 @@ class TestChannelRanges:
"channel_ranges": {"XX4.0": {"min": 0, "max": 100}}, **base})
assert r.status_code == 400 # bad code
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
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
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
async def test_negative_range_accepted(self, client, app):
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,
})
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
r = await client.get("/api/node")
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}
async def test_range_floor_is_minus_65534(self, client):
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
async def test_signed_value_display_and_write(self, client, app, datastore):
# channel with a negative-min range -> signed two's-complement view
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})
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
# raw image holds the two's-complement word
assert datastore.get_input_word(0) == (-2000) & 0xFFFF
# values API shows the signed number again
r = await client.get("/api/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
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 datastore.get_output_word(0) == (-3000) & 0xFFFF
# unsigned AO (default range) still displays raw
r = await client.get("/api/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
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
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
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
async def test_ranges_persist_to_yaml(self, client, app, tmp_path):
cfg_file = tmp_path / "node.yaml"
app.node_config_path = str(cfg_file)
r = await client.put("/api/config/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
text = cfg_file.read_text(encoding="utf-8")
@@ -563,18 +773,32 @@ class TestChannelRanges:
# loads back
from wago_sim.scenario.dsl import load_yaml, load_channel_ranges
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):
app.node_config_path = str(tmp_path / "node.yaml")
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={
"modules": ["750-430", "750-467"], "persist": True,
})
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
# semantics differ: stale name codes are dropped on import only)
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):
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()
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")
r = await client.post("/api/config/import", json=exported)
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")
assert "min: 600, max: 2000" in text