🚧 WIP
This commit is contained in:
Vendored
+55
@@ -481,6 +481,61 @@
|
|||||||
],
|
],
|
||||||
"isBackground": false,
|
"isBackground": false,
|
||||||
"problemMatcher": []
|
"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": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -321,13 +321,25 @@ requirement ID for each item in parentheses.
|
|||||||
(0x1020/0x1021) and module-diagnosis (0x1050) fault injection
|
(0x1020/0x1021) and module-diagnosis (0x1050) fault injection
|
||||||
and clear; SF LED on the coupler reflects injected LED errors.
|
and clear; SF LED on the coupler reflects injected LED errors.
|
||||||
- [x] M5: configuration — coupler settings forms (watchdog time/masks/type/close-on-timeout via 0x1000–0x100B, response delay 0x1037, idle timeout 0x1030) with the device's own validation surfaced (writes refused while the watchdog runs → field error); module list editor over the 13-article catalog with SAVE CONFIG → live process-image rebuild (`rebuild_node`: datastore survives, values clear like a KBUS reset, 0x1022–0x1025 and 0x2030+ updated) + persistence to the node-config YAML via a comment-preserving custom emitter.
|
- [x] M5: configuration — coupler settings forms (watchdog time/masks/type/close-on-timeout via 0x1000–0x100B, response delay 0x1037, idle timeout 0x1030) with the device's own validation surfaced (writes refused while the watchdog runs → field error); module list editor over the 13-article catalog with SAVE CONFIG → live process-image rebuild (`rebuild_node`: datastore survives, values clear like a KBUS reset, 0x1022–0x1025 and 0x2030+ updated) + persistence to the node-config YAML via a comment-preserving custom emitter.
|
||||||
|
- [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,
|
- [ ] M6: scenario run view + alias-ownership rejection (FR-10.2.6,
|
||||||
FR-10.2.9) — deferred.
|
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:
|
`ASGITransport` (in-process, no socket) + live verification:
|
||||||
browser SAVE CONFIG rebuilds the node and persists YAML; Modbus
|
browser SAVE CONFIG rebuilds the node and persists YAML; Modbus
|
||||||
master reads the module description (0x2030) correctly after the
|
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)
|
### Framework gaps (spec §4 — Scenario & Macro Engine)
|
||||||
|
|
||||||
|
|||||||
+14
-8
@@ -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
|
# 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
|
coupler: 750-362
|
||||||
|
|
||||||
modules:
|
modules:
|
||||||
- "750-430" # 8-channel digital input 24VDC
|
- "750-430" # 8-channel DI 24VDC
|
||||||
- "750-1405" # 16-channel digital input 24VDC
|
- "750-1405" # 16-channel DI 24VDC
|
||||||
- "750-530" # 8-channel digital output 24VDC
|
- "750-530" # 8-channel DO 24VDC
|
||||||
- "750-1516" # 16-channel digital output 24VDC
|
- "750-1516" # 16-channel DO 24VDC
|
||||||
- "750-467" # 2-channel analog input 4-20mA
|
- "750-467" # 2-channel AI 4-20mA
|
||||||
- "750-554" # 2-channel analog output 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
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
| Project | `wago-modbustcp-simulator-py` |
|
| Project | `wago-modbustcp-simulator-py` |
|
||||||
| Version | 1.4 (Draft for review) |
|
| Version | 1.4 (Draft for review) |
|
||||||
| Date | 2026-10-03 |
|
| Date | 2026-10-03 |
|
||||||
| Status | Framework phase; module addressing implemented (FR-4.3.11); service/container deployment implemented (§12); plain-text request tracing implemented (NFR-6.4); clean CLI error handling implemented (NFR-6.5 CLI part); web UI §10 in implementation (M1–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 |
|
| Target device simulated | WAGO 750-362 / 750-362/0000-0001 FC Modbus TCP (G4) bus coupler |
|
||||||
| Reference documents | `docs/m07500362_xxxxxxxx_0en.pdf` (750-362 Modbus TCP manual), Modbus TCP specification (modbus.org) |
|
| Reference documents | `docs/m07500362_xxxxxxxx_0en.pdf` (750-362 Modbus TCP manual), Modbus TCP specification (modbus.org) |
|
||||||
|
|
||||||
@@ -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
|
Modbus watchdog settings, I/O Config with SAVE CONFIG, Modbus Mapping
|
||||||
overview, address-selection DIP switch) are specified in §11; §10 covers
|
overview, address-selection DIP switch) are specified in §11; §10 covers
|
||||||
value/state oversight and manipulation.
|
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)
|
### 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 |
|
| `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/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/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
|
### 10.6 Milestones
|
||||||
|
|
||||||
@@ -989,6 +1019,7 @@ src/wago_sim/
|
|||||||
| M3 | Manipulation — toggle/pulse/ramp, AI engineering units, master-side force |
|
| M3 | Manipulation — toggle/pulse/ramp, AI engineering units, master-side force |
|
||||||
| M4 | Diagnostics — watchdog/statistics/diagnosis view, reset, fault injection |
|
| M4 | Diagnostics — watchdog/statistics/diagnosis view, reset, fault injection |
|
||||||
| M5 | Configuration — coupler forms; module CRUD with image rebuild and persistence |
|
| M5 | Configuration — coupler forms; module CRUD with image rebuild and persistence |
|
||||||
|
| M5a | Channel names (FR-10.2.11) + configuration import/export (FR-10.2.12) + split overview with channel list (FR-10.2.13) |
|
||||||
| M6 | (deferred) Scenario run view (FR-10.2.6) + alias-ownership rejection (FR-10.2.9) |
|
| M6 | (deferred) Scenario run view (FR-10.2.6) + alias-ownership rejection (FR-10.2.9) |
|
||||||
|
|
||||||
### 10.7 Acceptance criteria
|
### 10.7 Acceptance criteria
|
||||||
|
|||||||
+9
-4
@@ -42,7 +42,8 @@ class SimulatorApp:
|
|||||||
return self.node.modules
|
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.
|
"""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
|
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
|
recomputed, and the register bank is updated so 0x1022-0x1025
|
||||||
(image sizes) and 0x2030+ (module description) reflect the new
|
(image sizes) and 0x2030+ (module description) reflect the new
|
||||||
configuration. Values written so far are lost — like pulling the
|
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
|
datastore = app.datastore
|
||||||
|
names = channel_names if channel_names is not None else app.node.channel_names
|
||||||
datastore.reset_images()
|
datastore.reset_images()
|
||||||
app.node = VirtualNode(datastore, modules)
|
app.node = VirtualNode(datastore, modules, channel_names=names)
|
||||||
app.registers.set_process_image_bits(
|
app.registers.set_process_image_bits(
|
||||||
analog_in=app.node.analog_in_bits,
|
analog_in=app.node.analog_in_bits,
|
||||||
analog_out=app.node.analog_out_bits,
|
analog_out=app.node.analog_out_bits,
|
||||||
@@ -74,6 +78,7 @@ def build_app(
|
|||||||
firmware_description: str = "WAGO 750-362 Simulator",
|
firmware_description: str = "WAGO 750-362 Simulator",
|
||||||
web: WebUIConfig | None = None,
|
web: WebUIConfig | None = None,
|
||||||
node_config_path: str | None = None,
|
node_config_path: str | None = None,
|
||||||
|
channel_names: dict[str, str] | None = None,
|
||||||
) -> SimulatorApp:
|
) -> SimulatorApp:
|
||||||
"""Assemble all simulator components for a node configuration."""
|
"""Assemble all simulator components for a node configuration."""
|
||||||
stats = ModbusStatistics()
|
stats = ModbusStatistics()
|
||||||
@@ -116,7 +121,7 @@ def build_app(
|
|||||||
)
|
)
|
||||||
datastore._config = registers # late binding to break the cycle
|
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(
|
registers.set_process_image_bits(
|
||||||
analog_in=node.analog_in_bits,
|
analog_in=node.analog_in_bits,
|
||||||
analog_out=node.analog_out_bits,
|
analog_out=node.analog_out_bits,
|
||||||
|
|||||||
+4
-2
@@ -40,7 +40,8 @@ import yaml
|
|||||||
|
|
||||||
from .app import WebUIConfig, build_app
|
from .app import WebUIConfig, build_app
|
||||||
from .modules import ChannelMismatch, ModuleUnknown
|
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.engine import ScenarioEngine, ScenarioError
|
||||||
from .scenario.report import result_summary, write_csv, write_jsonl
|
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:
|
async def _serve(args: argparse.Namespace) -> int:
|
||||||
node_config = _load_yaml_file(args.node, "node config")
|
node_config = _load_yaml_file(args.node, "node config")
|
||||||
modules = load_node_modules(node_config)
|
modules = load_node_modules(node_config)
|
||||||
|
channel_names = load_channel_names(node_config)
|
||||||
web_cfg = None
|
web_cfg = None
|
||||||
if args.web:
|
if args.web:
|
||||||
web_cfg = WebUIConfig(
|
web_cfg = WebUIConfig(
|
||||||
@@ -190,7 +192,7 @@ async def _serve(args: argparse.Namespace) -> int:
|
|||||||
readonly=_env_flag("WAGO_SIM_WEB_READONLY"),
|
readonly=_env_flag("WAGO_SIM_WEB_READONLY"),
|
||||||
)
|
)
|
||||||
app = build_app(modules=modules, host=args.host, port=args.port, web=web_cfg,
|
app = build_app(modules=modules, host=args.host, port=args.port, web=web_cfg,
|
||||||
node_config_path=args.node)
|
node_config_path=args.node, channel_names=channel_names)
|
||||||
await app.server.start()
|
await app.server.start()
|
||||||
web_server = None
|
web_server = None
|
||||||
if web_cfg is not None:
|
if web_cfg is not None:
|
||||||
|
|||||||
+27
-2
@@ -119,12 +119,17 @@ class VirtualNode:
|
|||||||
"""A configured bus coupler with its I/O modules.
|
"""A configured bus coupler with its I/O modules.
|
||||||
|
|
||||||
Computes the WAGO process-image layout for a module list and binds
|
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.datastore = datastore
|
||||||
self.modules = modules
|
self.modules = modules
|
||||||
|
self.channel_names: dict[str, str] = dict(channel_names or {})
|
||||||
self._di_channels: list[ChannelLocation] = []
|
self._di_channels: list[ChannelLocation] = []
|
||||||
self._do_channels: list[ChannelLocation] = []
|
self._do_channels: list[ChannelLocation] = []
|
||||||
self._ai_channels: list[ChannelLocation] = []
|
self._ai_channels: list[ChannelLocation] = []
|
||||||
@@ -188,6 +193,26 @@ class VirtualNode:
|
|||||||
"""Text for firmware-info register 0x2030 (module description)."""
|
"""Text for firmware-info register 0x2030 (module description)."""
|
||||||
return ", ".join(f"{i}:{m.article}" for i, m in enumerate(self.modules))
|
return ", ".join(f"{i}:{m.article}" for i, m in enumerate(self.modules))
|
||||||
|
|
||||||
|
# -- channel naming (spec FR-10.2.11) ----------------------------------
|
||||||
|
|
||||||
|
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) -----------------------------
|
# -- named channel access (scenario engine API) -----------------------------
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -52,6 +52,27 @@ def load_node_modules(node_config: dict) -> list[ModuleType]:
|
|||||||
return modules
|
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]:
|
def validate_scenario(scenario: dict) -> list[str]:
|
||||||
"""Structural validation; returns a list of problems (empty == valid)."""
|
"""Structural validation; returns a list of problems (empty == valid)."""
|
||||||
problems: list[str] = []
|
problems: list[str] = []
|
||||||
|
|||||||
+192
-1
@@ -8,6 +8,7 @@ All handlers touch the simulator's internal memory handlers only —
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
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:
|
if payload.get("persist", True) and sim.node_config_path:
|
||||||
try:
|
try:
|
||||||
save_node_config(sim.node_config_path, modules,
|
save_node_config(sim.node_config_path, modules,
|
||||||
name=Path(sim.node_config_path).stem)
|
name=Path(sim.node_config_path).stem,
|
||||||
|
channel_names=sim.node.channel_names)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
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")
|
@router.websocket("/api/ws")
|
||||||
async def ws_values(websocket: WebSocket) -> None:
|
async def ws_values(websocket: WebSocket) -> None:
|
||||||
sim: SimulatorApp = websocket.app.state.sim
|
sim: SimulatorApp = websocket.app.state.sim
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class ChannelJSON(TypedDict):
|
|||||||
kind: str
|
kind: str
|
||||||
address: str
|
address: str
|
||||||
modbus: str
|
modbus: str
|
||||||
|
channel_name: str | None
|
||||||
|
|
||||||
|
|
||||||
class ModuleJSON(TypedDict):
|
class ModuleJSON(TypedDict):
|
||||||
@@ -38,3 +39,4 @@ class ChannelValue(TypedDict):
|
|||||||
module: int
|
module: int
|
||||||
channel: int
|
channel: int
|
||||||
value: bool | int
|
value: bool | int
|
||||||
|
name: str | None
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Node-config persistence for the web UI (spec §11, FR-11.5).
|
"""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
|
Writes the module list (and, since FR-10.2.11, per-channel names)
|
||||||
emitter is used instead of `yaml.dump` so the file stays human-readable
|
back to the node-config YAML. A small custom emitter is used instead
|
||||||
with a header comment — bench engineers edit this file by hand.
|
of `yaml.dump` so the file stays human-readable with a header comment
|
||||||
|
— bench engineers edit this file by hand.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -21,12 +22,26 @@ coupler: 750-362
|
|||||||
modules:
|
modules:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_NAMES_HEADER = """\
|
||||||
|
|
||||||
|
# Application names for module channels (I/O code -> name)
|
||||||
|
channel_names:
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def save_node_config(
|
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:
|
) -> 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)]
|
lines = [_HEADER.format(name=name)]
|
||||||
for m in modules:
|
for m in modules:
|
||||||
lines.append(f' - "{m.article}" # {m.description}\n')
|
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")
|
Path(path).write_text("".join(lines), encoding="utf-8", newline="\n")
|
||||||
@@ -21,12 +21,14 @@ def node_layout(node: VirtualNode) -> NodeLayoutJSON:
|
|||||||
# module-based addressing per FR-4.3.11: {KIND}{module}.{channel}
|
# module-based addressing per FR-4.3.11: {KIND}{module}.{channel}
|
||||||
# `module` is the 0-based slot; `channel` is 0-based on module.
|
# `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}"
|
||||||
channels.append(
|
channels.append(
|
||||||
{
|
{
|
||||||
"name": ch.name,
|
"name": ch.name,
|
||||||
"kind": ch.kind,
|
"kind": ch.kind,
|
||||||
"address": f"{kind_upper}{index}.{channel_no}",
|
"address": io_code,
|
||||||
"modbus": _modbus_address(node, ch.kind, index, channel_no),
|
"modbus": _modbus_address(node, ch.kind, index, channel_no),
|
||||||
|
"channel_name": node.channel_names.get(io_code),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
modules_json.append(
|
modules_json.append(
|
||||||
@@ -70,40 +72,48 @@ def channel_values(node: VirtualNode) -> list[ChannelValue]:
|
|||||||
|
|
||||||
# Digital channels: global 1-based numbers, bit-packed after analog.
|
# Digital channels: global 1-based numbers, bit-packed after analog.
|
||||||
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)
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"kind": "di",
|
"kind": "di",
|
||||||
"module": loc.module_index,
|
"module": loc.module_index,
|
||||||
"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),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
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)
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"kind": "do",
|
"kind": "do",
|
||||||
"module": loc.module_index,
|
"module": loc.module_index,
|
||||||
"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),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
# Analog channels: word indices in the process image.
|
# Analog channels: word indices in the process image.
|
||||||
for loc in node.ai:
|
for loc in node.ai:
|
||||||
|
code = node.io_code("ai", loc.module_index, loc.channel - 1)
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"kind": "ai",
|
"kind": "ai",
|
||||||
"module": loc.module_index,
|
"module": loc.module_index,
|
||||||
"channel": loc.channel - 1,
|
"channel": loc.channel - 1,
|
||||||
"value": ds.get_input_word(loc.word),
|
"value": ds.get_input_word(loc.word),
|
||||||
|
"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)
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"kind": "ao",
|
"kind": "ao",
|
||||||
"module": loc.module_index,
|
"module": loc.module_index,
|
||||||
"channel": loc.channel - 1,
|
"channel": loc.channel - 1,
|
||||||
"value": ds.get_output_word(loc.word),
|
"value": ds.get_output_word(loc.word),
|
||||||
|
"name": node.channel_names.get(code),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
@@ -26,6 +26,7 @@ async function init() {
|
|||||||
NODE = await api("/api/node");
|
NODE = await api("/api/node");
|
||||||
const diag = await api("/api/diagnostics").catch(() => null);
|
const diag = await api("/api/diagnostics").catch(() => null);
|
||||||
renderRail();
|
renderRail();
|
||||||
|
renderChannelList();
|
||||||
if (diag) renderDiagnostics(diag);
|
if (diag) renderDiagnostics(diag);
|
||||||
openWS();
|
openWS();
|
||||||
}
|
}
|
||||||
@@ -44,7 +45,158 @@ function openWS() {
|
|||||||
function applyValues(values) {
|
function applyValues(values) {
|
||||||
for (const v of values) {
|
for (const v of values) {
|
||||||
VALUES[key(v.kind, v.module, v.channel)] = v.value;
|
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);
|
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 ? `<span class="row-name">${ch.channel_name}</span>`
|
||||||
|
: `<span class="row-name unnamed">—</span>`;
|
||||||
|
let control;
|
||||||
|
if (ch.kind === "di" || ch.kind === "do") {
|
||||||
|
control = `<label class="switch"><input type="checkbox" class="row-value"
|
||||||
|
id="val-${ch.kind}-${mod.index}-${no}"
|
||||||
|
onchange="listWrite('${ch.kind}', ${mod.index}, ${no}, this)">
|
||||||
|
<span class="slider"></span></label>`;
|
||||||
|
} else {
|
||||||
|
control = `<input type="number" class="row-value num" min="0" max="65535"
|
||||||
|
id="val-${ch.kind}-${mod.index}-${no}"
|
||||||
|
onchange="listWrite('${ch.kind}', ${mod.index}, ${no}, this)">`;
|
||||||
|
}
|
||||||
|
html += `<div class="chlist-row" id="${id}" title="${ch.name} · MB ${ch.modbus}">
|
||||||
|
<span class="row-io">${ch.address}</span>
|
||||||
|
${named}
|
||||||
|
${control}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 += `<label class="name-row"><span class="row-io">${ch.address}</span>
|
||||||
|
<input type="text" class="name-input" data-code="${ch.address}"
|
||||||
|
value="${ch.channel_name || ""}"
|
||||||
|
placeholder="${ch.name}"></label>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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");
|
const mc = await api("/api/config/modules");
|
||||||
if (!mc) return;
|
if (!mc) return;
|
||||||
|
renderNameEditor();
|
||||||
const list = document.getElementById("module-list");
|
const list = document.getElementById("module-list");
|
||||||
list.innerHTML = mc.modules.map((m, i) => `
|
list.innerHTML = mc.modules.map((m, i) => `
|
||||||
<label class="module-row">
|
<label class="module-row">
|
||||||
@@ -313,6 +466,14 @@ async function saveModules() {
|
|||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
document.getElementById("coupler-save").addEventListener("click", saveCoupler);
|
document.getElementById("coupler-save").addEventListener("click", saveCoupler);
|
||||||
|
document.getElementById("names-save").addEventListener("click", saveChannelNames);
|
||||||
|
document.getElementById("cfg-export").addEventListener("click", exportConfig);
|
||||||
|
document.getElementById("cfg-import").addEventListener("click", () =>
|
||||||
|
document.getElementById("cfg-file").click());
|
||||||
|
document.getElementById("cfg-file").addEventListener("change", (ev) => {
|
||||||
|
if (ev.target.files.length) importConfig(ev.target.files[0]);
|
||||||
|
ev.target.value = "";
|
||||||
|
});
|
||||||
document.getElementById("module-add").addEventListener("click", () => {
|
document.getElementById("module-add").addEventListener("click", () => {
|
||||||
MODULE_ARTICLES.push(document.getElementById("catalog-pick").value);
|
MODULE_ARTICLES.push(document.getElementById("catalog-pick").value);
|
||||||
renderModuleRows();
|
renderModuleRows();
|
||||||
|
|||||||
@@ -32,6 +32,12 @@
|
|||||||
<h2>Node overview</h2>
|
<h2>Node overview</h2>
|
||||||
<div id="rail" class="rail"></div>
|
<div id="rail" class="rail"></div>
|
||||||
<div id="rail-detail" class="rail-detail"></div>
|
<div id="rail-detail" class="rail-detail"></div>
|
||||||
|
|
||||||
|
<h3 class="list-title">Channels</h3>
|
||||||
|
<div class="chlist-head">
|
||||||
|
<span>I/O</span><span>Name</span><span>Value</span>
|
||||||
|
</div>
|
||||||
|
<div id="chlist" class="chlist"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ============ Configuration ============ -->
|
<!-- ============ Configuration ============ -->
|
||||||
@@ -63,6 +69,24 @@
|
|||||||
<button id="module-save" class="primary">SAVE CONFIG</button>
|
<button id="module-save" class="primary">SAVE CONFIG</button>
|
||||||
<span id="module-path" class="cfg-path"></span>
|
<span id="module-path" class="cfg-path"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<h4>Channel names</h4>
|
||||||
|
<p class="cfg-note">Rename channels for the bench (I/O code →
|
||||||
|
application name). Names show in the overview list and tooltips
|
||||||
|
and persist with the node config.</p>
|
||||||
|
<div id="name-editor" class="name-editor"></div>
|
||||||
|
<div id="name-msg" class="error hidden"></div>
|
||||||
|
<div class="fault-buttons">
|
||||||
|
<button id="names-save">Save channel names</button>
|
||||||
|
</div>
|
||||||
|
<h4>Import / export</h4>
|
||||||
|
<p class="cfg-note">The complete configuration (modules, channel
|
||||||
|
names, coupler settings) as one JSON file.</p>
|
||||||
|
<div class="fault-buttons">
|
||||||
|
<button id="cfg-export">Export configuration</button>
|
||||||
|
<input type="file" id="cfg-file" accept=".json" class="hidden-file">
|
||||||
|
<button id="cfg-import">Import configuration…</button>
|
||||||
|
</div>
|
||||||
|
<div id="io-msg" class="error hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -186,3 +186,67 @@ h2 { margin: 4px 0 14px; }
|
|||||||
.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; }
|
||||||
button.primary:hover { background: #1b5e20; }
|
button.primary:hover { background: #1b5e20; }
|
||||||
|
|
||||||
|
/* ---------- channel list (FR-10.2.13) ---------- */
|
||||||
|
|
||||||
|
.list-title { margin: 22px 0 8px; }
|
||||||
|
.chlist-head, .chlist-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 70px 1fr 90px;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.chlist-head {
|
||||||
|
font-size: 0.72rem; text-transform: uppercase; color: #6b7684;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 0 10px 4px;
|
||||||
|
}
|
||||||
|
.chlist {
|
||||||
|
max-height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #d8dee5;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.chlist-row { padding: 4px 10px; border-bottom: 1px solid #eef1f4; }
|
||||||
|
.chlist-row:last-child { border-bottom: 0; }
|
||||||
|
.chlist-row:hover { background: #f2f6fa; }
|
||||||
|
.row-io {
|
||||||
|
font-family: Consolas, monospace; font-size: 0.82rem;
|
||||||
|
color: #004a99; font-weight: 600;
|
||||||
|
}
|
||||||
|
.row-name { font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.row-name.unnamed { color: #9aa4ae; }
|
||||||
|
.row-value.num {
|
||||||
|
width: 100%; padding: 4px 6px; border: 1px solid #c5ccd4;
|
||||||
|
border-radius: 4px; font-family: Consolas, monospace; font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* toggle switch */
|
||||||
|
.switch { position: relative; display: inline-block; width: 40px; height: 22px; }
|
||||||
|
.switch input { opacity: 0; width: 0; height: 0; }
|
||||||
|
.slider {
|
||||||
|
position: absolute; inset: 0; background: #b9c2cb;
|
||||||
|
border-radius: 22px; transition: 0.15s; cursor: pointer;
|
||||||
|
}
|
||||||
|
.slider::before {
|
||||||
|
content: ""; position: absolute; width: 16px; height: 16px;
|
||||||
|
left: 3px; top: 3px; background: #fff; border-radius: 50%;
|
||||||
|
transition: 0.15s;
|
||||||
|
}
|
||||||
|
.switch input:checked + .slider { background: #37b34a; }
|
||||||
|
.switch input:checked + .slider::before { transform: translateX(18px); }
|
||||||
|
|
||||||
|
/* ---------- channel-name editor ---------- */
|
||||||
|
|
||||||
|
.name-editor { max-height: 300px; overflow-y: auto; margin-bottom: 10px; }
|
||||||
|
.name-row {
|
||||||
|
display: grid; grid-template-columns: 70px 1fr; gap: 8px;
|
||||||
|
align-items: center; padding: 2px 0;
|
||||||
|
}
|
||||||
|
.name-input {
|
||||||
|
padding: 5px 8px; border: 1px solid #c5ccd4; border-radius: 4px;
|
||||||
|
font-size: 0.85rem; width: 100%;
|
||||||
|
}
|
||||||
|
.hidden-file { display: none; }
|
||||||
|
#cfg-io-buttons { display: flex; gap: 8px; margin-top: 8px; }
|
||||||
@@ -314,3 +314,138 @@ class TestModuleConfig:
|
|||||||
# write an output via the internal dispatch and read the mirror
|
# write an output via the internal dispatch and read the mirror
|
||||||
app.datastore.write_words(0x0000, [0x1234])
|
app.datastore.write_words(0x0000, [0x1234])
|
||||||
assert app.datastore.read_words(0x0200, 1) == [0x1234]
|
assert app.datastore.read_words(0x0200, 1) == [0x1234]
|
||||||
|
|
||||||
|
|
||||||
|
class TestChannelNames:
|
||||||
|
async def test_names_in_layout_and_values(self, client, app):
|
||||||
|
r = await client.put("/api/config/channel-names", json={
|
||||||
|
"channel_names": {"DI0.0": "start_button", "AI4.0": "tank_level"},
|
||||||
|
"persist": False,
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
# layout carries the name on the right channel
|
||||||
|
r = await client.get("/api/node")
|
||||||
|
di0 = next(c for m in r.json()["modules"] if m["index"] == 0
|
||||||
|
for c in m["channels"] if c["address"] == "DI0.0")
|
||||||
|
assert di0["channel_name"] == "start_button"
|
||||||
|
# values snapshot carries it too
|
||||||
|
r = await client.get("/api/values")
|
||||||
|
entry = next(v for v in r.json()["values"]
|
||||||
|
if (v["kind"], v["module"], v["channel"]) == ("ai", 4, 0))
|
||||||
|
assert entry["name"] == "tank_level"
|
||||||
|
|
||||||
|
async def test_names_persist_to_yaml(self, client, app, tmp_path):
|
||||||
|
cfg_file = tmp_path / "node.yaml"
|
||||||
|
app.node_config_path = str(cfg_file)
|
||||||
|
r = await client.put("/api/config/channel-names", json={
|
||||||
|
"channel_names": {"DO2.0": "pump_relay"}, "persist": True,
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
text = cfg_file.read_text(encoding="utf-8")
|
||||||
|
assert "channel_names:" in text
|
||||||
|
assert "DO2.0: pump_relay" in text
|
||||||
|
# and it loads back
|
||||||
|
from wago_sim.scenario.dsl import load_yaml, load_channel_names
|
||||||
|
names = load_channel_names(load_yaml(cfg_file))
|
||||||
|
assert names == {"DO2.0": "pump_relay"}
|
||||||
|
|
||||||
|
async def test_invalid_code_rejected(self, client):
|
||||||
|
r = await client.put("/api/config/channel-names", json={
|
||||||
|
"channel_names": {"XX0.0": "nope"}, "persist": False,
|
||||||
|
})
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "invalid I/O code" in r.json()["detail"]
|
||||||
|
|
||||||
|
async def test_names_survive_module_save(self, client, app, tmp_path):
|
||||||
|
"""Module SAVE CONFIG must not wipe channel names (they persist)."""
|
||||||
|
app.node_config_path = str(tmp_path / "node.yaml")
|
||||||
|
await client.put("/api/config/channel-names", json={
|
||||||
|
"channel_names": {"DI0.0": "start_button"}, "persist": True,
|
||||||
|
})
|
||||||
|
r = await client.put("/api/config/modules", json={
|
||||||
|
"modules": ["750-430", "750-530"], "persist": True,
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert app.node.channel_names.get("DI0.0") == "start_button"
|
||||||
|
# YAML contains both sections
|
||||||
|
text = (tmp_path / "node.yaml").read_text(encoding="utf-8")
|
||||||
|
assert "DI0.0: start_button" in text
|
||||||
|
|
||||||
|
async def test_readonly_blocks_rename(self, app):
|
||||||
|
ro = build_webui(app, user="admin", password="simu", readonly=True)
|
||||||
|
transport = httpx.ASGITransport(app=ro)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||||
|
await c.post("/login", json={"user": "admin", "password": "simu"})
|
||||||
|
r = await c.put("/api/config/channel-names", json={"channel_names": {}})
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigExportImport:
|
||||||
|
async def test_export_shape(self, client):
|
||||||
|
r = await client.get("/api/config/export")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["format"] == "wago-sim-config"
|
||||||
|
assert data["version"] == 1
|
||||||
|
assert len(data["modules"]) == 6
|
||||||
|
assert "coupler_settings" in data
|
||||||
|
assert "channel_names" in data
|
||||||
|
|
||||||
|
async def test_export_import_roundtrip(self, client, app, tmp_path):
|
||||||
|
# configure something recognizable
|
||||||
|
await client.put("/api/config/channel-names", json={
|
||||||
|
"channel_names": {"DI0.0": "start_button"}, "persist": False,
|
||||||
|
})
|
||||||
|
await client.put("/api/config/coupler", json={"response_delay_ms": 33})
|
||||||
|
exported = (await client.get("/api/config/export")).json()
|
||||||
|
|
||||||
|
app.node_config_path = str(tmp_path / "node.yaml")
|
||||||
|
r = await client.post("/api/config/import", json=exported)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert "750-430" in body["modules"]
|
||||||
|
# names and coupler settings applied
|
||||||
|
assert app.node.channel_names.get("DI0.0") == "start_button"
|
||||||
|
assert app.registers.read_config(0x1037, 1) == [33]
|
||||||
|
# persisted to YAML
|
||||||
|
text = (tmp_path / "node.yaml").read_text(encoding="utf-8")
|
||||||
|
assert "DI0.0: start_button" in text
|
||||||
|
|
||||||
|
async def test_import_rejects_bad_payload(self, client, app):
|
||||||
|
# wrong format marker
|
||||||
|
r = await client.post("/api/config/import", json={"format": "nope"})
|
||||||
|
assert r.status_code == 400
|
||||||
|
# unknown article: nothing applied
|
||||||
|
before = [m.article for m in app.node.modules]
|
||||||
|
r = await client.post("/api/config/import", json={
|
||||||
|
"format": "wago-sim-config", "modules": ["750-9999"],
|
||||||
|
"channel_names": {}, "coupler_settings": {},
|
||||||
|
})
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert [m.article for m in app.node.modules] == before # untouched
|
||||||
|
|
||||||
|
async def test_import_drops_stale_name_codes(self, client, app):
|
||||||
|
# names for channels that don't exist on the imported layout
|
||||||
|
r = await client.post("/api/config/import", json={
|
||||||
|
"format": "wago-sim-config",
|
||||||
|
"modules": ["750-530"],
|
||||||
|
"channel_names": {"DO0.0": "pump", "DI0.0": "stale_name"},
|
||||||
|
"coupler_settings": {},
|
||||||
|
"persist": False,
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["dropped_codes"] == ["DI0.0"]
|
||||||
|
assert app.node.channel_names == {"DO0.0": "pump"}
|
||||||
|
|
||||||
|
async def test_readonly_blocks_import_export(self, app):
|
||||||
|
ro = build_webui(app, user="admin", password="simu", readonly=True)
|
||||||
|
transport = httpx.ASGITransport(app=ro)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||||
|
await c.post("/login", json={"user": "admin", "password": "simu"})
|
||||||
|
r = await c.get("/api/config/export")
|
||||||
|
assert r.status_code == 200 # export is a read
|
||||||
|
r = await c.post("/api/config/import", json={"format": "wago-sim-config",
|
||||||
|
"modules": ["750-430"]})
|
||||||
|
assert r.status_code == 403
|
||||||
Reference in New Issue
Block a user