diff --git a/.vscode/tasks.json b/.vscode/tasks.json index f1dba98..5a92f35 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1003,6 +1003,205 @@ ], "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": [] } ] } \ No newline at end of file diff --git a/README.md b/README.md index 520f53b..077b8b1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/borra_node.yaml b/config/borra_node.yaml new file mode 100644 index 0000000..a1bf874 --- /dev/null +++ b/config/borra_node.yaml @@ -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 diff --git a/config/pump_node.example.yaml b/config/pump_node.example.yaml new file mode 100644 index 0000000..8347b05 --- /dev/null +++ b/config/pump_node.example.yaml @@ -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 diff --git a/config/pump_node.yaml b/config/pump_node.yaml index 48b02a2..7e2444e 100644 --- a/config/pump_node.yaml +++ b/config/pump_node.yaml @@ -5,8 +5,8 @@ name: pump_node coupler: 750-362 modules: - - "750-430" # 8-channel DI 24VDC - "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 @@ -14,5 +14,6 @@ modules: # Application names for module channels (I/O code -> name) channel_names: - AO5.0: speed_setpoint + AO0.0: speed_setpoint DI0.0: start_button + DI1.0: test diff --git a/docs/spec/software-spec.md b/docs/spec/software-spec.md index 34d475f..a2ca7ab 100644 --- a/docs/spec/software-spec.md +++ b/docs/spec/software-spec.md @@ -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 @@ -1126,6 +1135,14 @@ 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): 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 diff --git a/plugins/pump_plant.example.py b/plugins/pump_plant.example.py new file mode 100644 index 0000000..86ea9ad --- /dev/null +++ b/plugins/pump_plant.example.py @@ -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 \ No newline at end of file diff --git a/scenarios/pump_station_fat.example.yaml b/scenarios/pump_station_fat.example.yaml new file mode 100644 index 0000000..d486a8a --- /dev/null +++ b/scenarios/pump_station_fat.example.yaml @@ -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 \ No newline at end of file diff --git a/scenarios/pump_station_fat.yaml b/scenarios/pump_station_fat.yaml index 0e26deb..d486a8a 100644 --- a/scenarios/pump_station_fat.yaml +++ b/scenarios/pump_station_fat.yaml @@ -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: diff --git a/src/wago_sim/modules.py b/src/wago_sim/modules.py index 0932a21..7331c88 100644 --- a/src/wago_sim/modules.py +++ b/src/wago_sim/modules.py @@ -202,21 +202,32 @@ class VirtualNode: # -- 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). """ 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]: @@ -226,7 +237,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 +267,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 +329,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: diff --git a/src/wago_sim/webui/state.py b/src/wago_sim/webui/state.py index 711ebc4..a225e8e 100644 --- a/src/wago_sim/webui/state.py +++ b/src/wago_sim/webui/state.py @@ -16,12 +16,12 @@ 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}" + io_code = f"{ch.kind.upper()}{per_type[ch.kind]}.{channel_no}" lo, hi = (0, 4095) if ch.kind in ("ai", "ao"): lo, hi = node.channel_ranges.get(io_code, (0, 4095)) @@ -75,23 +75,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 +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 # 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), diff --git a/src/wago_sim/webui/static/app.js b/src/wago_sim/webui/static/app.js index 293a638..6e3f238 100644 --- a/src/wago_sim/webui/static/app.js +++ b/src/wago_sim/webui/static/app.js @@ -75,19 +75,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 ? `${ch.channel_name}` : `—`; let control; if (ch.kind === "di" || ch.kind === "do") { control = ``; } else { control = ``; + id="val-${ch.kind}-${mno}-${no}" + onchange="listWrite('${ch.kind}', ${mno}, ${no}, this)">`; } html += `
${ch.address} @@ -253,7 +254,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 +293,7 @@ function renderRail() { html += `
`; 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 +302,7 @@ function renderRail() { } html += ` + onclick="clickChannel('${ch.kind}', ${moduleNo(ch.address)}, ${channelNo(ch.address)})"> ${chNo}`; chIndex += 1; } else { @@ -313,7 +313,7 @@ function renderRail() { ${chNo} + onclick="clickBar('${ch.kind}', ${moduleNo(ch.address)}, ${channelNo(ch.address)}, event)"> ${chNo} @@ -337,6 +337,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); @@ -371,9 +378,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; } @@ -525,6 +535,13 @@ async function renderConfig() { ${m.output_bits ? m.output_bits + " DO" : ""} ${m.input_words ? m.input_words + " AIw" : ""} ${m.output_words ? m.output_words + " AOW" : ""} + + + + `).join(""); const pick = document.getElementById("catalog-pick"); pick.innerHTML = mc.catalog.map((c) => @@ -629,9 +646,26 @@ function renderModuleRows() { `).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 ---------------- document.querySelectorAll(".tab").forEach((btn) => { diff --git a/src/wago_sim/webui/static/index.html b/src/wago_sim/webui/static/index.html index 979e358..4f3cd62 100644 --- a/src/wago_sim/webui/static/index.html +++ b/src/wago_sim/webui/static/index.html @@ -4,7 +4,7 @@ WAGO 750-362 Simulator - +
@@ -111,6 +111,6 @@ - + \ No newline at end of file diff --git a/src/wago_sim/webui/static/login.html b/src/wago_sim/webui/static/login.html index 30bd0b0..f1d8148 100644 --- a/src/wago_sim/webui/static/login.html +++ b/src/wago_sim/webui/static/login.html @@ -4,7 +4,7 @@ WAGO Simulator — Login - +