🚧 WIP
This commit is contained in:
Vendored
+55
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
+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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
+9
-4
@@ -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,
|
||||
|
||||
+4
-2
@@ -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:
|
||||
|
||||
+27
-2
@@ -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
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
+192
-1
@@ -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
|
||||
|
||||
@@ -11,6 +11,7 @@ class ChannelJSON(TypedDict):
|
||||
kind: str
|
||||
address: str
|
||||
modbus: str
|
||||
channel_name: str | None
|
||||
|
||||
|
||||
class ModuleJSON(TypedDict):
|
||||
@@ -38,3 +39,4 @@ class ChannelValue(TypedDict):
|
||||
module: int
|
||||
channel: int
|
||||
value: bool | int
|
||||
name: str | None
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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 ? `<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");
|
||||
if (!mc) return;
|
||||
renderNameEditor();
|
||||
const list = document.getElementById("module-list");
|
||||
list.innerHTML = mc.modules.map((m, i) => `
|
||||
<label class="module-row">
|
||||
@@ -313,6 +466,14 @@ async function saveModules() {
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
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", () => {
|
||||
MODULE_ARTICLES.push(document.getElementById("catalog-pick").value);
|
||||
renderModuleRows();
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
<h2>Node overview</h2>
|
||||
<div id="rail" class="rail"></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>
|
||||
|
||||
<!-- ============ Configuration ============ -->
|
||||
@@ -63,6 +69,24 @@
|
||||
<button id="module-save" class="primary">SAVE CONFIG</button>
|
||||
<span id="module-path" class="cfg-path"></span>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -186,3 +186,67 @@ h2 { margin: 4px 0 14px; }
|
||||
.cfg-path { font-size: 0.75rem; color: #6b7684; align-self: center; }
|
||||
button.primary { background: #2e7d32; }
|
||||
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
|
||||
app.datastore.write_words(0x0000, [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