diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 0d58b55..4451b1b 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -481,6 +481,61 @@ ], "isBackground": false, "problemMatcher": [] + }, + { + "label": "wago-sim: pytest m5a", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "pytest", + "tests", + "-q", + "--tb=short" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "wago-sim: serve m5a", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "wago_sim.cli", + "serve", + "--node", + "config/pump_node.yaml", + "--host", + "127.0.0.1", + "--port", + "5020", + "--web-port", + "8088" + ], + "isBackground": true, + "problemMatcher": [] + }, + { + "label": "modbus after import probe", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "scripts\\live_probe.py" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "cleanup m5a", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-c", + "import pathlib, subprocess; pathlib.Path('scripts/live_probe.py').unlink(missing_ok=True); subprocess.run(['powershell','-NoProfile','-Command','Get-NetTCPConnection -LocalPort 5020,8088 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force }'], capture_output=True); print('cleaned')" + ], + "isBackground": false, + "problemMatcher": [] } ] } \ No newline at end of file diff --git a/README.md b/README.md index 78a21a4..07de953 100644 --- a/README.md +++ b/README.md @@ -321,13 +321,25 @@ requirement ID for each item in parentheses. (0x1020/0x1021) and module-diagnosis (0x1050) fault injection and clear; SF LED on the coupler reflects injected LED errors. - [x] M5: configuration — coupler settings forms (watchdog time/masks/type/close-on-timeout via 0x1000–0x100B, response delay 0x1037, idle timeout 0x1030) with the device's own validation surfaced (writes refused while the watchdog runs → field error); module list editor over the 13-article catalog with SAVE CONFIG → live process-image rebuild (`rebuild_node`: datastore survives, values clear like a KBUS reset, 0x1022–0x1025 and 0x2030+ updated) + persistence to the node-config YAML via a comment-preserving custom emitter. +- [x] M5a: channel names for every module channel (FR-10.2.11) — + node-config persisted (`channel_names:` mapping, I/O code → + name), survive module saves and rebuilds, shown in the overview + list/tooltips and a per-channel name editor; configuration + import/export as JSON (FR-10.2.12) — modules, channel names, + coupler settings in one file, atomic validated import with + stale-name-code dropping; split overview (FR-10.2.13) — DIN-rail + graphic on top, live scrollable channel list below with I/O + code, configured name, and a switch (DI/DO) / numeric field + (AI/AO) for display + manipulation. - [ ] M6: scenario run view + alias-ownership rejection (FR-10.2.6, FR-10.2.9) — deferred. -- [x] Tests: 28 web-UI tests (`tests/test_webui.py`) via httpx +- [x] Tests: 38 web-UI tests (`tests/test_webui.py`) via httpx `ASGITransport` (in-process, no socket) + live verification: browser SAVE CONFIG rebuilds the node and persists YAML; Modbus master reads the module description (0x2030) correctly after the - loader-overlap register fix. Full suite: 206 passing. + loader-overlap register fix; channel-name save + list sync; + export/import round-trip with a Modbus read after the rebuild. + Full suite: 216 passing. ### Framework gaps (spec §4 — Scenario & Macro Engine) diff --git a/config/pump_node.yaml b/config/pump_node.yaml index 1e5e4cd..48b02a2 100644 --- a/config/pump_node.yaml +++ b/config/pump_node.yaml @@ -1,12 +1,18 @@ -# Virtual WAGO node configuration — pump station FAT bench +# Virtual WAGO node configuration — saved from the web UI (SAVE CONFIG) # Simulated device: WAGO 750-362 Modbus TCP coupler -name: pump_station_node +# 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 digital input 24VDC - - "750-1405" # 16-channel digital input 24VDC - - "750-530" # 8-channel digital output 24VDC - - "750-1516" # 16-channel digital output 24VDC - - "750-467" # 2-channel analog input 4-20mA - - "750-554" # 2-channel analog output 4-20mA + - "750-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: + AO5.0: speed_setpoint + DI0.0: start_button diff --git a/docs/spec/software-spec.md b/docs/spec/software-spec.md index 35fc41f..d1957b7 100644 --- a/docs/spec/software-spec.md +++ b/docs/spec/software-spec.md @@ -7,7 +7,7 @@ | Project | `wago-modbustcp-simulator-py` | | Version | 1.4 (Draft for review) | | Date | 2026-10-03 | -| Status | Framework phase; module addressing implemented (FR-4.3.11); service/container deployment implemented (§12); plain-text request tracing implemented (NFR-6.4); clean CLI error handling implemented (NFR-6.5 CLI part); web UI §10 in implementation (M1–M5 done: auth, overview, manipulation, diagnostics, configuration; M6 scenario view open); long-duration simulation (§8), bench mode (§9), runtime device configuration (§11 — coupler/module subset now covered by web UI M5), multi-vendor device profiles (§13) and additional fieldbus protocols PROFINET/COMLI (§14) requested | +| Status | Framework phase; module addressing implemented (FR-4.3.11); service/container deployment implemented (§12); plain-text request tracing implemented (NFR-6.4); clean CLI error handling implemented (NFR-6.5 CLI part); web UI §10 in implementation (M1–M5a done: auth, overview, manipulation, diagnostics, configuration, channel names, import/export, split overview; M6 scenario view open); long-duration simulation (§8), bench mode (§9), runtime device configuration (§11 — coupler/module/channel-name subset now covered by web UI M5/M5a), multi-vendor device profiles (§13) and additional fieldbus protocols PROFINET/COMLI (§14) requested | | Target device simulated | WAGO 750-362 / 750-362/0000-0001 FC Modbus TCP (G4) bus coupler | | Reference documents | `docs/m07500362_xxxxxxxx_0en.pdf` (750-362 Modbus TCP manual), Modbus TCP specification (modbus.org) | @@ -919,6 +919,34 @@ Terminal monitors (§9) suffice for single engineers, but FAT benches are Modbus watchdog settings, I/O Config with SAVE CONFIG, Modbus Mapping overview, address-selection DIP switch) are specified in §11; §10 covers value/state oversight and manipulation. +- FR-10.2.11: **Channel names** (requested 2026-10-03): every module + channel can carry an application-specific name (e.g. `start_button`, + `tank_level`). Names live in the node configuration — not in the + module catalog (catalog articles are shared per type) — persisted + alongside the module list, and are re-applied after process-image + rebuilds (§11). Unnamed channels fall back to the generic channel + label (e.g. `DI 1`). Names surface everywhere the UI lists a channel: + node overview, the channel list, the configuration editor, and + tooltips. +- FR-10.2.12: **Import/export of module configuration** (requested + 2026-10-03): the complete module configuration — module list with + slot order, per-channel names, and coupler settings — can be + downloaded as a single JSON file and imported back. Import validates + the payload (known articles, ≤ 64 modules, name shapes) and applies + it atomically: module rebuild + channel names + coupler settings in + one step, with the same persistence behavior as SAVE CONFIG (§11). + Export/import makes FAT benches shareable and reproducible between + engineers and benches. +- FR-10.2.13: **Split overview** (requested 2026-10-03): the overview + page is divided in two — the realistic node graphic (DIN rail with + LEDs, FR-10.2.2) on top, and below it a scrollable **channel list**: + one row per channel with the I/O code (`DI0.0`), the configured + channel name (if any), and a control for the value — a switch for + DI/DO and a numeric field for AI/AO — which both displays the live + value and manipulates it (same semantics as FR-10.2.4: DI = field + side, DO/AO = master-side force through internal paths). The list + updates live like the rail (FR-10.2.3) and is the primary working + surface for benches with many channels. ### 10.3 Implementation decisions (binding, 2026-10-03) @@ -979,6 +1007,8 @@ src/wago_sim/ | `POST /api/diagnostics/inject-fault`, `POST /api/diagnostics/clear-fault` | LED / module-diagnosis injection | 10.2.5 | | `GET/PUT /api/config/coupler` | watchdog settings, response delay, idle timeout, MAC-ID | §11 subset | | `GET/PUT /api/config/modules` | module list CRUD + process-image rebuild | §11 FR-11.2.1–11.2.4 | +| `GET /api/config/export` · `POST /api/config/import` | full configuration (modules + channel names + coupler) as JSON | 10.2.12 | +| `GET/PUT /api/config/channel-names` | read/write per-channel names | 10.2.11 | ### 10.6 Milestones @@ -989,6 +1019,7 @@ src/wago_sim/ | M3 | Manipulation — toggle/pulse/ramp, AI engineering units, master-side force | | M4 | Diagnostics — watchdog/statistics/diagnosis view, reset, fault injection | | M5 | Configuration — coupler forms; module CRUD with image rebuild and persistence | +| M5a | Channel names (FR-10.2.11) + configuration import/export (FR-10.2.12) + split overview with channel list (FR-10.2.13) | | M6 | (deferred) Scenario run view (FR-10.2.6) + alias-ownership rejection (FR-10.2.9) | ### 10.7 Acceptance criteria diff --git a/src/wago_sim/app.py b/src/wago_sim/app.py index 2ecaae7..8657c2e 100644 --- a/src/wago_sim/app.py +++ b/src/wago_sim/app.py @@ -42,7 +42,8 @@ class SimulatorApp: return self.node.modules -def rebuild_node(app: SimulatorApp, modules: list[ModuleType]) -> None: +def rebuild_node(app: SimulatorApp, modules: list[ModuleType], + channel_names: dict[str, str] | None = None) -> None: """Hot-configure the module list: rebuild the process image in place. Implements the §11 FR-11.2.1–11.2.3 semantics for the web UI: the @@ -51,11 +52,14 @@ def rebuild_node(app: SimulatorApp, modules: list[ModuleType]) -> None: recomputed, and the register bank is updated so 0x1022-0x1025 (image sizes) and 0x2030+ (module description) reflect the new configuration. Values written so far are lost — like pulling the - KBUS and re-configuring on a real node. + KBUS and re-configuring on a real node. Channel names (FR-10.2.11) + are carried over when `channel_names` is None (unchanged module + list) or replaced with the given mapping. """ datastore = app.datastore + names = channel_names if channel_names is not None else app.node.channel_names datastore.reset_images() - app.node = VirtualNode(datastore, modules) + app.node = VirtualNode(datastore, modules, channel_names=names) app.registers.set_process_image_bits( analog_in=app.node.analog_in_bits, analog_out=app.node.analog_out_bits, @@ -74,6 +78,7 @@ def build_app( firmware_description: str = "WAGO 750-362 Simulator", web: WebUIConfig | None = None, node_config_path: str | None = None, + channel_names: dict[str, str] | None = None, ) -> SimulatorApp: """Assemble all simulator components for a node configuration.""" stats = ModbusStatistics() @@ -116,7 +121,7 @@ def build_app( ) datastore._config = registers # late binding to break the cycle - node = VirtualNode(datastore, modules) + node = VirtualNode(datastore, modules, channel_names=channel_names) registers.set_process_image_bits( analog_in=node.analog_in_bits, analog_out=node.analog_out_bits, diff --git a/src/wago_sim/cli.py b/src/wago_sim/cli.py index ba0ce66..9fa90bf 100644 --- a/src/wago_sim/cli.py +++ b/src/wago_sim/cli.py @@ -40,7 +40,8 @@ import yaml from .app import WebUIConfig, build_app from .modules import ChannelMismatch, ModuleUnknown -from .scenario.dsl import load_node_modules, load_yaml, validate_scenario +from .scenario.dsl import (load_channel_names, load_node_modules, + load_yaml, validate_scenario) from .scenario.engine import ScenarioEngine, ScenarioError from .scenario.report import result_summary, write_csv, write_jsonl @@ -180,6 +181,7 @@ def _prepare_report_path(path: str, option: str) -> Path: async def _serve(args: argparse.Namespace) -> int: node_config = _load_yaml_file(args.node, "node config") modules = load_node_modules(node_config) + channel_names = load_channel_names(node_config) web_cfg = None if args.web: web_cfg = WebUIConfig( @@ -190,7 +192,7 @@ async def _serve(args: argparse.Namespace) -> int: readonly=_env_flag("WAGO_SIM_WEB_READONLY"), ) app = build_app(modules=modules, host=args.host, port=args.port, web=web_cfg, - node_config_path=args.node) + node_config_path=args.node, channel_names=channel_names) await app.server.start() web_server = None if web_cfg is not None: diff --git a/src/wago_sim/modules.py b/src/wago_sim/modules.py index 97753f5..05aa747 100644 --- a/src/wago_sim/modules.py +++ b/src/wago_sim/modules.py @@ -119,12 +119,17 @@ class VirtualNode: """A configured bus coupler with its I/O modules. Computes the WAGO process-image layout for a module list and binds - it to a :class:`WagoDatastore`. + it to a :class:`WagoDatastore`. Optional per-channel application + names (spec FR-10.2.11) live here — not on ModuleType, whose + catalog objects are shared per article — keyed by I/O code + ("DI0.0", "AI4.1", ...). """ - def __init__(self, datastore: WagoDatastore, modules: list[ModuleType]) -> None: + def __init__(self, datastore: WagoDatastore, modules: list[ModuleType], + channel_names: dict[str, str] | None = None) -> None: self.datastore = datastore self.modules = modules + self.channel_names: dict[str, str] = dict(channel_names or {}) self._di_channels: list[ChannelLocation] = [] self._do_channels: list[ChannelLocation] = [] self._ai_channels: list[ChannelLocation] = [] @@ -188,6 +193,26 @@ class VirtualNode: """Text for firmware-info register 0x2030 (module description).""" return ", ".join(f"{i}:{m.article}" for i, m in enumerate(self.modules)) + # -- channel naming (spec FR-10.2.11) ---------------------------------- + + def io_code(self, kind: str, module: int, channel: int) -> str: + """I/O code for a module channel, e.g. ('di', 0, 0) -> 'DI0.0'.""" + return f"{kind.upper()}{module}.{channel}" + + def channel_name(self, kind: str, module: int, channel: int) -> str | None: + """Configured application name for a channel, or None.""" + return self.channel_names.get(self.io_code(kind, module, channel)) + + def validate_channel_names(self) -> list[str]: + """Names whose I/O code does not exist on this node (after a + module-list change, stale entries are dropped on save).""" + known: set[str] = set() + 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)) + return sorted(set(self.channel_names) - known) + # -- named channel access (scenario engine API) ----------------------------- @property diff --git a/src/wago_sim/scenario/dsl.py b/src/wago_sim/scenario/dsl.py index dc21891..dfb2aaa 100644 --- a/src/wago_sim/scenario/dsl.py +++ b/src/wago_sim/scenario/dsl.py @@ -52,6 +52,27 @@ def load_node_modules(node_config: dict) -> list[ModuleType]: return modules +def load_channel_names(node_config: dict) -> dict[str, str]: + """Per-channel application names from the node configuration. + + Shape (FR-10.2.11):: + + channel_names: + DI0.0: start_button + AI4.0: tank_level + + Keys are I/O codes (module-based addressing per FR-4.3.11). No + validation against the module list here — that happens when the + names are bound to a VirtualNode. + """ + raw = node_config.get("channel_names") or {} + if not isinstance(raw, dict): + raise ScenarioError("channel_names must be a mapping of I/O code -> name") + return {str(code).strip().upper(): str(name).strip() + for code, name in raw.items() + if str(name).strip()} + + def validate_scenario(scenario: dict) -> list[str]: """Structural validation; returns a list of problems (empty == valid).""" problems: list[str] = [] diff --git a/src/wago_sim/webui/api.py b/src/wago_sim/webui/api.py index dc12217..eb31f88 100644 --- a/src/wago_sim/webui/api.py +++ b/src/wago_sim/webui/api.py @@ -8,6 +8,7 @@ All handlers touch the simulator's internal memory handlers only — from __future__ import annotations import asyncio +import re from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect @@ -397,7 +398,8 @@ async def put_module_config( if payload.get("persist", True) and sim.node_config_path: try: save_node_config(sim.node_config_path, modules, - name=Path(sim.node_config_path).stem) + name=Path(sim.node_config_path).stem, + channel_names=sim.node.channel_names) except OSError as exc: raise HTTPException( status_code=500, @@ -411,6 +413,195 @@ async def put_module_config( } +# -- channel names (FR-10.2.11) ---------------------------------------------- + + +_NAME_CODE = re.compile(r"^(DI|DO|AI|AO)(\d+)\.(\d+)$") + + +@router.get("/api/config/channel-names") +async def get_channel_names( + sim: SimulatorApp = Depends(get_sim), + _: None = Depends(require_session), +) -> dict: + return {"channel_names": dict(sim.node.channel_names)} + + +@router.put("/api/config/channel-names") +async def put_channel_names( + payload: dict, + request: Request, + sim: SimulatorApp = Depends(get_sim), + sessions: SessionStore = Depends(get_sessions), +) -> dict: + """Set the full channel-name mapping (empty name clears an entry). + + Codes are validated against the current module layout; unknown + codes are rejected so typos don't linger invisibly. Passing + `persist: true` (default when a node config path exists) also + writes the YAML. + """ + _require_write(request, sessions) + raw = payload.get("channel_names") + if not isinstance(raw, dict): + raise HTTPException(status_code=400, detail="'channel_names' must be a mapping") + names: dict[str, str] = {} + for code, name in raw.items(): + code_up = str(code).strip().upper() + if not _NAME_CODE.match(code_up): + raise HTTPException( + status_code=400, + detail=f"invalid I/O code {code!r} (expected e.g. DI0.0)", + ) + name_s = str(name).strip() + if name_s: + names[code_up] = name_s + stale = sim.node.validate_channel_names() # against the NEW mapping + # replace the mapping on the live node + sim.node.channel_names = names + stale_now = sim.node.validate_channel_names() + if payload.get("persist", True) and sim.node_config_path: + from .persist import save_node_config + try: + save_node_config( + sim.node_config_path, sim.node.modules, + name=Path(sim.node_config_path).stem, + channel_names=names, + ) + except OSError as exc: + raise HTTPException( + status_code=500, + detail=f"names applied, but saving {sim.node_config_path} failed: {exc}", + ) from None + return {"ok": True, "channel_names": names, "unknown_codes": stale_now} + + +# -- configuration import/export (FR-10.2.12) --------------------------------- + + +def _coupler_snapshot(sim: SimulatorApp) -> dict: + r = sim.registers + wd = r.read_config(0x1000, 12) + return { + "watchdog": { + "time_units": wd[0], + "mask_fc_1_16": wd[1], + "mask_fc_17_32": wd[2], + "close_on_timeout": wd[9], + "type": wd[10], + }, + "response_delay_ms": r.read_config(0x1037, 1)[0], + "idle_timeout_units": r.read_config(0x1030, 1)[0], + } + + +@router.get("/api/config/export") +async def export_config( + sim: SimulatorApp = Depends(get_sim), + _: None = Depends(require_session), +) -> dict: + """Complete configuration as one JSON payload (downloadable).""" + return { + "format": "wago-sim-config", + "version": 1, + "coupler": "750-362", + "modules": [m.article for m in sim.node.modules], + "channel_names": dict(sim.node.channel_names), + "coupler_settings": _coupler_snapshot(sim), + } + + +@router.post("/api/config/import") +async def import_config( + payload: dict, + request: Request, + sim: SimulatorApp = Depends(get_sim), + sessions: SessionStore = Depends(get_sessions), +) -> dict: + """Atomically apply an exported configuration: module rebuild + + channel names + coupler settings, then persist (like SAVE CONFIG). + + Import is all-or-nothing: validation happens before any change is + applied; on the first problem nothing is touched. + """ + _require_write(request, sessions) + if payload.get("format") != "wago-sim-config": + raise HTTPException(status_code=400, detail="not a wago-sim configuration") + articles = payload.get("modules") + if not isinstance(articles, list) or not articles: + raise HTTPException(status_code=400, detail="'modules' must be a non-empty list") + if len(articles) > 64: + raise HTTPException(status_code=400, detail="too many modules (max 64)") + try: + modules = [resolve_module(str(a)) for a in articles] + except ModuleUnknown as exc: + raise HTTPException(status_code=400, detail=str(exc)) from None + names_raw = payload.get("channel_names") or {} + if not isinstance(names_raw, dict): + raise HTTPException(status_code=400, detail="'channel_names' must be a mapping") + names: dict[str, str] = {} + for code, name in names_raw.items(): + code_up = str(code).strip().upper() + if not _NAME_CODE.match(code_up): + raise HTTPException(status_code=400, + detail=f"invalid I/O code {code!r}") + if str(name).strip(): + names[code_up] = str(name).strip() + coupler = payload.get("coupler_settings") or {} + + # -- everything validated: apply atomically ------------------------- + rebuild_node(sim, modules, channel_names=names) + stale = sim.node.validate_channel_names() + if stale: + # codes that don't exist on the imported module list are dropped + for code in stale: + names.pop(code, None) + rebuild_node(sim, modules, channel_names=names) + errors: list[str] = [] + wd = coupler.get("watchdog") or {} + writes: list[tuple[int, int]] = [] + if "time_units" in wd: + writes.append((0x1000, int(wd["time_units"]))) + if "mask_fc_1_16" in wd: + writes.append((0x1001, int(wd["mask_fc_1_16"]))) + if "mask_fc_17_32" in wd: + writes.append((0x1002, int(wd["mask_fc_17_32"]))) + if "close_on_timeout" in wd: + writes.append((0x1009, 1 if wd["close_on_timeout"] else 0)) + if "type" in wd: + writes.append((0x100A, 1 if wd["type"] in (1, "alternative") else 0)) + if "response_delay_ms" in coupler: + writes.append((0x1037, int(coupler["response_delay_ms"]))) + if "idle_timeout_units" in coupler: + writes.append((0x1030, int(coupler["idle_timeout_units"]))) + for address, value in writes: + try: + sim.registers.write_config(address, [value]) + except RegisterWriteRefused as exc: + errors.append(f"{address:#06x}: {exc}") + if payload.get("persist", True) and sim.node_config_path: + try: + save_node_config( + sim.node_config_path, modules, + name=Path(sim.node_config_path).stem, + channel_names=names, + ) + except OSError as exc: + raise HTTPException( + status_code=500, + detail=f"imported, but saving {sim.node_config_path} failed: {exc}", + ) from None + return { + "ok": True, + "modules": [m.article for m in modules], + "channel_names": names, + "coupler_writes_applied": len(writes) - len(errors), + "coupler_write_errors": errors, + "dropped_codes": stale, + "saved_to": sim.node_config_path if payload.get("persist", True) else None, + } + + @router.websocket("/api/ws") async def ws_values(websocket: WebSocket) -> None: sim: SimulatorApp = websocket.app.state.sim diff --git a/src/wago_sim/webui/api_models.py b/src/wago_sim/webui/api_models.py index e671235..d0b812d 100644 --- a/src/wago_sim/webui/api_models.py +++ b/src/wago_sim/webui/api_models.py @@ -11,6 +11,7 @@ class ChannelJSON(TypedDict): kind: str address: str modbus: str + channel_name: str | None class ModuleJSON(TypedDict): @@ -37,4 +38,5 @@ class ChannelValue(TypedDict): kind: str module: int channel: int - value: bool | int \ No newline at end of file + value: bool | int + name: str | None \ No newline at end of file diff --git a/src/wago_sim/webui/persist.py b/src/wago_sim/webui/persist.py index eecd696..c0b6ab2 100644 --- a/src/wago_sim/webui/persist.py +++ b/src/wago_sim/webui/persist.py @@ -1,8 +1,9 @@ """Node-config persistence for the web UI (spec §11, FR-11.5). -Writes the module list back to the node-config YAML. A small custom -emitter is used instead of `yaml.dump` so the file stays human-readable -with a header comment — bench engineers edit this file by hand. +Writes the module list (and, since FR-10.2.11, per-channel names) +back to the node-config YAML. A small custom emitter is used instead +of `yaml.dump` so the file stays human-readable with a header comment +— bench engineers edit this file by hand. """ from __future__ import annotations @@ -21,12 +22,26 @@ coupler: 750-362 modules: """ +_NAMES_HEADER = """\ + +# Application names for module channels (I/O code -> name) +channel_names: +""" + def save_node_config( - path: str | Path, modules: list[ModuleType], *, name: str = "node" + path: str | Path, + modules: list[ModuleType], + *, + name: str = "node", + channel_names: dict[str, str] | None = None, ) -> None: - """Write the module list to the node-config YAML (custom emitter).""" + """Write the module list + channel names to the node-config YAML.""" lines = [_HEADER.format(name=name)] for m in modules: lines.append(f' - "{m.article}" # {m.description}\n') + if channel_names: + lines.append(_NAMES_HEADER) + for code in sorted(channel_names): + lines.append(f" {code}: {channel_names[code]}\n") Path(path).write_text("".join(lines), encoding="utf-8", newline="\n") \ No newline at end of file diff --git a/src/wago_sim/webui/state.py b/src/wago_sim/webui/state.py index 89ea4a0..04c3199 100644 --- a/src/wago_sim/webui/state.py +++ b/src/wago_sim/webui/state.py @@ -21,12 +21,14 @@ def node_layout(node: VirtualNode) -> NodeLayoutJSON: # 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}" channels.append( { "name": ch.name, "kind": ch.kind, - "address": f"{kind_upper}{index}.{channel_no}", + "address": io_code, "modbus": _modbus_address(node, ch.kind, index, channel_no), + "channel_name": node.channel_names.get(io_code), } ) modules_json.append( @@ -70,40 +72,48 @@ def channel_values(node: VirtualNode) -> list[ChannelValue]: # Digital channels: global 1-based numbers, bit-packed after analog. for i, loc in enumerate(node.di, start=1): + code = node.io_code("di", loc.module_index, loc.channel - 1) out.append( { "kind": "di", "module": loc.module_index, "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) out.append( { "kind": "do", "module": loc.module_index, "channel": loc.channel - 1, "value": ds.digital_output_channel(i), + "name": node.channel_names.get(code), } ) # Analog channels: word indices in the process image. for loc in node.ai: + code = node.io_code("ai", loc.module_index, loc.channel - 1) out.append( { "kind": "ai", "module": loc.module_index, "channel": loc.channel - 1, "value": 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) out.append( { "kind": "ao", "module": loc.module_index, "channel": loc.channel - 1, "value": ds.get_output_word(loc.word), + "name": node.channel_names.get(code), } ) return out \ No newline at end of file diff --git a/src/wago_sim/webui/static/app.js b/src/wago_sim/webui/static/app.js index 0793a1a..d566a19 100644 --- a/src/wago_sim/webui/static/app.js +++ b/src/wago_sim/webui/static/app.js @@ -26,6 +26,7 @@ async function init() { NODE = await api("/api/node"); const diag = await api("/api/diagnostics").catch(() => null); renderRail(); + renderChannelList(); if (diag) renderDiagnostics(diag); openWS(); } @@ -44,7 +45,158 @@ function openWS() { function applyValues(values) { for (const v of values) { VALUES[key(v.kind, v.module, v.channel)] = v.value; + if (v.name !== undefined) NAMES[key(v.kind, v.module, v.channel)] = v.name; updateChannel(v.kind, v.module, v.channel, v.value); + updateListRow(v.kind, v.module, v.channel, v.value, v.name); + } +} + +// ---------------- channel list (FR-10.2.13) ---------------- + +let NAMES = {}; // "kind:module:channel" -> configured name + +function updateListRow(kind, module, channel, value, name) { + const row = document.getElementById(`row-${kind}-${module}-${channel}`); + if (!row) return; + const ctl = row.querySelector(".row-value"); + if (kind === "di" || kind === "do") { + ctl.checked = !!value; + } else { + if (document.activeElement !== ctl) ctl.value = value; + } + const nameEl = row.querySelector(".row-name"); + if (nameEl && name !== undefined) nameEl.textContent = name || "—"; +} + +function renderChannelList() { + const list = document.getElementById("chlist"); + if (!NODE) return; + let html = ""; + 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 named = ch.channel_name ? `${ch.channel_name}` + : `—`; + let control; + if (ch.kind === "di" || ch.kind === "do") { + control = ``; + } else { + control = ``; + } + html += `
+ ${ch.address} + ${named} + ${control} +
`; + } + } + list.innerHTML = html; +} + +async function listWrite(kind, module, channel, el) { + if (READONLY) return; + const payload = (kind === "di" || kind === "do") + ? {action: "set", value: el.checked} + : {action: "set", value: Number(el.value) & 0xFFFF}; + try { + await api(`/api/channels/${kind}/${module}/${channel}`, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(payload), + }); + const res = await api("/api/values"); + applyValues(res.values); + } catch (e) { + console.warn("write rejected:", e.message); + const res = await api("/api/values").catch(() => null); + if (res) applyValues(res.values); + } +} + +// ---------------- channel names (FR-10.2.11) ---------------- + +function renderNameEditor() { + const editor = document.getElementById("name-editor"); + if (!NODE || !editor) return; + let html = ""; + for (const mod of NODE.modules) { + for (const ch of mod.channels) { + html += ``; + } + } + editor.innerHTML = html; +} + +async function saveChannelNames() { + const msg = document.getElementById("name-msg"); + msg.classList.add("hidden"); + const names = {}; + document.querySelectorAll(".name-input").forEach((el) => { + if (el.value.trim()) names[el.dataset.code] = el.value.trim(); + }); + try { + await api("/api/config/channel-names", { + method: "PUT", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({channel_names: names}), + }); + msg.textContent = "Saved."; + msg.classList.remove("hidden", "error"); + msg.classList.add("ok"); + NODE = null; // re-fetch layout with names + await init(); + await renderConfig(); + } catch (e) { + msg.textContent = e.message; + msg.classList.remove("hidden", "ok"); + msg.classList.add("error"); + } +} + +// ---------------- import / export (FR-10.2.12) ---------------- + +async function exportConfig() { + const data = await api("/api/config/export"); + const blob = new Blob([JSON.stringify(data, null, 2)], {type: "application/json"}); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "wago-sim-config.json"; + a.click(); + URL.revokeObjectURL(a.href); +} + +async function importConfig(file) { + const msg = document.getElementById("io-msg"); + msg.classList.add("hidden"); + try { + const text = await file.text(); + const payload = JSON.parse(text); + const r = await api("/api/config/import", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(payload), + }); + msg.textContent = `Imported ${r.modules.length} modules` + + (r.dropped_codes && r.dropped_codes.length + ? ` (dropped ${r.dropped_codes.length} stale name codes)` : ""); + msg.classList.remove("hidden", "error"); + msg.classList.add("ok"); + NODE = null; + await init(); + await renderConfig(); + } catch (e) { + msg.textContent = e.message; + msg.classList.remove("hidden", "ok"); + msg.classList.add("error"); } } @@ -230,6 +382,7 @@ async function renderConfig() { const mc = await api("/api/config/modules"); if (!mc) return; + renderNameEditor(); const list = document.getElementById("module-list"); list.innerHTML = mc.modules.map((m, i) => `