🚧 Change module numbering
This commit is contained in:
Vendored
+199
@@ -1003,6 +1003,205 @@
|
|||||||
],
|
],
|
||||||
"isBackground": false,
|
"isBackground": false,
|
||||||
"problemMatcher": []
|
"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": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -22,9 +22,11 @@ a virtual node whose I/O behavior is driven by scripted scenarios.
|
|||||||
pulses, waits, and PASS/FAIL assertions with timeouts; state machines
|
pulses, waits, and PASS/FAIL assertions with timeouts; state machines
|
||||||
and Python action plugins for complex plant models.
|
and Python action plugins for complex plant models.
|
||||||
- **Two I/O addressing schemes** for aliases: *module-based* —
|
- **Two I/O addressing schemes** for aliases: *module-based* —
|
||||||
`{ type: di, module: 2, channel: 1 }` = `DI2.1` (module slot and
|
`{ type: di, module: 2, channel: 1 }` = `DI2.1` (module **numbered
|
||||||
channel both 0-based) — and *pool-based* — `{ type: di, channel: 3 }`
|
0-based within its type** — `DI0`, `DO0`, `AI0`, `AO0` start each
|
||||||
(1-based index across the node's DI pool). Both may be mixed.
|
kind — and channel 0-based; v1.5) — and *pool-based* —
|
||||||
|
`{ type: di, channel: 3 }` (1-based index across the node's DI
|
||||||
|
pool). Both may be mixed.
|
||||||
- **Bench mode** *(requested, planned — spec §9)*: run the simulator
|
- **Bench mode** *(requested, planned — spec §9)*: run the simulator
|
||||||
without a scenario (`wago-sim serve --node ...` already works);
|
without a scenario (`wago-sim serve --node ...` already works);
|
||||||
planned additions: `--set DI0.1=ON` startup seeding, an interactive
|
planned additions: `--set DI0.1=ON` startup seeding, an interactive
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Virtual WAGO node configuration — saved from the web UI (SAVE CONFIG)
|
||||||
|
# Simulated device: WAGO 750-362 Modbus TCP coupler
|
||||||
|
# Edit by hand or via the UI; comments are not preserved on UI saves.
|
||||||
|
name: pump_node
|
||||||
|
coupler: 750-362
|
||||||
|
|
||||||
|
modules:
|
||||||
|
- "750-430" # 8-channel DI 24VDC
|
||||||
|
- "750-1405" # 16-channel DI 24VDC
|
||||||
|
- "750-530" # 8-channel DO 24VDC
|
||||||
|
- "750-1516" # 16-channel DO 24VDC
|
||||||
|
- "750-467" # 2-channel AI 4-20mA
|
||||||
|
- "750-554" # 2-channel AO 4-20mA
|
||||||
|
|
||||||
|
# Application names for module channels (I/O code -> name)
|
||||||
|
channel_names:
|
||||||
|
DI0.0: P1_DI
|
||||||
|
DI0.1: P1_AUTO
|
||||||
|
DI0.2: P1_FEL
|
||||||
|
DI0.3: rsv1
|
||||||
|
DO0.1: P1_START
|
||||||
|
AO0.0: P1_BORV
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Virtual WAGO node configuration — saved from the web UI (SAVE CONFIG)
|
||||||
|
# Simulated device: WAGO 750-362 Modbus TCP coupler
|
||||||
|
# Edit by hand or via the UI; comments are not preserved on UI saves.
|
||||||
|
name: pump_node
|
||||||
|
coupler: 750-362
|
||||||
|
|
||||||
|
modules:
|
||||||
|
- "750-1405" # 16-channel DI 24VDC
|
||||||
|
- "750-430" # 8-channel DI 24VDC
|
||||||
|
- "750-530" # 8-channel DO 24VDC
|
||||||
|
- "750-1516" # 16-channel DO 24VDC
|
||||||
|
- "750-467" # 2-channel AI 4-20mA
|
||||||
|
- "750-554" # 2-channel AO 4-20mA
|
||||||
|
|
||||||
|
# Application names for module channels (I/O code -> name)
|
||||||
|
channel_names:
|
||||||
|
AO0.0: speed_setpoint
|
||||||
|
DI0.0: start_button
|
||||||
@@ -5,8 +5,8 @@ name: pump_node
|
|||||||
coupler: 750-362
|
coupler: 750-362
|
||||||
|
|
||||||
modules:
|
modules:
|
||||||
- "750-430" # 8-channel DI 24VDC
|
|
||||||
- "750-1405" # 16-channel DI 24VDC
|
- "750-1405" # 16-channel DI 24VDC
|
||||||
|
- "750-430" # 8-channel DI 24VDC
|
||||||
- "750-530" # 8-channel DO 24VDC
|
- "750-530" # 8-channel DO 24VDC
|
||||||
- "750-1516" # 16-channel DO 24VDC
|
- "750-1516" # 16-channel DO 24VDC
|
||||||
- "750-467" # 2-channel AI 4-20mA
|
- "750-467" # 2-channel AI 4-20mA
|
||||||
@@ -14,5 +14,6 @@ modules:
|
|||||||
|
|
||||||
# Application names for module channels (I/O code -> name)
|
# Application names for module channels (I/O code -> name)
|
||||||
channel_names:
|
channel_names:
|
||||||
AO5.0: speed_setpoint
|
AO0.0: speed_setpoint
|
||||||
DI0.0: start_button
|
DI0.0: start_button
|
||||||
|
DI1.0: test
|
||||||
|
|||||||
@@ -303,14 +303,23 @@ sequence: # time-ordered script (or fully state-driven)
|
|||||||
- FR-4.3.11: **Module-based channel addressing** — aliases may be addressed
|
- FR-4.3.11: **Module-based channel addressing** — aliases may be addressed
|
||||||
as *module type + module number + channel number*, all components
|
as *module type + module number + channel number*, all components
|
||||||
explicitly configurable: `{ type: di, module: 2, channel: 1 }` selects
|
explicitly configurable: `{ type: di, module: 2, channel: 1 }` selects
|
||||||
digital input channel 1 (0-based) on module slot 2 (0-based position in
|
digital input channel 1 (0-based) on DI-module 2 (0-based **number
|
||||||
the node's module list), i.e. `DI2.1`. Both numbers are 0-based to match
|
among the modules of the same type**, i.e. the third DI module in the
|
||||||
physical slot/channel labeling. The channel kind must match the module
|
node), i.e. `DI2.1`. Channel numbers are 0-based. The channel kind must
|
||||||
(`DO` on a DI-only module is a validation error). This is an alternative
|
match the module (`DO` on a DI-only module is a validation error).
|
||||||
to pool addressing (`{ type: di, channel: 3 }` = 3rd DI channel across
|
This is an alternative to addressing by physical slot; both pool
|
||||||
the whole node); both schemes are supported and may be mixed, but an
|
addressing (`{ type: di, channel: 3 }` = 3rd DI channel across the
|
||||||
alias uses exactly one. **Implemented** in v1.1 (`channel_by_module`,
|
whole node) and module addressing are supported and may be mixed,
|
||||||
`Alias.module`, `alias_location`).
|
but an alias uses exactly one. **Implemented** in v1.1
|
||||||
|
(`channel_by_module`, `Alias.module`, `alias_location`).
|
||||||
|
**Changed in v1.5 (2026-10-04, breaking)**: module numbers previously
|
||||||
|
counted physical slots (all modules in one sequence); they now number
|
||||||
|
**within the module's type** — `DI0`/`DO0`/`AI0`/`AO0` start each kind's
|
||||||
|
sequence (e.g. a node `[DI_8, DI_16, DO_8, DO_16, AI_2, AO_2]` numbers
|
||||||
|
its DI modules `DI0`, `DI1` regardless of the DI/DO interleaving).
|
||||||
|
This matches how channels are named on real WAGO benches and makes
|
||||||
|
rearranging modules less disruptive to existing configs. Scenarios,
|
||||||
|
channel names, and analog ranges use the per-type codes.
|
||||||
|
|
||||||
### 4.4 Example FAT flow
|
### 4.4 Example FAT flow
|
||||||
|
|
||||||
@@ -1126,6 +1135,14 @@ This feature request closes that gap.
|
|||||||
- FR-11.2.5: A reconfiguration event is logged (sim-time, change
|
- FR-11.2.5: A reconfiguration event is logged (sim-time, change
|
||||||
description, new image sizes) and visible in the web UI's I/O Config
|
description, new image sizes) and visible in the web UI's I/O Config
|
||||||
page, mirroring the WBM SAVE CONFIG flow.
|
page, mirroring the WBM SAVE CONFIG flow.
|
||||||
|
- FR-11.2.6: **Module reordering** (requested 2026-10-04): the web UI's
|
||||||
|
I/O configuration pane supports rearranging modules — move up/down
|
||||||
|
controls per module slot. Reordering triggers the same process-image
|
||||||
|
rebuild as any module change (FR-11.2.1–11.2.3) and persists the new
|
||||||
|
order. Because I/O codes number modules per type (FR-4.3.11 v1.5),
|
||||||
|
moving a module past a module of a different kind does not renumber
|
||||||
|
the other kind's channels — only the relative order within the same
|
||||||
|
type affects addressing.
|
||||||
|
|
||||||
### 11.3 Requirements — address-selection DIP switch emulation
|
### 11.3 Requirements — address-selection DIP switch emulation
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Example plant/PLC model plugin for the pump-station FAT scenario.
|
||||||
|
|
||||||
|
In a real FAT bench the PLC under test drives the outputs. For a
|
||||||
|
headless demo (or pre-FAT scenario smoke tests) this plugin emulates
|
||||||
|
the expected PLC behavior so the scenario can PASS end-to-end.
|
||||||
|
|
||||||
|
Register it in a scenario via:
|
||||||
|
|
||||||
|
plugins: [plugins.pump_plant.pump_plant]
|
||||||
|
|
||||||
|
The engine calls it once per tick.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# latch state per engine instance (module-level dict keyed by id)
|
||||||
|
_LATCH = {}
|
||||||
|
|
||||||
|
|
||||||
|
def pump_plant(engine, value) -> None:
|
||||||
|
"""Emulated PLC with classic start/stop latch circuit:
|
||||||
|
|
||||||
|
- rising edge on start_button latches the pump ON
|
||||||
|
- e-stop OFF breaks the latch (and the safe state follows)
|
||||||
|
- valve follows the pump; speed setpoint follows level above 4000
|
||||||
|
"""
|
||||||
|
key = id(engine)
|
||||||
|
start = bool(engine.alias_value("start_button"))
|
||||||
|
em_stop = bool(engine.alias_value("em_stop_ok"))
|
||||||
|
level = float(engine.alias_value("level_sensor"))
|
||||||
|
|
||||||
|
# rising edge of the start button sets the latch; e-stop clears it
|
||||||
|
if engine.consume_rising_edge("start_button"):
|
||||||
|
_LATCH[key] = True
|
||||||
|
if not em_stop:
|
||||||
|
_LATCH[key] = False
|
||||||
|
|
||||||
|
pump_on = _LATCH.get(key, False) and em_stop
|
||||||
|
speed = int(max(0.0, (level - 4000.0) * 10.0)) if pump_on else 0
|
||||||
|
|
||||||
|
_set_do(engine, "pump_relay", pump_on)
|
||||||
|
_set_do(engine, "valve_open", pump_on)
|
||||||
|
_set_ao(engine, "speed_setpoint", speed)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_do(engine, alias_name: str, value: bool) -> None:
|
||||||
|
"""Emulate a Modbus master writing the output coil.
|
||||||
|
|
||||||
|
Uses the engine's global alias location: ``loc.bit`` is the 0-based
|
||||||
|
global digital channel index == the Modbus bit address (Table 122).
|
||||||
|
"""
|
||||||
|
loc = engine.alias_location(alias_name)
|
||||||
|
result = engine.datastore.write_bits(loc.bit, [bool(value)])
|
||||||
|
assert not isinstance(result, Exception), result
|
||||||
|
|
||||||
|
|
||||||
|
def _set_ao(engine, alias_name: str, value: int) -> None:
|
||||||
|
"""Emulate a Modbus master writing the output register.
|
||||||
|
|
||||||
|
``loc.word`` is the global analog output word index == the Modbus
|
||||||
|
word address in output area 1 (0x0000-0x00FF).
|
||||||
|
"""
|
||||||
|
alias = engine._aliases[alias_name]
|
||||||
|
loc = engine.alias_location(alias_name)
|
||||||
|
raw = int(value * alias.scale + alias.offset) & 0xFFFF
|
||||||
|
result = engine.datastore.write_words(loc.word, [raw])
|
||||||
|
assert not isinstance(result, Exception), result
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Example FAT scenario for a pump-station PLC program.
|
||||||
|
#
|
||||||
|
# With a REAL PLC under test: run `wago-sim serve` against this node
|
||||||
|
# configuration; the PLC drives the outputs and the scenario asserts on
|
||||||
|
# them. Remove the `plugins:` line below — the PLC is the plant.
|
||||||
|
#
|
||||||
|
# Headless (no PLC): the plugins line loads an emulated plant model so
|
||||||
|
# the scenario can be smoke-tested end-to-end before the FAT bench is
|
||||||
|
# wired.
|
||||||
|
|
||||||
|
name: pump_station_fat
|
||||||
|
description: "Basic FAT round: start button -> pump relay -> valve -> level ramp"
|
||||||
|
|
||||||
|
plugins: [plugins.pump_plant.pump_plant] # remove when a real PLC is connected
|
||||||
|
|
||||||
|
inputs: # simulated field signals (what the PLC reads)
|
||||||
|
# module-based addressing: module number is
|
||||||
|
# 0-based WITHIN ITS TYPE (DI0, DO0, AI0,
|
||||||
|
# AO0 — FR-4.3.11 v1.5); channel 0-based
|
||||||
|
start_button: { type: di, module: 0, channel: 0 } # DI0.0
|
||||||
|
em_stop_ok: { type: di, module: 0, channel: 1 } # DI0.1
|
||||||
|
level_sensor: { type: ai, module: 0, channel: 0, scale: 0.1 } # AI0.0, level mm -> counts
|
||||||
|
|
||||||
|
outputs: # PLC outputs observed by the scenario
|
||||||
|
pump_relay: { type: do, module: 0, channel: 0 } # DO0.0
|
||||||
|
valve_open: { type: do, module: 0, channel: 1 } # DO0.1
|
||||||
|
speed_setpoint: { type: ao, module: 0, channel: 0, scale: 0.1 } # AO0.0
|
||||||
|
|
||||||
|
macros:
|
||||||
|
pulse_start:
|
||||||
|
- set: { start_button: ON }
|
||||||
|
- wait: 250ms
|
||||||
|
- set: { start_button: OFF }
|
||||||
|
|
||||||
|
sequence:
|
||||||
|
# --- startup checks -------------------------------------------------------
|
||||||
|
- set: { em_stop_ok: ON }
|
||||||
|
- assert: { label: "pump idle", conditions: { pump_relay: OFF }, within: 1s }
|
||||||
|
|
||||||
|
# --- start sequence ---------------------------------------------------------
|
||||||
|
- macro: pulse_start
|
||||||
|
- assert: { label: "pump starts", conditions: { pump_relay: ON }, within: 2s }
|
||||||
|
- assert: { label: "valve opens", conditions: { valve_open: ON }, within: 2s }
|
||||||
|
|
||||||
|
# --- process dynamics -------------------------------------------------------
|
||||||
|
- ramp: { name: level_sensor, to: 8500, over: 30s }
|
||||||
|
- wait: 31s
|
||||||
|
- assert: { label: "speed setpoint raised", conditions: { speed_setpoint: ">= 1000" }, within: 3s }
|
||||||
|
|
||||||
|
# --- stop sequence ------------------------------------------------------------
|
||||||
|
- set: { em_stop_ok: OFF }
|
||||||
|
- assert: { label: "safe state on e-stop", conditions: { pump_relay: OFF, valve_open: OFF }, within: 2s }
|
||||||
|
|
||||||
|
- report: PASS
|
||||||
@@ -14,16 +14,17 @@ description: "Basic FAT round: start button -> pump relay -> valve -> level ramp
|
|||||||
plugins: [plugins.pump_plant.pump_plant] # remove when a real PLC is connected
|
plugins: [plugins.pump_plant.pump_plant] # remove when a real PLC is connected
|
||||||
|
|
||||||
inputs: # simulated field signals (what the PLC reads)
|
inputs: # simulated field signals (what the PLC reads)
|
||||||
# module-based addressing: module slot and
|
# module-based addressing: module number is
|
||||||
# channel number on the module, both 0-based
|
# 0-based WITHIN ITS TYPE (DI0, DO0, AI0,
|
||||||
|
# AO0 — FR-4.3.11 v1.5); channel 0-based
|
||||||
start_button: { type: di, module: 0, channel: 0 } # DI0.0
|
start_button: { type: di, module: 0, channel: 0 } # DI0.0
|
||||||
em_stop_ok: { type: di, module: 0, channel: 1 } # DI0.1
|
em_stop_ok: { type: di, module: 0, channel: 1 } # DI0.1
|
||||||
level_sensor: { type: ai, module: 4, channel: 0, scale: 0.1 } # AI4.0, level mm -> counts
|
level_sensor: { type: ai, module: 0, channel: 0, scale: 0.1 } # AI0.0, level mm -> counts
|
||||||
|
|
||||||
outputs: # PLC outputs observed by the scenario
|
outputs: # PLC outputs observed by the scenario
|
||||||
pump_relay: { type: do, module: 2, channel: 0 } # DO2.0
|
pump_relay: { type: do, module: 0, channel: 0 } # DO0.0
|
||||||
valve_open: { type: do, module: 2, channel: 1 } # DO2.1
|
valve_open: { type: do, module: 0, channel: 1 } # DO0.1
|
||||||
speed_setpoint: { type: ao, module: 5, channel: 0, scale: 0.1 } # AO5.0
|
speed_setpoint: { type: ao, module: 0, channel: 0, scale: 0.1 } # AO0.0
|
||||||
|
|
||||||
macros:
|
macros:
|
||||||
pulse_start:
|
pulse_start:
|
||||||
|
|||||||
+88
-27
@@ -202,21 +202,32 @@ class VirtualNode:
|
|||||||
|
|
||||||
# -- channel naming (spec FR-10.2.11) ----------------------------------
|
# -- channel naming (spec FR-10.2.11) ----------------------------------
|
||||||
|
|
||||||
def io_code(self, kind: str, module: int, channel: int) -> str:
|
def io_code_at_slot(self, kind: str, module_slot: int, channel: int) -> str:
|
||||||
"""I/O code for a module channel, e.g. ('di', 0, 0) -> 'DI0.0'."""
|
"""I/O code for a channel identified by PHYSICAL slot, e.g.
|
||||||
return f"{kind.upper()}{module}.{channel}"
|
('di', 0, 0) -> 'DI0.0' (first DI module). Internal callers that
|
||||||
|
walk the channel pools (slot-keyed) use this; the code itself
|
||||||
|
carries the per-type module number (FR-4.3.11 v1.5)."""
|
||||||
|
try:
|
||||||
|
per_type = self.kind_index(module_slot).get(kind)
|
||||||
|
except IndexError:
|
||||||
|
return f"{kind.upper()}?.{channel}"
|
||||||
|
if per_type is None:
|
||||||
|
return f"{kind.upper()}?.{channel}"
|
||||||
|
return f"{kind.upper()}{per_type}.{channel}"
|
||||||
|
|
||||||
def channel_name(self, kind: str, module: int, channel: int) -> str | None:
|
def channel_name(self, kind: str, module_slot: int, channel: int) -> str | None:
|
||||||
"""Configured application name for a channel, or None."""
|
"""Configured application name for a channel, or None."""
|
||||||
return self.channel_names.get(self.io_code(kind, module, channel))
|
return self.channel_names.get(
|
||||||
|
self.io_code_at_slot(kind, module_slot, channel)
|
||||||
|
)
|
||||||
|
|
||||||
def channel_range(self, kind: str, module: int, channel: int) -> tuple[int, int]:
|
def channel_range(self, kind: str, module_slot: int, channel: int) -> tuple[int, int]:
|
||||||
"""Display range (min, max) raw counts for an analog channel.
|
"""Display range (min, max) raw counts for an analog channel.
|
||||||
|
|
||||||
Defaults to (0, 4095) when unconfigured (FR-10.2.14).
|
Defaults to (0, 4095) when unconfigured (FR-10.2.14).
|
||||||
"""
|
"""
|
||||||
return self.channel_ranges.get(
|
return self.channel_ranges.get(
|
||||||
self.io_code(kind, module, channel), self.DEFAULT_RANGE
|
self.io_code_at_slot(kind, module_slot, channel), self.DEFAULT_RANGE
|
||||||
)
|
)
|
||||||
|
|
||||||
def validate_channel_names(self) -> list[str]:
|
def validate_channel_names(self) -> list[str]:
|
||||||
@@ -226,7 +237,7 @@ class VirtualNode:
|
|||||||
for kind, pool in (("di", self._di_channels), ("do", self._do_channels),
|
for kind, pool in (("di", self._di_channels), ("do", self._do_channels),
|
||||||
("ai", self._ai_channels), ("ao", self._ao_channels)):
|
("ai", self._ai_channels), ("ao", self._ao_channels)):
|
||||||
for loc in pool:
|
for loc in pool:
|
||||||
known.add(self.io_code(kind, loc.module_index, loc.channel - 1))
|
known.add(self.io_code_at_slot(kind, loc.module_index, loc.channel - 1))
|
||||||
return sorted(set(self.channel_names) - known)
|
return sorted(set(self.channel_names) - known)
|
||||||
|
|
||||||
# -- named channel access (scenario engine API) -----------------------------
|
# -- named channel access (scenario engine API) -----------------------------
|
||||||
@@ -256,24 +267,57 @@ class VirtualNode:
|
|||||||
raise IndexError(f"no {kind} channel {index} (have {len(pool)})")
|
raise IndexError(f"no {kind} channel {index} (have {len(pool)})")
|
||||||
return pool[index - 1]
|
return pool[index - 1]
|
||||||
|
|
||||||
|
def kind_modules(self, kind: str) -> list[ModuleType]:
|
||||||
|
"""Modules providing the given kind, in physical order.
|
||||||
|
|
||||||
|
`channel_by_module` numbers modules 0-based within this list
|
||||||
|
(FR-4.3.11 v1.5: DI0, DO0, AI0, AO0 start each kind's sequence).
|
||||||
|
"""
|
||||||
|
kind_field = {
|
||||||
|
"di": "input_bits", "do": "output_bits",
|
||||||
|
"ai": "input_words", "ao": "output_words",
|
||||||
|
}
|
||||||
|
if kind not in kind_field:
|
||||||
|
raise ValueError(f"unknown channel kind: {kind!r}")
|
||||||
|
field = kind_field[kind]
|
||||||
|
return [m for m in self.modules
|
||||||
|
if getattr(m, kind_field[kind]) > 0]
|
||||||
|
|
||||||
|
def kind_index(self, module_index: int) -> dict[str, int]:
|
||||||
|
"""Per-type number (FR-4.3.11 v1.5) for the module at the given
|
||||||
|
physical slot: {'di': 0, ...} for the first DI module etc."""
|
||||||
|
mod = self.modules[module_index]
|
||||||
|
out: dict[str, int] = {}
|
||||||
|
for kind in ("di", "do", "ai", "ao"):
|
||||||
|
field = {"di": "input_bits", "do": "output_bits",
|
||||||
|
"ai": "input_words", "ao": "output_words"}[kind]
|
||||||
|
if getattr(mod, field) == 0:
|
||||||
|
continue
|
||||||
|
same_kind = [i for i, m in enumerate(self.modules)
|
||||||
|
if getattr(m, field) > 0]
|
||||||
|
out[kind] = same_kind.index(module_index)
|
||||||
|
return out
|
||||||
|
|
||||||
def channel_by_module(self, kind: str, module: int, channel: int) -> ChannelLocation:
|
def channel_by_module(self, kind: str, module: int, channel: int) -> ChannelLocation:
|
||||||
"""Access a channel by module position and channel number, both 0-based.
|
"""Access a channel by module number and channel number, both 0-based.
|
||||||
|
|
||||||
Addresses a channel as *module type + module number + channel
|
Addresses a channel as *module type + module number + channel
|
||||||
number*, e.g. DI0.1 = digital input, module slot 0, channel 1.
|
number*, e.g. DI1.0 = digital inputs, the 2nd DI module, channel 0
|
||||||
The module number is the position in the configured module list;
|
(0-based) — **the module number counts within the module's type**
|
||||||
the channel number is the channel on that module (0-based), i.e.
|
(FR-4.3.11 v1.5): DI modules are numbered DI0, DI1, … regardless
|
||||||
DI0.1 selects the module's 2nd digital input.
|
of their physical slots among DO/AI/AO modules.
|
||||||
|
|
||||||
The channel kind must match what the module actually provides —
|
The channel kind must match what the module actually provides —
|
||||||
DI0.1 on a module with no inputs is a configuration error.
|
DI1.0 on a module with no inputs is a configuration error.
|
||||||
"""
|
"""
|
||||||
if not 0 <= module < len(self.modules):
|
modules_of_kind = self.kind_modules(kind)
|
||||||
|
if not 0 <= module < len(modules_of_kind):
|
||||||
raise IndexError(
|
raise IndexError(
|
||||||
f"no module {module} (node has {len(self.modules)} modules, "
|
f"no {kind.upper()} module {module} "
|
||||||
f"numbered 0-{len(self.modules) - 1})"
|
f"(node has {len(modules_of_kind)} {kind.upper()} modules, "
|
||||||
|
f"numbered 0-{len(modules_of_kind) - 1} within the type)"
|
||||||
)
|
)
|
||||||
mod = self.modules[module]
|
mod = modules_of_kind[module]
|
||||||
pool_map = {
|
pool_map = {
|
||||||
"di": (self._di_channels, mod.input_bits),
|
"di": (self._di_channels, mod.input_bits),
|
||||||
"do": (self._do_channels, mod.output_bits),
|
"do": (self._do_channels, mod.output_bits),
|
||||||
@@ -285,24 +329,41 @@ class VirtualNode:
|
|||||||
pool, module_channel_count = pool_map[kind]
|
pool, module_channel_count = pool_map[kind]
|
||||||
if module_channel_count == 0:
|
if module_channel_count == 0:
|
||||||
raise ChannelMismatch(
|
raise ChannelMismatch(
|
||||||
f"module {module} ({mod.article}) has no {kind.upper()} channels"
|
f"{kind.upper()} module {module} ({mod.article}) has no "
|
||||||
|
f"{kind.upper()} channels"
|
||||||
)
|
)
|
||||||
if not 0 <= channel < module_channel_count:
|
if not 0 <= channel < module_channel_count:
|
||||||
raise IndexError(
|
raise IndexError(
|
||||||
f"module {module} ({mod.article}) has no {kind.upper()} "
|
f"{kind.upper()} module {module} ({mod.article}) has no "
|
||||||
f"channel {channel} (has {module_channel_count}: 0-"
|
f"{kind.upper()} channel {channel} (has {module_channel_count}: "
|
||||||
f"{module_channel_count - 1})"
|
f"0-{module_channel_count - 1})"
|
||||||
)
|
)
|
||||||
# find the location: channels of one module are contiguous in the
|
# channels of one module are contiguous in the kind pool; find the
|
||||||
# kind pool; locate the first channel of this module in the pool
|
# physical slot of the per-type module and its matching location
|
||||||
|
physical_slot = [
|
||||||
|
i for i, m in enumerate(self.modules) if m is mod
|
||||||
|
][0]
|
||||||
for loc in pool:
|
for loc in pool:
|
||||||
if loc.module_index == module and loc.channel == channel + 1:
|
if (loc.module_index == physical_slot
|
||||||
|
and loc.channel == channel + 1):
|
||||||
return loc
|
return loc
|
||||||
raise ChannelMismatch(
|
raise ChannelMismatch(
|
||||||
f"module {module} ({mod.article}) channel {channel} is not a "
|
f"{kind.upper()} module {module} ({mod.article}) channel "
|
||||||
f"{kind.upper()} channel"
|
f"{channel} is not a {kind.upper()} channel"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def io_code(self, kind: str, module: int, channel: int) -> str:
|
||||||
|
"""I/O code with the PER-TYPE module number, e.g. ('di', 0, 0) -> 'DI0.0'
|
||||||
|
where the module number is the index within the module's type
|
||||||
|
(FR-4.3.11 v1.5)."""
|
||||||
|
modules_of_kind = self.kind_modules(kind)
|
||||||
|
if not 0 <= module < len(modules_of_kind):
|
||||||
|
raise IndexError(
|
||||||
|
f"no {kind.upper()} module {module} "
|
||||||
|
f"(node has {len(modules_of_kind)} {kind.upper()} modules)"
|
||||||
|
)
|
||||||
|
return f"{kind.upper()}{module}.{channel}"
|
||||||
|
|
||||||
# -- field-side setters (simulated world) -------------------------------------
|
# -- field-side setters (simulated world) -------------------------------------
|
||||||
|
|
||||||
def set_di(self, channel: int, value: bool) -> None:
|
def set_di(self, channel: int, value: bool) -> None:
|
||||||
|
|||||||
+18
-12
@@ -16,12 +16,12 @@ def node_layout(node: VirtualNode) -> NodeLayoutJSON:
|
|||||||
modules_json = []
|
modules_json = []
|
||||||
for index, mod in enumerate(node.modules):
|
for index, mod in enumerate(node.modules):
|
||||||
channels = []
|
channels = []
|
||||||
|
# per-type module number for this slot (FR-4.3.11 v1.5):
|
||||||
|
# a DI module at physical slot 2 with no earlier DI module is DI0
|
||||||
|
per_type = node.kind_index(index)
|
||||||
for ch in mod.channels:
|
for ch in mod.channels:
|
||||||
kind_upper = ch.kind.upper()
|
|
||||||
# module-based addressing per FR-4.3.11: {KIND}{module}.{channel}
|
|
||||||
# `module` is the 0-based slot; `channel` is 0-based on module.
|
|
||||||
channel_no = int(ch.name.split()[-1]) - 1
|
channel_no = int(ch.name.split()[-1]) - 1
|
||||||
io_code = f"{kind_upper}{index}.{channel_no}"
|
io_code = f"{ch.kind.upper()}{per_type[ch.kind]}.{channel_no}"
|
||||||
lo, hi = (0, 4095)
|
lo, hi = (0, 4095)
|
||||||
if ch.kind in ("ai", "ao"):
|
if ch.kind in ("ai", "ao"):
|
||||||
lo, hi = node.channel_ranges.get(io_code, (0, 4095))
|
lo, hi = node.channel_ranges.get(io_code, (0, 4095))
|
||||||
@@ -75,23 +75,27 @@ def channel_values(node: VirtualNode) -> list[ChannelValue]:
|
|||||||
out: list[ChannelValue] = []
|
out: list[ChannelValue] = []
|
||||||
|
|
||||||
# Digital channels: global 1-based numbers, bit-packed after analog.
|
# Digital channels: global 1-based numbers, bit-packed after analog.
|
||||||
|
# `module` in each entry is the PER-TYPE module number (FR-4.3.11
|
||||||
|
# v1.5) so the UI round-trips the same number the codes carry.
|
||||||
for i, loc in enumerate(node.di, start=1):
|
for i, loc in enumerate(node.di, start=1):
|
||||||
code = node.io_code("di", loc.module_index, loc.channel - 1)
|
kind_no = node.kind_index(loc.module_index)["di"]
|
||||||
|
code = node.io_code_at_slot("di", loc.module_index, loc.channel - 1)
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"kind": "di",
|
"kind": "di",
|
||||||
"module": loc.module_index,
|
"module": kind_no,
|
||||||
"channel": loc.channel - 1,
|
"channel": loc.channel - 1,
|
||||||
"value": ds.digital_input_channel(i),
|
"value": ds.digital_input_channel(i),
|
||||||
"name": node.channel_names.get(code),
|
"name": node.channel_names.get(code),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
for i, loc in enumerate(node.do, start=1):
|
for i, loc in enumerate(node.do, start=1):
|
||||||
code = node.io_code("do", loc.module_index, loc.channel - 1)
|
kind_no = node.kind_index(loc.module_index)["do"]
|
||||||
|
code = node.io_code_at_slot("do", loc.module_index, loc.channel - 1)
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"kind": "do",
|
"kind": "do",
|
||||||
"module": loc.module_index,
|
"module": kind_no,
|
||||||
"channel": loc.channel - 1,
|
"channel": loc.channel - 1,
|
||||||
"value": ds.digital_output_channel(i),
|
"value": ds.digital_output_channel(i),
|
||||||
"name": node.channel_names.get(code),
|
"name": node.channel_names.get(code),
|
||||||
@@ -102,22 +106,24 @@ def channel_values(node: VirtualNode) -> list[ChannelValue]:
|
|||||||
# FR-10.2.14), the raw word is shown as its 16-bit two's-complement
|
# FR-10.2.14), the raw word is shown as its 16-bit two's-complement
|
||||||
# value so the UI, the bar, and the configured range agree.
|
# value so the UI, the bar, and the configured range agree.
|
||||||
for loc in node.ai:
|
for loc in node.ai:
|
||||||
code = node.io_code("ai", loc.module_index, loc.channel - 1)
|
kind_no = node.kind_index(loc.module_index)["ai"]
|
||||||
|
code = node.io_code_at_slot("ai", loc.module_index, loc.channel - 1)
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"kind": "ai",
|
"kind": "ai",
|
||||||
"module": loc.module_index,
|
"module": kind_no,
|
||||||
"channel": loc.channel - 1,
|
"channel": loc.channel - 1,
|
||||||
"value": _display_value(node, "ai", loc, ds.get_input_word(loc.word)),
|
"value": _display_value(node, "ai", loc, ds.get_input_word(loc.word)),
|
||||||
"name": node.channel_names.get(code),
|
"name": node.channel_names.get(code),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
for loc in node.ao:
|
for loc in node.ao:
|
||||||
code = node.io_code("ao", loc.module_index, loc.channel - 1)
|
kind_no = node.kind_index(loc.module_index)["ao"]
|
||||||
|
code = node.io_code_at_slot("ao", loc.module_index, loc.channel - 1)
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"kind": "ao",
|
"kind": "ao",
|
||||||
"module": loc.module_index,
|
"module": kind_no,
|
||||||
"channel": loc.channel - 1,
|
"channel": loc.channel - 1,
|
||||||
"value": _display_value(node, "ao", loc, ds.get_output_word(loc.word)),
|
"value": _display_value(node, "ao", loc, ds.get_output_word(loc.word)),
|
||||||
"name": node.channel_names.get(code),
|
"name": node.channel_names.get(code),
|
||||||
|
|||||||
@@ -75,19 +75,20 @@ function renderChannelList() {
|
|||||||
for (const mod of NODE.modules) {
|
for (const mod of NODE.modules) {
|
||||||
for (const ch of mod.channels) {
|
for (const ch of mod.channels) {
|
||||||
const no = channelNo(ch.address);
|
const no = channelNo(ch.address);
|
||||||
const id = `row-${ch.kind}-${mod.index}-${no}`;
|
const mno = moduleNo(ch.address); // per-type module number
|
||||||
|
const id = `row-${ch.kind}-${mno}-${no}`;
|
||||||
const named = ch.channel_name ? `<span class="row-name">${ch.channel_name}</span>`
|
const named = ch.channel_name ? `<span class="row-name">${ch.channel_name}</span>`
|
||||||
: `<span class="row-name unnamed">—</span>`;
|
: `<span class="row-name unnamed">—</span>`;
|
||||||
let control;
|
let control;
|
||||||
if (ch.kind === "di" || ch.kind === "do") {
|
if (ch.kind === "di" || ch.kind === "do") {
|
||||||
control = `<label class="switch"><input type="checkbox" class="row-value"
|
control = `<label class="switch"><input type="checkbox" class="row-value"
|
||||||
id="val-${ch.kind}-${mod.index}-${no}"
|
id="val-${ch.kind}-${mno}-${no}"
|
||||||
onchange="listWrite('${ch.kind}', ${mod.index}, ${no}, this)">
|
onchange="listWrite('${ch.kind}', ${mno}, ${no}, this)">
|
||||||
<span class="slider"></span></label>`;
|
<span class="slider"></span></label>`;
|
||||||
} else {
|
} else {
|
||||||
control = `<input type="number" class="row-value num" min="-65534" max="65535"
|
control = `<input type="number" class="row-value num" min="-65534" max="65535"
|
||||||
id="val-${ch.kind}-${mod.index}-${no}"
|
id="val-${ch.kind}-${mno}-${no}"
|
||||||
onchange="listWrite('${ch.kind}', ${mod.index}, ${no}, this)">`;
|
onchange="listWrite('${ch.kind}', ${mno}, ${no}, this)">`;
|
||||||
}
|
}
|
||||||
html += `<div class="chlist-row" id="${id}" title="${ch.name} · MB ${ch.modbus}">
|
html += `<div class="chlist-row" id="${id}" title="${ch.name} · MB ${ch.modbus}">
|
||||||
<span class="row-io">${ch.address}</span>
|
<span class="row-io">${ch.address}</span>
|
||||||
@@ -253,7 +254,6 @@ async function importConfig(file) {
|
|||||||
function renderRail() {
|
function renderRail() {
|
||||||
const rail = document.getElementById("rail");
|
const rail = document.getElementById("rail");
|
||||||
rail.innerHTML = "";
|
rail.innerHTML = "";
|
||||||
|
|
||||||
// coupler block on the left
|
// coupler block on the left
|
||||||
const coupler = document.createElement("div");
|
const coupler = document.createElement("div");
|
||||||
coupler.className = "coupler";
|
coupler.className = "coupler";
|
||||||
@@ -293,7 +293,7 @@ function renderRail() {
|
|||||||
html += `<div class="mod-channels ${isDigital ? "mod-channels-v" : ""}">`;
|
html += `<div class="mod-channels ${isDigital ? "mod-channels-v" : ""}">`;
|
||||||
let chIndex = 0;
|
let chIndex = 0;
|
||||||
for (const ch of mod.channels) {
|
for (const ch of mod.channels) {
|
||||||
const id = `ch-${ch.kind}-${mod.index}-${channelNo(ch.address)}`;
|
const id = `ch-${ch.kind}-${moduleNo(ch.address)}-${channelNo(ch.address)}`;
|
||||||
const chNo = String(channelNo(ch.address)); // 0-based channel only
|
const chNo = String(channelNo(ch.address)); // 0-based channel only
|
||||||
if (isDigital) {
|
if (isDigital) {
|
||||||
if (chIndex > 0 && chIndex % 8 === 0) {
|
if (chIndex > 0 && chIndex % 8 === 0) {
|
||||||
@@ -302,7 +302,7 @@ function renderRail() {
|
|||||||
}
|
}
|
||||||
html += `<span class="led-cell">
|
html += `<span class="led-cell">
|
||||||
<span class="led" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})"
|
<span class="led" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})"
|
||||||
onclick="clickChannel('${ch.kind}', ${mod.index}, ${channelNo(ch.address)})"></span>
|
onclick="clickChannel('${ch.kind}', ${moduleNo(ch.address)}, ${channelNo(ch.address)})"></span>
|
||||||
<span class="led-label">${chNo}</span></span>`;
|
<span class="led-label">${chNo}</span></span>`;
|
||||||
chIndex += 1;
|
chIndex += 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -313,7 +313,7 @@ function renderRail() {
|
|||||||
<span class="abar-label top-label">${chNo}</span>
|
<span class="abar-label top-label">${chNo}</span>
|
||||||
<span class="lim-led lim-over" id="${id}-over" title="overflow (> ${rg.max})"></span>
|
<span class="lim-led lim-over" id="${id}-over" title="overflow (> ${rg.max})"></span>
|
||||||
<span class="abar" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})"
|
<span class="abar" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})"
|
||||||
onclick="clickBar('${ch.kind}', ${mod.index}, ${channelNo(ch.address)}, event)">
|
onclick="clickBar('${ch.kind}', ${moduleNo(ch.address)}, ${channelNo(ch.address)}, event)">
|
||||||
<span class="abar-fill"></span></span>
|
<span class="abar-fill"></span></span>
|
||||||
<span class="lim-led lim-under" id="${id}-under" title="underflow (< ${rg.min})"></span>
|
<span class="lim-led lim-under" id="${id}-under" title="underflow (< ${rg.min})"></span>
|
||||||
<span class="abar-label bottom-label">${chNo}</span>
|
<span class="abar-label bottom-label">${chNo}</span>
|
||||||
@@ -337,6 +337,13 @@ function channelNo(address) {
|
|||||||
return dot >= 0 ? Number(address.slice(dot + 1)) : 0;
|
return dot >= 0 ? Number(address.slice(dot + 1)) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moduleNo(address) {
|
||||||
|
// address is like "DI2.1" — the module part before the dot;
|
||||||
|
// per FR-4.3.11 v1.5 this is the PER-TYPE module number (DI0, DO0…)
|
||||||
|
const dot = address.indexOf(".");
|
||||||
|
return dot >= 0 ? Number(address.slice(2, dot)) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
function updateChannel(kind, module, channel, value) {
|
function updateChannel(kind, module, channel, value) {
|
||||||
const id = `ch-${kind}-${module}-${channel}`;
|
const id = `ch-${kind}-${module}-${channel}`;
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
@@ -371,9 +378,12 @@ function updateChannel(kind, module, channel, value) {
|
|||||||
function findChannel(kind, module, channel) {
|
function findChannel(kind, module, channel) {
|
||||||
if (!NODE) return null;
|
if (!NODE) return null;
|
||||||
for (const mod of NODE.modules) {
|
for (const mod of NODE.modules) {
|
||||||
if (mod.index !== module) continue;
|
for (const c of mod.channels) {
|
||||||
return mod.channels.find((c) => c.kind === kind &&
|
if (c.kind === kind && moduleNo(c.address) === module
|
||||||
channelNo(c.address) === channel) || null;
|
&& channelNo(c.address) === channel) {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -525,6 +535,13 @@ async function renderConfig() {
|
|||||||
${m.output_bits ? m.output_bits + " DO" : ""}
|
${m.output_bits ? m.output_bits + " DO" : ""}
|
||||||
${m.input_words ? m.input_words + " AIw" : ""}
|
${m.input_words ? m.input_words + " AIw" : ""}
|
||||||
${m.output_words ? m.output_words + " AOW" : ""}</span>
|
${m.output_words ? m.output_words + " AOW" : ""}</span>
|
||||||
|
<span class="row-buttons">
|
||||||
|
<button class="mini" onclick="moveModule(${i}, -1)"
|
||||||
|
${i === 0 ? "disabled" : ""} title="Move up">▲</button>
|
||||||
|
<button class="mini" onclick="moveModule(${i}, +1)"
|
||||||
|
${i === mc.modules.length - 1 ? "disabled" : ""}
|
||||||
|
title="Move down">▼</button>
|
||||||
|
</span>
|
||||||
</label>`).join("");
|
</label>`).join("");
|
||||||
const pick = document.getElementById("catalog-pick");
|
const pick = document.getElementById("catalog-pick");
|
||||||
pick.innerHTML = mc.catalog.map((c) =>
|
pick.innerHTML = mc.catalog.map((c) =>
|
||||||
@@ -629,9 +646,26 @@ function renderModuleRows() {
|
|||||||
<label class="module-row">
|
<label class="module-row">
|
||||||
<input type="radio" name="mod" value="${i}">
|
<input type="radio" name="mod" value="${i}">
|
||||||
<b>${i}</b> ${a}
|
<b>${i}</b> ${a}
|
||||||
|
<span class="row-buttons">
|
||||||
|
<button class="mini" onclick="moveModule(${i}, -1)"
|
||||||
|
${i === 0 ? "disabled" : ""} title="Move up">▲</button>
|
||||||
|
<button class="mini" onclick="moveModule(${i}, +1)"
|
||||||
|
${i === MODULE_ARTICLES.length - 1 ? "disabled" : ""}
|
||||||
|
title="Move down">▼</button>
|
||||||
|
</span>
|
||||||
</label>`).join("");
|
</label>`).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function moveModule(index, delta) {
|
||||||
|
// FR-11.2.6: reorder by swapping with the neighbor, then SAVE CONFIG
|
||||||
|
// (rebuild + persist) so the rail reflects the new physical order
|
||||||
|
const target = index + delta;
|
||||||
|
if (target < 0 || target >= MODULE_ARTICLES.length) return;
|
||||||
|
[MODULE_ARTICLES[index], MODULE_ARTICLES[target]] =
|
||||||
|
[MODULE_ARTICLES[target], MODULE_ARTICLES[index]];
|
||||||
|
await saveModules();
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------- tabs ----------------
|
// ---------------- tabs ----------------
|
||||||
|
|
||||||
document.querySelectorAll(".tab").forEach((btn) => {
|
document.querySelectorAll(".tab").forEach((btn) => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>WAGO 750-362 Simulator</title>
|
<title>WAGO 750-362 Simulator</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css?v=2">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@@ -111,6 +111,6 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js?v=2"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>WAGO Simulator — Login</title>
|
<title>WAGO Simulator — Login</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css?v=2">
|
||||||
</head>
|
</head>
|
||||||
<body class="login-body">
|
<body class="login-body">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
|
|||||||
@@ -259,6 +259,14 @@ h2 { margin: 4px 0 14px; }
|
|||||||
}
|
}
|
||||||
.module-row:hover { background: #eaf1f8; }
|
.module-row:hover { background: #eaf1f8; }
|
||||||
.mod-io { color: #55606c; margin-left: auto; font-size: 0.78rem; white-space: nowrap; }
|
.mod-io { color: #55606c; margin-left: auto; font-size: 0.78rem; white-space: nowrap; }
|
||||||
|
.row-buttons { display: inline-flex; gap: 3px; margin-left: 6px; }
|
||||||
|
button.mini {
|
||||||
|
padding: 1px 6px; border: 1px solid #c5ccd4; border-radius: 3px;
|
||||||
|
background: #f0f3f6; color: #33414f; font-size: 0.6rem;
|
||||||
|
line-height: 1.2; cursor: pointer;
|
||||||
|
}
|
||||||
|
button.mini:hover:not(:disabled) { background: #dce7f2; }
|
||||||
|
button.mini:disabled { opacity: 0.35; cursor: default; }
|
||||||
#catalog-pick { padding: 6px; border: 1px solid #c5ccd4; border-radius: 4px; flex: 1; }
|
#catalog-pick { padding: 6px; border: 1px solid #c5ccd4; border-radius: 4px; flex: 1; }
|
||||||
.cfg-path { font-size: 0.75rem; color: #6b7684; align-self: center; }
|
.cfg-path { font-size: 0.75rem; color: #6b7684; align-self: center; }
|
||||||
button.primary { background: #2e7d32; }
|
button.primary { background: #2e7d32; }
|
||||||
|
|||||||
@@ -88,13 +88,15 @@ class TestAliases:
|
|||||||
class TestModuleAddressing:
|
class TestModuleAddressing:
|
||||||
"""Module type + module number + channel number addressing (DI0.1).
|
"""Module type + module number + channel number addressing (DI0.1).
|
||||||
|
|
||||||
The pump node fixture modules: [DI_8, DI_16, DO_8, DO_16, AI_2, AO_2]
|
The pump node fixture modules: [DI_8, DI_16, DO_8, DO_16, AI_2, AO_2].
|
||||||
(indices 0..5). Module addressing uses 0-based module positions and
|
Module addressing (FR-4.3.11 v1.5) numbers modules 0-based WITHIN
|
||||||
0-based channel numbers on the module.
|
THEIR TYPE: DI0=DI_8, DI1=DI_16, DO0=DO_8, DO1=DO_16, AI0=AI_2,
|
||||||
|
AO0=AO_2 — regardless of physical slot interleaving. Channels are
|
||||||
|
0-based on the module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def test_module_addressing_di(self, node, datastore):
|
def test_module_addressing_di(self, node, datastore):
|
||||||
# DI0.1: module slot 0 (DI_8), channel 1 (0-based) = 2nd DI
|
# DI0.1: DI module 0 (DI_8), channel 1 (0-based) = 2nd DI
|
||||||
scenario = _scenario()
|
scenario = _scenario()
|
||||||
scenario["inputs"]["second_di"] = {"type": "di", "module": 0, "channel": 1}
|
scenario["inputs"]["second_di"] = {"type": "di", "module": 0, "channel": 1}
|
||||||
engine = ScenarioEngine(node, scenario)
|
engine = ScenarioEngine(node, scenario)
|
||||||
@@ -104,35 +106,42 @@ class TestModuleAddressing:
|
|||||||
assert engine.alias_value("second_di") is True
|
assert engine.alias_value("second_di") is True
|
||||||
|
|
||||||
def test_module_addressing_across_modules(self, node, datastore):
|
def test_module_addressing_across_modules(self, node, datastore):
|
||||||
# DI1.0: module slot 1 (DI_16), channel 0 = 9th DI globally
|
# DI1.0: DI module 1 (DI_16, physical slot 1), channel 0 = 9th DI
|
||||||
# (8 channels on module 0 come first)
|
|
||||||
scenario = _scenario()
|
scenario = _scenario()
|
||||||
scenario["inputs"]["di16_first"] = {"type": "di", "module": 1, "channel": 0}
|
scenario["inputs"]["di16_first"] = {"type": "di", "module": 1, "channel": 0}
|
||||||
engine = ScenarioEngine(node, scenario)
|
engine = ScenarioEngine(node, scenario)
|
||||||
engine.set_alias("di16_first", True)
|
engine.set_alias("di16_first", True)
|
||||||
assert datastore.read_bits(8, 1) == [True]
|
assert datastore.read_bits(8, 1) == [True]
|
||||||
|
|
||||||
def test_module_addressing_do(self, node, datastore):
|
def test_module_addressing_do_interleaved(self, node, datastore):
|
||||||
# DO2.2: module slot 2 (DO_8), channel 2 = 3rd DO globally
|
# DO0.2: DO module 0 is the DO_8 at PHYSICAL SLOT 2 — per-type
|
||||||
|
# numbering means module 0, not slot 2
|
||||||
scenario = _scenario()
|
scenario = _scenario()
|
||||||
scenario["outputs"]["third_do"] = {"type": "do", "module": 2, "channel": 2}
|
scenario["outputs"]["third_do"] = {"type": "do", "module": 0, "channel": 2}
|
||||||
engine = ScenarioEngine(node, scenario)
|
engine = ScenarioEngine(node, scenario)
|
||||||
bit = engine.alias_location("third_do").bit
|
bit = engine.alias_location("third_do").bit
|
||||||
assert bit == 2
|
assert bit == 2
|
||||||
assert datastore.write_bits(bit, [True]) is None
|
assert datastore.write_bits(bit, [True]) is None
|
||||||
assert engine.alias_value("third_do") is True
|
assert engine.alias_value("third_do") is True
|
||||||
|
|
||||||
def test_module_addressing_ai_ao(self, node, datastore):
|
def test_do_module_1_is_do16(self, node):
|
||||||
# AI4.1: module slot 4 (AI_2), channel 1 = 2nd AI word
|
# DO1.0: DO module 1 is the DO_16 at physical slot 3
|
||||||
scenario = _scenario()
|
scenario = _scenario()
|
||||||
scenario["inputs"]["ai2"] = {"type": "ai", "module": 4, "channel": 1, "scale": 1.0}
|
scenario["outputs"]["x"] = {"type": "do", "module": 1, "channel": 0}
|
||||||
|
engine = ScenarioEngine(node, scenario)
|
||||||
|
assert engine.alias_location("x").bit == 8 # after DO_8's 8 channels
|
||||||
|
|
||||||
|
def test_module_addressing_ai_ao(self, node, datastore):
|
||||||
|
# AI0.1: AI module 0 (AI_2 at physical slot 4), channel 1 = 2nd AI word
|
||||||
|
scenario = _scenario()
|
||||||
|
scenario["inputs"]["ai2"] = {"type": "ai", "module": 0, "channel": 1, "scale": 1.0}
|
||||||
engine = ScenarioEngine(node, scenario)
|
engine = ScenarioEngine(node, scenario)
|
||||||
engine.set_alias("ai2", 0x1234)
|
engine.set_alias("ai2", 0x1234)
|
||||||
loc = node.channel("ai", 2) # global 2nd AI
|
loc = node.channel("ai", 2) # global 2nd AI
|
||||||
assert datastore.get_input_word(loc.word) == 0x1234
|
assert datastore.get_input_word(loc.word) == 0x1234
|
||||||
|
|
||||||
# AO5.1: module slot 5 (AO_2), channel 1 = 2nd AO word
|
# AO0.1: AO module 0 (AO_2 at physical slot 5), channel 1 = 2nd AO word
|
||||||
scenario["outputs"] = {"ao2": {"type": "ao", "module": 5, "channel": 1}}
|
scenario["outputs"] = {"ao2": {"type": "ao", "module": 0, "channel": 1}}
|
||||||
engine2 = ScenarioEngine(node, scenario)
|
engine2 = ScenarioEngine(node, scenario)
|
||||||
bit_word = node.channel("ao", 2).word
|
bit_word = node.channel("ao", 2).word
|
||||||
assert datastore.write_words(bit_word, [0xBEEF]) is None
|
assert datastore.write_words(bit_word, [0xBEEF]) is None
|
||||||
@@ -149,31 +158,34 @@ class TestModuleAddressing:
|
|||||||
|
|
||||||
def test_module_out_of_range_rejected(self, node):
|
def test_module_out_of_range_rejected(self, node):
|
||||||
scenario = _scenario()
|
scenario = _scenario()
|
||||||
scenario["inputs"]["bad"] = {"type": "di", "module": 99, "channel": 0}
|
# the node has only 2 DI modules
|
||||||
with pytest.raises(IndexError, match="module 99"):
|
scenario["inputs"]["bad"] = {"type": "di", "module": 2, "channel": 0}
|
||||||
|
with pytest.raises(IndexError, match="DI module 2"):
|
||||||
ScenarioEngine(node, scenario)
|
ScenarioEngine(node, scenario)
|
||||||
|
|
||||||
def test_channel_out_of_range_rejected(self, node):
|
def test_channel_out_of_range_rejected(self, node):
|
||||||
# module 0 is a DI_8 -> channels 0-7 only
|
# DI module 0 is a DI_8 -> channels 0-7 only
|
||||||
scenario = _scenario()
|
scenario = _scenario()
|
||||||
scenario["inputs"]["bad"] = {"type": "di", "module": 0, "channel": 8}
|
scenario["inputs"]["bad"] = {"type": "di", "module": 0, "channel": 8}
|
||||||
with pytest.raises(IndexError, match="channel 8"):
|
with pytest.raises(IndexError, match="channel 8"):
|
||||||
ScenarioEngine(node, scenario)
|
ScenarioEngine(node, scenario)
|
||||||
|
|
||||||
def test_kind_mismatch_rejected(self, node):
|
def test_module_kind_routes_to_own_type(self, node):
|
||||||
# module 0 is DI-only -> DO addressing must fail
|
"""Per-type numbering (v1.5): 'do' module numbers address the DO
|
||||||
|
modules regardless of physical slot — DO0 is the DO_8 at slot 2.
|
||||||
|
A 'do' alias on module 1 (the DO_16) is valid and distinct."""
|
||||||
scenario = _scenario()
|
scenario = _scenario()
|
||||||
scenario["outputs"]["bad"] = {"type": "do", "module": 0, "channel": 0}
|
scenario["outputs"]["do8"] = {"type": "do", "module": 0, "channel": 0}
|
||||||
from wago_sim.modules import ChannelMismatch
|
scenario["outputs"]["do16"] = {"type": "do", "module": 1, "channel": 0}
|
||||||
with pytest.raises(ChannelMismatch):
|
engine = ScenarioEngine(node, scenario)
|
||||||
ScenarioEngine(node, scenario)
|
assert engine.alias_location("do8").bit == 0
|
||||||
|
assert engine.alias_location("do16").bit == 8
|
||||||
|
|
||||||
def test_analog_kind_on_digital_module_rejected(self, node):
|
def test_analog_kind_on_digital_module_rejected(self, node):
|
||||||
# module 0 is DI-only -> AI addressing must fail
|
# per-type routing: 'ai' module 9 is out of range (only 1 AI module)
|
||||||
scenario = _scenario()
|
scenario = _scenario()
|
||||||
scenario["inputs"]["bad"] = {"type": "ai", "module": 0, "channel": 0}
|
scenario["inputs"]["bad"] = {"type": "ai", "module": 9, "channel": 0}
|
||||||
from wago_sim.modules import ChannelMismatch
|
with pytest.raises(IndexError):
|
||||||
with pytest.raises(ChannelMismatch):
|
|
||||||
ScenarioEngine(node, scenario)
|
ScenarioEngine(node, scenario)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+42
-38
@@ -106,7 +106,7 @@ class TestManipulation:
|
|||||||
|
|
||||||
async def test_toggle_do_forces_output_image(self, client, datastore):
|
async def test_toggle_do_forces_output_image(self, client, datastore):
|
||||||
# DO_8 sits at module slot 2 in the pump fixture
|
# DO_8 sits at module slot 2 in the pump fixture
|
||||||
r = await client.post("/api/channels/do/2/0", json={"action": "toggle"})
|
r = await client.post("/api/channels/do/0/0", json={"action": "toggle"})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
# DO channel 1 is forced ON in the OUTPUT image (master-side)
|
# DO channel 1 is forced ON in the OUTPUT image (master-side)
|
||||||
assert datastore.digital_output_channel(1) is True
|
assert datastore.digital_output_channel(1) is True
|
||||||
@@ -117,13 +117,13 @@ class TestManipulation:
|
|||||||
async def test_ai_value_set(self, client, datastore):
|
async def test_ai_value_set(self, client, datastore):
|
||||||
# AI_2 sits at module slot 4 in the pump fixture (DI_8, DI_16,
|
# AI_2 sits at module slot 4 in the pump fixture (DI_8, DI_16,
|
||||||
# DO_8, DO_16, AI_2, AO_2); its first word is input word 0
|
# DO_8, DO_16, AI_2, AO_2); its first word is input word 0
|
||||||
r = await client.post("/api/channels/ai/4/0", json={"value": 200})
|
r = await client.post("/api/channels/ai/0/0", json={"value": 200})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json()["value"] == 200
|
assert r.json()["value"] == 200
|
||||||
assert datastore.get_input_word(0) == 200
|
assert datastore.get_input_word(0) == 200
|
||||||
|
|
||||||
async def test_ao_force_writes_output_image(self, client, datastore):
|
async def test_ao_force_writes_output_image(self, client, datastore):
|
||||||
r = await client.post("/api/channels/ao/5/0", json={"value": 1234})
|
r = await client.post("/api/channels/ao/0/0", json={"value": 1234})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
# written at the output image word 0 -> read back at +0x0200
|
# written at the output image word 0 -> read back at +0x0200
|
||||||
assert datastore.read_words(0x0200, 1) == [1234]
|
assert datastore.read_words(0x0200, 1) == [1234]
|
||||||
@@ -133,9 +133,13 @@ class TestManipulation:
|
|||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
|
|
||||||
async def test_kind_mismatch_400(self, client):
|
async def test_kind_mismatch_400(self, client):
|
||||||
# DI_16 module (index 1) has no DO channels
|
# per-type numbering (v1.5): DO module 0 is the DO_8 — asking
|
||||||
r = await client.post("/api/channels/do/1/0", json={"action": "toggle"})
|
# for a DO channel beyond its 8 channels is out of range (404);
|
||||||
assert r.status_code == 400
|
# DO module 2 does not exist (only DO0, DO1)
|
||||||
|
r = await client.post("/api/channels/do/2/0", json={"action": "toggle"})
|
||||||
|
assert r.status_code == 404
|
||||||
|
r = await client.post("/api/channels/do/0/9", json={"action": "toggle"})
|
||||||
|
assert r.status_code in (400, 404) # channel out of module range
|
||||||
|
|
||||||
|
|
||||||
class TestDiagnostics:
|
class TestDiagnostics:
|
||||||
@@ -319,7 +323,7 @@ class TestModuleConfig:
|
|||||||
class TestChannelNames:
|
class TestChannelNames:
|
||||||
async def test_names_in_layout_and_values(self, client, app):
|
async def test_names_in_layout_and_values(self, client, app):
|
||||||
r = await client.put("/api/config/channel-names", json={
|
r = await client.put("/api/config/channel-names", json={
|
||||||
"channel_names": {"DI0.0": "start_button", "AI4.0": "tank_level"},
|
"channel_names": {"DI0.0": "start_button", "AI0.0": "tank_level"},
|
||||||
"persist": False,
|
"persist": False,
|
||||||
})
|
})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
@@ -331,23 +335,23 @@ class TestChannelNames:
|
|||||||
# values snapshot carries it too
|
# values snapshot carries it too
|
||||||
r = await client.get("/api/values")
|
r = await client.get("/api/values")
|
||||||
entry = next(v for v in r.json()["values"]
|
entry = next(v for v in r.json()["values"]
|
||||||
if (v["kind"], v["module"], v["channel"]) == ("ai", 4, 0))
|
if (v["kind"], v["module"], v["channel"]) == ("ai", 0, 0))
|
||||||
assert entry["name"] == "tank_level"
|
assert entry["name"] == "tank_level"
|
||||||
|
|
||||||
async def test_names_persist_to_yaml(self, client, app, tmp_path):
|
async def test_names_persist_to_yaml(self, client, app, tmp_path):
|
||||||
cfg_file = tmp_path / "node.yaml"
|
cfg_file = tmp_path / "node.yaml"
|
||||||
app.node_config_path = str(cfg_file)
|
app.node_config_path = str(cfg_file)
|
||||||
r = await client.put("/api/config/channel-names", json={
|
r = await client.put("/api/config/channel-names", json={
|
||||||
"channel_names": {"DO2.0": "pump_relay"}, "persist": True,
|
"channel_names": {"DO0.0": "pump_relay"}, "persist": True,
|
||||||
})
|
})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
text = cfg_file.read_text(encoding="utf-8")
|
text = cfg_file.read_text(encoding="utf-8")
|
||||||
assert "channel_names:" in text
|
assert "channel_names:" in text
|
||||||
assert "DO2.0: pump_relay" in text
|
assert "DO0.0: pump_relay" in text
|
||||||
# and it loads back
|
# and it loads back
|
||||||
from wago_sim.scenario.dsl import load_yaml, load_channel_names
|
from wago_sim.scenario.dsl import load_yaml, load_channel_names
|
||||||
names = load_channel_names(load_yaml(cfg_file))
|
names = load_channel_names(load_yaml(cfg_file))
|
||||||
assert names == {"DO2.0": "pump_relay"}
|
assert names == {"DO0.0": "pump_relay"}
|
||||||
|
|
||||||
async def test_invalid_code_rejected(self, client):
|
async def test_invalid_code_rejected(self, client):
|
||||||
r = await client.put("/api/config/channel-names", json={
|
r = await client.put("/api/config/channel-names", json={
|
||||||
@@ -464,21 +468,21 @@ class TestChannelRanges:
|
|||||||
|
|
||||||
async def test_put_ranges_and_layout(self, client, app):
|
async def test_put_ranges_and_layout(self, client, app):
|
||||||
r = await client.put("/api/config/channel-ranges", json={
|
r = await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": 0, "max": 32000},
|
"channel_ranges": {"AI0.0": {"min": 0, "max": 32000},
|
||||||
"AO5.0": {"min": 400, "max": 2000}},
|
"AO0.0": {"min": 400, "max": 2000}},
|
||||||
"persist": False,
|
"persist": False,
|
||||||
})
|
})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
# layout reflects the configured range
|
# layout reflects the configured range
|
||||||
r = await client.get("/api/node")
|
r = await client.get("/api/node")
|
||||||
ai = next(c for m in r.json()["modules"] if m["index"] == 4
|
ai = next(c for m in r.json()["modules"] if m["index"] == 4
|
||||||
for c in m["channels"] if c["kind"] == "ai" and c["address"] == "AI4.0")
|
for c in m["channels"] if c["kind"] == "ai" and c["address"] == "AI0.0")
|
||||||
assert ai["range"] == {"min": 0, "max": 32000}
|
assert ai["range"] == {"min": 0, "max": 32000}
|
||||||
assert app.node.channel_range("ao", 5, 0) == (400, 2000)
|
assert app.node.channel_ranges["AO0.0"] == (400, 2000)
|
||||||
|
|
||||||
async def test_default_range_dropped_from_store(self, client, app):
|
async def test_default_range_dropped_from_store(self, client, app):
|
||||||
r = await client.put("/api/config/channel-ranges", json={
|
r = await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": 0, "max": 4095}}, # == default
|
"channel_ranges": {"AI0.0": {"min": 0, "max": 4095}}, # == default
|
||||||
"persist": False,
|
"persist": False,
|
||||||
})
|
})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
@@ -490,71 +494,71 @@ class TestChannelRanges:
|
|||||||
"channel_ranges": {"XX4.0": {"min": 0, "max": 100}}, **base})
|
"channel_ranges": {"XX4.0": {"min": 0, "max": 100}}, **base})
|
||||||
assert r.status_code == 400 # bad code
|
assert r.status_code == 400 # bad code
|
||||||
r = await client.put("/api/config/channel-ranges", json={
|
r = await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": 100, "max": 50}}, **base})
|
"channel_ranges": {"AI0.0": {"min": 100, "max": 50}}, **base})
|
||||||
assert r.status_code == 400 # min >= max
|
assert r.status_code == 400 # min >= max
|
||||||
r = await client.put("/api/config/channel-ranges", json={
|
r = await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": 0, "max": 70000}}, **base})
|
"channel_ranges": {"AI0.0": {"min": 0, "max": 70000}}, **base})
|
||||||
assert r.status_code == 400 # > 65535
|
assert r.status_code == 400 # > 65535
|
||||||
r = await client.put("/api/config/channel-ranges", json={
|
r = await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": 0}}, **base})
|
"channel_ranges": {"AI0.0": {"min": 0}}, **base})
|
||||||
assert r.status_code == 400 # missing max
|
assert r.status_code == 400 # missing max
|
||||||
|
|
||||||
async def test_negative_range_accepted(self, client, app):
|
async def test_negative_range_accepted(self, client, app):
|
||||||
r = await client.put("/api/config/channel-ranges", json={
|
r = await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": -32000, "max": 32000}},
|
"channel_ranges": {"AI0.0": {"min": -32000, "max": 32000}},
|
||||||
"persist": False,
|
"persist": False,
|
||||||
})
|
})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert app.node.channel_range("ai", 4, 0) == (-32000, 32000)
|
assert app.node.channel_ranges["AI0.0"] == (-32000, 32000)
|
||||||
# layout exposes the signed range
|
# layout exposes the signed range
|
||||||
r = await client.get("/api/node")
|
r = await client.get("/api/node")
|
||||||
ai = next(c for m in r.json()["modules"] if m["index"] == 4
|
ai = next(c for m in r.json()["modules"] if m["index"] == 4
|
||||||
for c in m["channels"] if c["address"] == "AI4.0")
|
for c in m["channels"] if c["address"] == "AI0.0")
|
||||||
assert ai["range"] == {"min": -32000, "max": 32000}
|
assert ai["range"] == {"min": -32000, "max": 32000}
|
||||||
|
|
||||||
async def test_range_floor_is_minus_65534(self, client):
|
async def test_range_floor_is_minus_65534(self, client):
|
||||||
r = await client.put("/api/config/channel-ranges", json={
|
r = await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": -65535, "max": 0}}, "persist": False})
|
"channel_ranges": {"AI0.0": {"min": -65535, "max": 0}}, "persist": False})
|
||||||
assert r.status_code == 400 # below -65534
|
assert r.status_code == 400 # below -65534
|
||||||
|
|
||||||
async def test_signed_value_display_and_write(self, client, app, datastore):
|
async def test_signed_value_display_and_write(self, client, app, datastore):
|
||||||
# channel with a negative-min range -> signed two's-complement view
|
# channel with a negative-min range -> signed two's-complement view
|
||||||
await client.put("/api/config/channel-ranges", json={
|
await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": -32000, "max": 32000}},
|
"channel_ranges": {"AI0.0": {"min": -32000, "max": 32000}},
|
||||||
"persist": False})
|
"persist": False})
|
||||||
r = await client.post("/api/channels/ai/4/0", json={"value": -2000})
|
r = await client.post("/api/channels/ai/0/0", json={"value": -2000})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
# raw image holds the two's-complement word
|
# raw image holds the two's-complement word
|
||||||
assert datastore.get_input_word(0) == (-2000) & 0xFFFF
|
assert datastore.get_input_word(0) == (-2000) & 0xFFFF
|
||||||
# values API shows the signed number again
|
# values API shows the signed number again
|
||||||
r = await client.get("/api/values")
|
r = await client.get("/api/values")
|
||||||
entry = next(v for v in r.json()["values"]
|
entry = next(v for v in r.json()["values"]
|
||||||
if (v["kind"], v["module"], v["channel"]) == ("ai", 4, 0))
|
if (v["kind"], v["module"], v["channel"]) == ("ai", 0, 0))
|
||||||
assert entry["value"] == -2000
|
assert entry["value"] == -2000
|
||||||
|
|
||||||
async def test_negative_ao_force(self, client, datastore):
|
async def test_negative_ao_force(self, client, datastore):
|
||||||
r = await client.post("/api/channels/ao/5/0", json={"value": -3000})
|
r = await client.post("/api/channels/ao/0/0", json={"value": -3000})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert datastore.get_output_word(0) == (-3000) & 0xFFFF
|
assert datastore.get_output_word(0) == (-3000) & 0xFFFF
|
||||||
# unsigned AO (default range) still displays raw
|
# unsigned AO (default range) still displays raw
|
||||||
r = await client.get("/api/values")
|
r = await client.get("/api/values")
|
||||||
entry = next(v for v in r.json()["values"]
|
entry = next(v for v in r.json()["values"]
|
||||||
if (v["kind"], v["module"], v["channel"]) == ("ao", 5, 0))
|
if (v["kind"], v["module"], v["channel"]) == ("ao", 0, 0))
|
||||||
assert entry["value"] == (-3000) & 0xFFFF
|
assert entry["value"] == (-3000) & 0xFFFF
|
||||||
|
|
||||||
async def test_value_bounds_rejected(self, client):
|
async def test_value_bounds_rejected(self, client):
|
||||||
r = await client.post("/api/channels/ai/4/0", json={"value": -65535})
|
r = await client.post("/api/channels/ai/0/0", json={"value": -65535})
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
r = await client.post("/api/channels/ai/4/0", json={"value": 65536})
|
r = await client.post("/api/channels/ai/0/0", json={"value": 65536})
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
r = await client.post("/api/channels/ai/4/0", json={"value": "banana"})
|
r = await client.post("/api/channels/ai/0/0", json={"value": "banana"})
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
async def test_ranges_persist_to_yaml(self, client, app, tmp_path):
|
async def test_ranges_persist_to_yaml(self, client, app, tmp_path):
|
||||||
cfg_file = tmp_path / "node.yaml"
|
cfg_file = tmp_path / "node.yaml"
|
||||||
app.node_config_path = str(cfg_file)
|
app.node_config_path = str(cfg_file)
|
||||||
r = await client.put("/api/config/channel-ranges", json={
|
r = await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": 0, "max": 32000}}, "persist": True,
|
"channel_ranges": {"AI0.0": {"min": 0, "max": 32000}}, "persist": True,
|
||||||
})
|
})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
text = cfg_file.read_text(encoding="utf-8")
|
text = cfg_file.read_text(encoding="utf-8")
|
||||||
@@ -563,18 +567,18 @@ class TestChannelRanges:
|
|||||||
# loads back
|
# loads back
|
||||||
from wago_sim.scenario.dsl import load_yaml, load_channel_ranges
|
from wago_sim.scenario.dsl import load_yaml, load_channel_ranges
|
||||||
ranges = load_channel_ranges(load_yaml(cfg_file))
|
ranges = load_channel_ranges(load_yaml(cfg_file))
|
||||||
assert ranges == {"AI4.0": (0, 32000)}
|
assert ranges == {"AI0.0": (0, 32000)}
|
||||||
|
|
||||||
async def test_ranges_survive_module_save(self, client, app, tmp_path):
|
async def test_ranges_survive_module_save(self, client, app, tmp_path):
|
||||||
app.node_config_path = str(tmp_path / "node.yaml")
|
app.node_config_path = str(tmp_path / "node.yaml")
|
||||||
await client.put("/api/config/channel-ranges", json={
|
await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": 0, "max": 32000}}, "persist": True,
|
"channel_ranges": {"AI0.0": {"min": 0, "max": 32000}}, "persist": True,
|
||||||
})
|
})
|
||||||
r = await client.put("/api/config/modules", json={
|
r = await client.put("/api/config/modules", json={
|
||||||
"modules": ["750-430", "750-467"], "persist": True,
|
"modules": ["750-430", "750-467"], "persist": True,
|
||||||
})
|
})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
# AI_2 is at slot 1 now; the AI4.0 range became stale -> carried
|
# AI_2 is at slot 1 now; the AI0.0 range became stale -> carried
|
||||||
# over but no longer matches a channel; the store keeps it (names
|
# over but no longer matches a channel; the store keeps it (names
|
||||||
# semantics differ: stale name codes are dropped on import only)
|
# semantics differ: stale name codes are dropped on import only)
|
||||||
text = (tmp_path / "node.yaml").read_text(encoding="utf-8")
|
text = (tmp_path / "node.yaml").read_text(encoding="utf-8")
|
||||||
@@ -582,14 +586,14 @@ class TestChannelRanges:
|
|||||||
|
|
||||||
async def test_export_import_carries_ranges(self, client, app, tmp_path):
|
async def test_export_import_carries_ranges(self, client, app, tmp_path):
|
||||||
await client.put("/api/config/channel-ranges", json={
|
await client.put("/api/config/channel-ranges", json={
|
||||||
"channel_ranges": {"AI4.0": {"min": 600, "max": 2000}}, "persist": False,
|
"channel_ranges": {"AI0.0": {"min": 600, "max": 2000}}, "persist": False,
|
||||||
})
|
})
|
||||||
exported = (await client.get("/api/config/export")).json()
|
exported = (await client.get("/api/config/export")).json()
|
||||||
assert exported["channel_ranges"]["AI4.0"] == {"min": 600, "max": 2000}
|
assert exported["channel_ranges"]["AI0.0"] == {"min": 600, "max": 2000}
|
||||||
app.node_config_path = str(tmp_path / "node.yaml")
|
app.node_config_path = str(tmp_path / "node.yaml")
|
||||||
r = await client.post("/api/config/import", json=exported)
|
r = await client.post("/api/config/import", json=exported)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert app.node.channel_range("ai", 4, 0) == (600, 2000)
|
assert app.node.channel_ranges["AI0.0"] == (600, 2000)
|
||||||
text = (tmp_path / "node.yaml").read_text(encoding="utf-8")
|
text = (tmp_path / "node.yaml").read_text(encoding="utf-8")
|
||||||
assert "min: 600, max: 2000" in text
|
assert "min: 600, max: 2000" in text
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user