This commit is contained in:
Jonas Bengtsson
2026-10-04 09:28:02 +02:00
parent 87c1c8db7b
commit 4adbb4171e
16 changed files with 788 additions and 29 deletions
+55
View File
@@ -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": []
} }
] ]
} }
+14 -2
View File
@@ -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
View File
@@ -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
+32 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+21
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
+20 -5
View File
@@ -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")
+11 -1
View File
@@ -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
+161
View File
@@ -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();
+24
View File
@@ -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>
+64
View File
@@ -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; }
+135
View File
@@ -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