🚧 WIP
This commit is contained in:
Vendored
+135
@@ -536,6 +536,141 @@
|
||||
],
|
||||
"isBackground": false,
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "wago-sim: pytest ranges",
|
||||
"type": "shell",
|
||||
"command": ".venv\\Scripts\\python.exe",
|
||||
"args": [
|
||||
"-m",
|
||||
"pytest",
|
||||
"tests",
|
||||
"-q",
|
||||
"--tb=short"
|
||||
],
|
||||
"isBackground": false,
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "wago-sim: serve ranges",
|
||||
"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": "stop ranges serve",
|
||||
"type": "shell",
|
||||
"command": ".venv\\Scripts\\python.exe",
|
||||
"args": [
|
||||
"-c",
|
||||
"import subprocess; subprocess.run(['powershell','-NoProfile','-Command','Get-NetTCPConnection -LocalPort 5020,8088 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force }'], capture_output=True); print('stopped')"
|
||||
],
|
||||
"isBackground": false,
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "wago-sim: pytest labels",
|
||||
"type": "shell",
|
||||
"command": ".venv\\Scripts\\python.exe",
|
||||
"args": [
|
||||
"-m",
|
||||
"pytest",
|
||||
"tests",
|
||||
"-q",
|
||||
"--tb=short"
|
||||
],
|
||||
"isBackground": false,
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "wago-sim: serve labels",
|
||||
"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": "stop labels serve",
|
||||
"type": "shell",
|
||||
"command": ".venv\\Scripts\\python.exe",
|
||||
"args": [
|
||||
"-c",
|
||||
"import subprocess; subprocess.run(['powershell','-NoProfile','-Command','Get-NetTCPConnection -LocalPort 5020,8088 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force }'], capture_output=True); print('stopped')"
|
||||
],
|
||||
"isBackground": false,
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "wago-sim: pytest designations",
|
||||
"type": "shell",
|
||||
"command": ".venv\\Scripts\\python.exe",
|
||||
"args": [
|
||||
"-m",
|
||||
"pytest",
|
||||
"tests",
|
||||
"-q",
|
||||
"--tb=short"
|
||||
],
|
||||
"isBackground": false,
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "wago-sim: serve designations",
|
||||
"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": "stop designations serve",
|
||||
"type": "shell",
|
||||
"command": ".venv\\Scripts\\python.exe",
|
||||
"args": [
|
||||
"-c",
|
||||
"import subprocess; subprocess.run(['powershell','-NoProfile','-Command','Get-NetTCPConnection -LocalPort 5020,8088 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force }'], capture_output=True); print('stopped')"
|
||||
],
|
||||
"isBackground": false,
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -307,10 +307,13 @@ requirement ID for each item in parentheses.
|
||||
`--web-port`, env `WAGO_SIM_WEB_USER/PASSWORD/READONLY/
|
||||
NO_WEB/WEB_PORT`; login page + SPA shell.
|
||||
- [x] M2: node overview page — DIN-rail strip with coupler block
|
||||
(MS/NS/BF/SF LEDs), module bezels with article numbers,
|
||||
per-channel LEDs for DI/DO, bar+numeric indicators for AI/AO,
|
||||
live values via WebSocket (`/api/ws`, 10 Hz delta push),
|
||||
`GET /api/values` snapshot.
|
||||
(MS/NS/BF/SF LEDs), module bezels with article numbers plus the
|
||||
kind+slot designation (`DI0`, `DO2`, `AI4`) in the header's
|
||||
upper right corner, per-channel LEDs for DI/DO with the channel
|
||||
number beneath, bar+numeric indicators for AI/AO with the channel
|
||||
number outside the overflow/underflow limit LEDs, live values
|
||||
via WebSocket (`/api/ws`, 10 Hz delta push), `GET /api/values`
|
||||
snapshot (FR-10.2.2).
|
||||
- [x] M3: manipulation — DI/DO toggle (field side via
|
||||
`set_digital_input_channel`, master side via internal
|
||||
`write_bits`/`write_words` dispatch so mirrors/statistics stay
|
||||
@@ -331,15 +334,26 @@ requirement ID for each item in parentheses.
|
||||
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.
|
||||
- [x] M5b: analog display ranges (FR-10.2.14) — bar indicators fill
|
||||
from the bottom against the channel's display range, default
|
||||
0–4095 counts (100 % at 4095, WAGO 750 12-bit scaling), with
|
||||
per-channel min/max configurable in the node config
|
||||
(`channel_ranges:`) and editable on the Configuration page; values
|
||||
outside the range fill the bar red with a red LED above the bar
|
||||
(overflow) or below it (underflow); ranges carried through
|
||||
module saves, export/import, and rebuilds; also fixes the bar
|
||||
never filling (it set width instead of height on a vertical bar)
|
||||
and the invisible numeric label (clipped by overflow: hidden).
|
||||
- [ ] M6: scenario run view + alias-ownership rejection (FR-10.2.6,
|
||||
FR-10.2.9) — deferred.
|
||||
- [x] Tests: 38 web-UI tests (`tests/test_webui.py`) via httpx
|
||||
- [x] Tests: 46 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; channel-name save + list sync;
|
||||
export/import round-trip with a Modbus read after the rebuild.
|
||||
Full suite: 216 passing.
|
||||
export/import round-trip with a Modbus read after the rebuild;
|
||||
bar states verified for in-range (97.7 % at 4000), overflow,
|
||||
custom-range, and underflow. Full suite: 224 passing.
|
||||
|
||||
### Framework gaps (spec §4 — Scenario & Macro Engine)
|
||||
|
||||
|
||||
@@ -890,7 +890,15 @@ Terminal monitors (§9) suffice for single engineers, but FAT benches are
|
||||
- FR-10.2.2: The UI shall present the **node overview**: module list with
|
||||
article numbers, per-module channel tables (type, module, channel,
|
||||
addressing `DI0.1` per FR-4.3.11, Modbus address, current value/state),
|
||||
derived from the same process image the Modbus server serves.
|
||||
derived from the same process image the Modbus server serves. On the
|
||||
node graphic (requested 2026-10-04, refined same day): each module
|
||||
shows its kind+slot designation (`DI0`, `DO2`, `AI4`, …) in the
|
||||
module header's upper right corner — same size and vertical
|
||||
alignment as the article number — and the channel indicators carry
|
||||
only the 0-based channel number: below each DI/DO LED, and on analog
|
||||
modules outside the indicator stack, above the overflow and below
|
||||
the underflow LED (FR-10.2.14) so the limit LEDs stay adjacent to the
|
||||
bar.
|
||||
- FR-10.2.3: Values shall update live (WebSocket push or short-polling),
|
||||
including changes made by the PLC/master over Modbus, by scenarios, by
|
||||
the console monitor, or by other UI clients — one shared source of truth.
|
||||
@@ -947,6 +955,16 @@ Terminal monitors (§9) suffice for single engineers, but FAT benches are
|
||||
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.
|
||||
- FR-10.2.14: **Analog display ranges** (requested 2026-10-04): the
|
||||
analog bar indicators (rail + list) scale a channel's raw value
|
||||
against a display range. The default range is 0–4095 counts
|
||||
(100 % at 4095, matching the WAGO 750 analog modules' 12-bit
|
||||
scaling); per-channel `min`/`max` can be configured in the node
|
||||
configuration — used by both AI (generated) and AO (used) values.
|
||||
Values within the range fill the bar 0–100 % from the bottom.
|
||||
Underflow/overflow are indicated: the bar fills in the limit color
|
||||
(red) and a small red LED above the bar lights for overflow, below
|
||||
the bar for underflow, clearing when the value returns to range.
|
||||
|
||||
### 10.3 Implementation decisions (binding, 2026-10-03)
|
||||
|
||||
|
||||
+10
-5
@@ -43,7 +43,8 @@ class SimulatorApp:
|
||||
|
||||
|
||||
def rebuild_node(app: SimulatorApp, modules: list[ModuleType],
|
||||
channel_names: dict[str, str] | None = None) -> None:
|
||||
channel_names: dict[str, str] | None = None,
|
||||
channel_ranges: dict[str, tuple[int, int]] | 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
|
||||
@@ -53,13 +54,15 @@ def rebuild_node(app: SimulatorApp, modules: list[ModuleType],
|
||||
(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. Channel names (FR-10.2.11)
|
||||
are carried over when `channel_names` is None (unchanged module
|
||||
list) or replaced with the given mapping.
|
||||
and analog display ranges (FR-10.2.14) are carried over when the
|
||||
argument is None; an explicit mapping replaces it.
|
||||
"""
|
||||
datastore = app.datastore
|
||||
names = channel_names if channel_names is not None else app.node.channel_names
|
||||
ranges = channel_ranges if channel_ranges is not None else app.node.channel_ranges
|
||||
datastore.reset_images()
|
||||
app.node = VirtualNode(datastore, modules, channel_names=names)
|
||||
app.node = VirtualNode(datastore, modules,
|
||||
channel_names=names, channel_ranges=ranges)
|
||||
app.registers.set_process_image_bits(
|
||||
analog_in=app.node.analog_in_bits,
|
||||
analog_out=app.node.analog_out_bits,
|
||||
@@ -79,6 +82,7 @@ def build_app(
|
||||
web: WebUIConfig | None = None,
|
||||
node_config_path: str | None = None,
|
||||
channel_names: dict[str, str] | None = None,
|
||||
channel_ranges: dict[str, tuple[int, int]] | None = None,
|
||||
) -> SimulatorApp:
|
||||
"""Assemble all simulator components for a node configuration."""
|
||||
stats = ModbusStatistics()
|
||||
@@ -121,7 +125,8 @@ def build_app(
|
||||
)
|
||||
datastore._config = registers # late binding to break the cycle
|
||||
|
||||
node = VirtualNode(datastore, modules, channel_names=channel_names)
|
||||
node = VirtualNode(datastore, modules, channel_names=channel_names,
|
||||
channel_ranges=channel_ranges)
|
||||
registers.set_process_image_bits(
|
||||
analog_in=node.analog_in_bits,
|
||||
analog_out=node.analog_out_bits,
|
||||
|
||||
+5
-3
@@ -40,8 +40,8 @@ import yaml
|
||||
|
||||
from .app import WebUIConfig, build_app
|
||||
from .modules import ChannelMismatch, ModuleUnknown
|
||||
from .scenario.dsl import (load_channel_names, load_node_modules,
|
||||
load_yaml, validate_scenario)
|
||||
from .scenario.dsl import (load_channel_names, load_channel_ranges,
|
||||
load_node_modules, load_yaml, validate_scenario)
|
||||
from .scenario.engine import ScenarioEngine, ScenarioError
|
||||
from .scenario.report import result_summary, write_csv, write_jsonl
|
||||
|
||||
@@ -182,6 +182,7 @@ 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)
|
||||
channel_ranges = load_channel_ranges(node_config)
|
||||
web_cfg = None
|
||||
if args.web:
|
||||
web_cfg = WebUIConfig(
|
||||
@@ -192,7 +193,8 @@ 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, channel_names=channel_names)
|
||||
node_config_path=args.node, channel_names=channel_names,
|
||||
channel_ranges=channel_ranges)
|
||||
await app.server.start()
|
||||
web_server = None
|
||||
if web_cfg is not None:
|
||||
|
||||
+18
-2
@@ -122,14 +122,21 @@ class VirtualNode:
|
||||
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", ...).
|
||||
("DI0.0", "AI4.1", ...). Analog display ranges (FR-10.2.14) follow
|
||||
the same pattern: keyed by I/O code, (min, max) raw counts,
|
||||
defaulting to (0, 4095) — 100 % at 4095 per the WAGO 750 analog
|
||||
modules' 12-bit scaling.
|
||||
"""
|
||||
|
||||
DEFAULT_RANGE = (0, 4095)
|
||||
|
||||
def __init__(self, datastore: WagoDatastore, modules: list[ModuleType],
|
||||
channel_names: dict[str, str] | None = None) -> None:
|
||||
channel_names: dict[str, str] | None = None,
|
||||
channel_ranges: dict[str, tuple[int, int]] | None = None) -> None:
|
||||
self.datastore = datastore
|
||||
self.modules = modules
|
||||
self.channel_names: dict[str, str] = dict(channel_names or {})
|
||||
self.channel_ranges: dict[str, tuple[int, int]] = dict(channel_ranges or {})
|
||||
self._di_channels: list[ChannelLocation] = []
|
||||
self._do_channels: list[ChannelLocation] = []
|
||||
self._ai_channels: list[ChannelLocation] = []
|
||||
@@ -203,6 +210,15 @@ class VirtualNode:
|
||||
"""Configured application name for a channel, or None."""
|
||||
return self.channel_names.get(self.io_code(kind, module, channel))
|
||||
|
||||
def channel_range(self, kind: str, module: int, channel: int) -> tuple[int, int]:
|
||||
"""Display range (min, max) raw counts for an analog channel.
|
||||
|
||||
Defaults to (0, 4095) when unconfigured (FR-10.2.14).
|
||||
"""
|
||||
return self.channel_ranges.get(
|
||||
self.io_code(kind, module, channel), self.DEFAULT_RANGE
|
||||
)
|
||||
|
||||
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)."""
|
||||
|
||||
@@ -73,6 +73,37 @@ def load_channel_names(node_config: dict) -> dict[str, str]:
|
||||
if str(name).strip()}
|
||||
|
||||
|
||||
def load_channel_ranges(node_config: dict) -> dict[str, tuple[int, int]]:
|
||||
"""Per-channel analog display ranges from the node configuration.
|
||||
|
||||
Shape (FR-10.2.14)::
|
||||
|
||||
channel_ranges:
|
||||
AI4.0: { min: 0, max: 32000 }
|
||||
AO5.0: { min: 400, max: 2000 }
|
||||
|
||||
Keys are I/O codes; values are raw-count display ranges used by
|
||||
the web UI bar indicators. Channels without an entry scale 0-4095.
|
||||
"""
|
||||
raw = node_config.get("channel_ranges") or {}
|
||||
if not isinstance(raw, dict):
|
||||
raise ScenarioError("channel_ranges must be a mapping of I/O code -> {min, max}")
|
||||
ranges: dict[str, tuple[int, int]] = {}
|
||||
for code, spec in raw.items():
|
||||
code_up = str(code).strip().upper()
|
||||
if not isinstance(spec, dict) or "min" not in spec or "max" not in spec:
|
||||
raise ScenarioError(
|
||||
f"channel_ranges entry for {code_up!r} needs 'min' and 'max'"
|
||||
)
|
||||
lo, hi = int(spec["min"]), int(spec["max"])
|
||||
if not 0 <= lo < hi <= 65535:
|
||||
raise ScenarioError(
|
||||
f"channel_ranges for {code_up!r}: need 0 <= min < max <= 65535"
|
||||
)
|
||||
ranges[code_up] = (lo, hi)
|
||||
return ranges
|
||||
|
||||
|
||||
def validate_scenario(scenario: dict) -> list[str]:
|
||||
"""Structural validation; returns a list of problems (empty == valid)."""
|
||||
problems: list[str] = []
|
||||
|
||||
+107
-3
@@ -399,7 +399,8 @@ async def put_module_config(
|
||||
try:
|
||||
save_node_config(sim.node_config_path, modules,
|
||||
name=Path(sim.node_config_path).stem,
|
||||
channel_names=sim.node.channel_names)
|
||||
channel_names=sim.node.channel_names,
|
||||
channel_ranges=sim.node.channel_ranges)
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
@@ -476,6 +477,86 @@ async def put_channel_names(
|
||||
return {"ok": True, "channel_names": names, "unknown_codes": stale_now}
|
||||
|
||||
|
||||
# -- channel display ranges (FR-10.2.14) ---------------------------------------
|
||||
|
||||
|
||||
@router.get("/api/config/channel-ranges")
|
||||
async def get_channel_ranges(
|
||||
sim: SimulatorApp = Depends(get_sim),
|
||||
_: None = Depends(require_session),
|
||||
) -> dict:
|
||||
return {
|
||||
"channel_ranges": {
|
||||
code: {"min": lo, "max": hi} for code, (lo, hi) in sim.node.channel_ranges.items()
|
||||
},
|
||||
"default": {"min": 0, "max": 4095},
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/config/channel-ranges")
|
||||
async def put_channel_ranges(
|
||||
payload: dict,
|
||||
request: Request,
|
||||
sim: SimulatorApp = Depends(get_sim),
|
||||
sessions: SessionStore = Depends(get_sessions),
|
||||
) -> dict:
|
||||
"""Set per-channel analog display ranges (FR-10.2.14).
|
||||
|
||||
Payload: {"channel_ranges": {"AI4.0": {"min": 0, "max": 32000}, ...}}.
|
||||
Codes are regex-validated; 0 <= min < max <= 65535 required; an
|
||||
entry with min=0/max=4095 (the default) is dropped from the store.
|
||||
"""
|
||||
_require_write(request, sessions)
|
||||
raw = payload.get("channel_ranges")
|
||||
if not isinstance(raw, dict):
|
||||
raise HTTPException(status_code=400, detail="'channel_ranges' must be a mapping")
|
||||
ranges: dict[str, tuple[int, int]] = {}
|
||||
for code, spec 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. AI4.0)",
|
||||
)
|
||||
if not isinstance(spec, dict) or "min" not in spec or "max" not in spec:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"range for {code_up!r} needs 'min' and 'max'"
|
||||
)
|
||||
try:
|
||||
lo, hi = int(spec["min"]), int(spec["max"])
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"range for {code_up!r} must be numeric"
|
||||
) from None
|
||||
if not 0 <= lo < hi <= 65535:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"range for {code_up!r}: need 0 <= min < max <= 65535",
|
||||
)
|
||||
if (lo, hi) == (0, 4095):
|
||||
continue # default — keep the store clean
|
||||
ranges[code_up] = (lo, hi)
|
||||
sim.node.channel_ranges = ranges
|
||||
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=sim.node.channel_names,
|
||||
channel_ranges=ranges,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"ranges applied, but saving {sim.node_config_path} failed: {exc}",
|
||||
) from None
|
||||
return {
|
||||
"ok": True,
|
||||
"channel_ranges": {c: {"min": lo, "max": hi} for c, (lo, hi) in ranges.items()},
|
||||
}
|
||||
|
||||
|
||||
# -- configuration import/export (FR-10.2.12) ---------------------------------
|
||||
|
||||
|
||||
@@ -507,6 +588,8 @@ async def export_config(
|
||||
"coupler": "750-362",
|
||||
"modules": [m.article for m in sim.node.modules],
|
||||
"channel_names": dict(sim.node.channel_names),
|
||||
"channel_ranges": {c: {"min": lo, "max": hi}
|
||||
for c, (lo, hi) in sim.node.channel_ranges.items()},
|
||||
"coupler_settings": _coupler_snapshot(sim),
|
||||
}
|
||||
|
||||
@@ -547,16 +630,34 @@ async def import_config(
|
||||
detail=f"invalid I/O code {code!r}")
|
||||
if str(name).strip():
|
||||
names[code_up] = str(name).strip()
|
||||
ranges_raw = payload.get("channel_ranges") or {}
|
||||
if not isinstance(ranges_raw, dict):
|
||||
raise HTTPException(status_code=400, detail="'channel_ranges' must be a mapping")
|
||||
ranges: dict[str, tuple[int, int]] = {}
|
||||
for code, spec in ranges_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 not isinstance(spec, dict) or "min" not in spec or "max" not in spec:
|
||||
raise HTTPException(status_code=400,
|
||||
detail=f"range for {code_up!r} needs 'min' and 'max'")
|
||||
lo, hi = int(spec["min"]), int(spec["max"])
|
||||
if not 0 <= lo < hi <= 65535:
|
||||
raise HTTPException(status_code=400,
|
||||
detail=f"range for {code_up!r}: need 0 <= min < max <= 65535")
|
||||
if (lo, hi) != (0, 4095):
|
||||
ranges[code_up] = (lo, hi)
|
||||
coupler = payload.get("coupler_settings") or {}
|
||||
|
||||
# -- everything validated: apply atomically -------------------------
|
||||
rebuild_node(sim, modules, channel_names=names)
|
||||
rebuild_node(sim, modules, channel_names=names, channel_ranges=ranges)
|
||||
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)
|
||||
rebuild_node(sim, modules, channel_names=names, channel_ranges=ranges)
|
||||
errors: list[str] = []
|
||||
wd = coupler.get("watchdog") or {}
|
||||
writes: list[tuple[int, int]] = []
|
||||
@@ -585,6 +686,7 @@ async def import_config(
|
||||
sim.node_config_path, modules,
|
||||
name=Path(sim.node_config_path).stem,
|
||||
channel_names=names,
|
||||
channel_ranges=ranges,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
@@ -595,6 +697,8 @@ async def import_config(
|
||||
"ok": True,
|
||||
"modules": [m.article for m in modules],
|
||||
"channel_names": names,
|
||||
"channel_ranges": {c: {"min": lo, "max": hi}
|
||||
for c, (lo, hi) in ranges.items()},
|
||||
"coupler_writes_applied": len(writes) - len(errors),
|
||||
"coupler_write_errors": errors,
|
||||
"dropped_codes": stale,
|
||||
|
||||
@@ -12,6 +12,7 @@ class ChannelJSON(TypedDict):
|
||||
address: str
|
||||
modbus: str
|
||||
channel_name: str | None
|
||||
range: dict | None # {"min": int, "max": int} for ai/ao, else None
|
||||
|
||||
|
||||
class ModuleJSON(TypedDict):
|
||||
|
||||
@@ -28,6 +28,12 @@ _NAMES_HEADER = """\
|
||||
channel_names:
|
||||
"""
|
||||
|
||||
_RANGES_HEADER = """\
|
||||
|
||||
# Analog display ranges (I/O code -> {min, max} raw counts; default 0-4095)
|
||||
channel_ranges:
|
||||
"""
|
||||
|
||||
|
||||
def save_node_config(
|
||||
path: str | Path,
|
||||
@@ -35,8 +41,9 @@ def save_node_config(
|
||||
*,
|
||||
name: str = "node",
|
||||
channel_names: dict[str, str] | None = None,
|
||||
channel_ranges: dict[str, tuple[int, int]] | None = None,
|
||||
) -> None:
|
||||
"""Write the module list + channel names to the node-config YAML."""
|
||||
"""Write the module list + channel names + ranges to node-config YAML."""
|
||||
lines = [_HEADER.format(name=name)]
|
||||
for m in modules:
|
||||
lines.append(f' - "{m.article}" # {m.description}\n')
|
||||
@@ -44,4 +51,9 @@ def save_node_config(
|
||||
lines.append(_NAMES_HEADER)
|
||||
for code in sorted(channel_names):
|
||||
lines.append(f" {code}: {channel_names[code]}\n")
|
||||
if channel_ranges:
|
||||
lines.append(_RANGES_HEADER)
|
||||
for code in sorted(channel_ranges):
|
||||
lo, hi = channel_ranges[code]
|
||||
lines.append(f" {code}: {{ min: {lo}, max: {hi} }}\n")
|
||||
Path(path).write_text("".join(lines), encoding="utf-8", newline="\n")
|
||||
@@ -22,6 +22,9 @@ def node_layout(node: VirtualNode) -> NodeLayoutJSON:
|
||||
# `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}"
|
||||
lo, hi = (0, 4095)
|
||||
if ch.kind in ("ai", "ao"):
|
||||
lo, hi = node.channel_ranges.get(io_code, (0, 4095))
|
||||
channels.append(
|
||||
{
|
||||
"name": ch.name,
|
||||
@@ -29,6 +32,7 @@ def node_layout(node: VirtualNode) -> NodeLayoutJSON:
|
||||
"address": io_code,
|
||||
"modbus": _modbus_address(node, ch.kind, index, channel_no),
|
||||
"channel_name": node.channel_names.get(io_code),
|
||||
"range": {"min": lo, "max": hi} if ch.kind in ("ai", "ao") else None,
|
||||
}
|
||||
)
|
||||
modules_json.append(
|
||||
|
||||
@@ -134,6 +134,54 @@ function renderNameEditor() {
|
||||
}
|
||||
}
|
||||
editor.innerHTML = html;
|
||||
renderRangeEditor();
|
||||
}
|
||||
|
||||
function renderRangeEditor() {
|
||||
const editor = document.getElementById("range-editor");
|
||||
if (!NODE || !editor) return;
|
||||
let html = "";
|
||||
for (const mod of NODE.modules) {
|
||||
for (const ch of mod.channels) {
|
||||
if (ch.kind !== "ai" && ch.kind !== "ao") continue;
|
||||
const rg = ch.range || {min: 0, max: 4095};
|
||||
html += `<label class="range-row"><span class="row-io">${ch.address}</span>
|
||||
<span class="range-fields">
|
||||
<input type="number" class="range-min" data-code="${ch.address}" min="0" max="65534" value="${rg.min}">
|
||||
<span class="range-sep">–</span>
|
||||
<input type="number" class="range-max" data-code="${ch.address}" min="1" max="65535" value="${rg.max}">
|
||||
</span></label>`;
|
||||
}
|
||||
}
|
||||
editor.innerHTML = html;
|
||||
}
|
||||
|
||||
async function saveChannelRanges() {
|
||||
const msg = document.getElementById("range-msg");
|
||||
msg.classList.add("hidden");
|
||||
const ranges = {};
|
||||
document.querySelectorAll(".range-min").forEach((el) => {
|
||||
const code = el.dataset.code;
|
||||
const maxEl = document.querySelector(`.range-max[data-code="${code}"]`);
|
||||
ranges[code] = {min: Number(el.value), max: Number(maxEl.value)};
|
||||
});
|
||||
try {
|
||||
await api("/api/config/channel-ranges", {
|
||||
method: "PUT",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({channel_ranges: ranges}),
|
||||
});
|
||||
msg.textContent = "Saved.";
|
||||
msg.classList.remove("hidden", "error");
|
||||
msg.classList.add("ok");
|
||||
NODE = null; // re-fetch layout with ranges
|
||||
await init();
|
||||
await renderConfig();
|
||||
} catch (e) {
|
||||
msg.textContent = e.message;
|
||||
msg.classList.remove("hidden", "ok");
|
||||
msg.classList.add("error");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveChannelNames() {
|
||||
@@ -223,21 +271,39 @@ function renderRail() {
|
||||
const el = document.createElement("div");
|
||||
el.className = "module";
|
||||
el.dataset.index = mod.index;
|
||||
const width = 1 + Math.min(mod.channels.length, 16) * 0.42;
|
||||
el.style.width = `${Math.max(90, 26 * Math.min(mod.channels.length, 16))}px`;
|
||||
// width: per-channel LED + channel-number label, min 110px
|
||||
el.style.width = `${Math.max(110, 30 * Math.min(mod.channels.length, 16))}px`;
|
||||
const kind = mod.channels[0] ? mod.channels[0].kind : "di";
|
||||
el.classList.add(`mod-${kind.startsWith("d") ? kind : "analog"}`);
|
||||
|
||||
let html = `<div class="mod-head" title="${mod.description}">${mod.article}</div>`;
|
||||
// kind+slot designation (e.g. DI0, DO2) from the first channel's
|
||||
// I/O code — module slot is the part before the dot
|
||||
const firstCode = mod.channels[0] ? mod.channels[0].address : "";
|
||||
const designation = firstCode.slice(0, firstCode.indexOf("."));
|
||||
|
||||
let html = `<div class="mod-head" title="${mod.description}">${mod.article}
|
||||
<span class="mod-designation">${designation}</span></div>`;
|
||||
html += `<div class="mod-channels">`;
|
||||
for (const ch of mod.channels) {
|
||||
const id = `ch-${ch.kind}-${mod.index}-${channelNo(ch.address)}`;
|
||||
const chNo = String(channelNo(ch.address)); // 0-based channel only
|
||||
if (ch.kind === "di" || ch.kind === "do") {
|
||||
html += `<span class="led" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})"
|
||||
onclick="clickChannel('${ch.kind}', ${mod.index}, ${channelNo(ch.address)})"></span>`;
|
||||
html += `<span class="led-cell">
|
||||
<span class="led" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})"
|
||||
onclick="clickChannel('${ch.kind}', ${mod.index}, ${channelNo(ch.address)})"></span>
|
||||
<span class="led-label">${chNo}</span></span>`;
|
||||
} else {
|
||||
html += `<span class="abar" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})">
|
||||
<span class="abar-fill"></span><span class="abar-num">0</span></span>`;
|
||||
const rg = ch.range || {min: 0, max: 4095};
|
||||
// channel number sits OUTSIDE the limit LEDs: above the overflow
|
||||
// LED and below the underflow LED (FR-10.2.2 / FR-10.2.14 stacks)
|
||||
html += `<span class="abar-cell">
|
||||
<span class="abar-label top-label">${chNo}</span>
|
||||
<span class="lim-led lim-over" id="${id}-over" title="overflow (> ${rg.max})"></span>
|
||||
<span class="abar" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})">
|
||||
<span class="abar-fill"></span></span>
|
||||
<span class="lim-led lim-under" id="${id}-under" title="underflow (< ${rg.min})"></span>
|
||||
<span class="abar-label bottom-label">${chNo}</span>
|
||||
<span class="abar-num" id="${id}-num">0</span></span>`;
|
||||
}
|
||||
}
|
||||
html += `</div>`;
|
||||
@@ -261,10 +327,38 @@ function updateChannel(kind, module, channel, value) {
|
||||
if (kind === "di" || kind === "do") {
|
||||
el.classList.toggle("on", !!value);
|
||||
} else {
|
||||
const frac = Math.min(1, value / 4095);
|
||||
el.querySelector(".abar-fill").style.width = `${(frac * 100).toFixed(1)}%`;
|
||||
el.querySelector(".abar-num").textContent = value;
|
||||
// configured display range for this channel (default 0-4095)
|
||||
const chInfo = findChannel(kind, module, channel);
|
||||
const rg = (chInfo && chInfo.range) || {min: 0, max: 4095};
|
||||
const span = rg.max - rg.min;
|
||||
const bar = el.querySelector(".abar-fill");
|
||||
const over = document.getElementById(`${id}-over`);
|
||||
const under = document.getElementById(`${id}-under`);
|
||||
const num = document.getElementById(`${id}-num`);
|
||||
let frac, limit = "in"; // "in" | "over" | "under"
|
||||
if (value > rg.max) {
|
||||
frac = 1; limit = "over";
|
||||
} else if (value < rg.min) {
|
||||
frac = 0; limit = "under";
|
||||
} else {
|
||||
frac = span > 0 ? (value - rg.min) / span : 0;
|
||||
}
|
||||
bar.style.height = `${(frac * 100).toFixed(1)}%`; // fills from the bottom
|
||||
bar.classList.toggle("limit", limit !== "in");
|
||||
if (over) over.classList.toggle("on", limit === "over");
|
||||
if (under) under.classList.toggle("on", limit === "under");
|
||||
if (num) num.textContent = value;
|
||||
}
|
||||
}
|
||||
|
||||
function findChannel(kind, module, channel) {
|
||||
if (!NODE) return null;
|
||||
for (const mod of NODE.modules) {
|
||||
if (mod.index !== module) continue;
|
||||
return mod.channels.find((c) => c.kind === kind &&
|
||||
channelNo(c.address) === channel) || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function clickChannel(kind, module, channel) {
|
||||
@@ -467,6 +561,7 @@ async function saveModules() {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.getElementById("coupler-save").addEventListener("click", saveCoupler);
|
||||
document.getElementById("names-save").addEventListener("click", saveChannelNames);
|
||||
document.getElementById("ranges-save").addEventListener("click", saveChannelRanges);
|
||||
document.getElementById("cfg-export").addEventListener("click", exportConfig);
|
||||
document.getElementById("cfg-import").addEventListener("click", () =>
|
||||
document.getElementById("cfg-file").click());
|
||||
|
||||
@@ -78,6 +78,15 @@
|
||||
<div class="fault-buttons">
|
||||
<button id="names-save">Save channel names</button>
|
||||
</div>
|
||||
<h4>Analog ranges</h4>
|
||||
<p class="cfg-note">Display ranges for the analog bar indicators
|
||||
in raw counts. Default 0–4095 (WAGO 12-bit: 100 % at 4095).
|
||||
Values outside the range light the red limit LEDs.</p>
|
||||
<div id="range-editor" class="name-editor"></div>
|
||||
<div id="range-msg" class="error hidden"></div>
|
||||
<div class="fault-buttons">
|
||||
<button id="ranges-save">Save analog ranges</button>
|
||||
</div>
|
||||
<h4>Import / export</h4>
|
||||
<p class="cfg-note">The complete configuration (modules, channel
|
||||
names, coupler settings) as one JSON file.</p>
|
||||
|
||||
@@ -87,11 +87,23 @@ h2 { margin: 4px 0 14px; }
|
||||
position: relative;
|
||||
padding-top: 16px;
|
||||
}
|
||||
.module {
|
||||
/* label stacks (LED + code, or code + lim LEDs + bar + num) are
|
||||
taller than the bare indicators; keep the rail visually even */
|
||||
min-height: 124px;
|
||||
}
|
||||
.mod-head {
|
||||
position: absolute; top: 2px; left: 5px;
|
||||
font-size: 0.62rem; color: #33414f; font-weight: 600;
|
||||
letter-spacing: 0.02em; white-space: nowrap;
|
||||
}
|
||||
.mod-designation {
|
||||
/* kind+slot designation (DI0, DO2, AI4) — upper right corner,
|
||||
same size and vertical alignment as the article number */
|
||||
position: absolute; top: 2px; right: 5px;
|
||||
font-size: 0.62rem; color: #33414f; font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.coupler { min-width: 96px; background: linear-gradient(#0d5db4, #003a7a); }
|
||||
.coupler .mod-head { color: #fff; font-size: 0.75rem; }
|
||||
.coupler-leds { display: flex; gap: 7px; padding: 42px 10px 0; }
|
||||
@@ -109,6 +121,17 @@ h2 { margin: 4px 0 14px; }
|
||||
padding: 8px 6px 8px;
|
||||
}
|
||||
|
||||
/* DI/DO LED cells with channel-number labels (FR-10.2.2) */
|
||||
.led-cell {
|
||||
display: inline-flex; flex-direction: column;
|
||||
align-items: center; gap: 2px; cursor: pointer;
|
||||
}
|
||||
.led-label {
|
||||
font-size: 0.55rem; color: #33414f;
|
||||
font-family: Consolas, monospace;
|
||||
white-space: nowrap; line-height: 1;
|
||||
}
|
||||
|
||||
/* channel LEDs */
|
||||
.led {
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
@@ -123,24 +146,54 @@ h2 { margin: 4px 0 14px; }
|
||||
box-shadow: 0 0 9px 2px rgba(70, 230, 80, 0.8);
|
||||
}
|
||||
|
||||
/* analog bars */
|
||||
/* analog bars (FR-10.2.14: range-scaled vertical fill + limit LEDs) */
|
||||
.abar-cell {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.abar-label {
|
||||
font-size: 0.55rem; color: #33414f;
|
||||
font-family: Consolas, monospace;
|
||||
white-space: nowrap; line-height: 1;
|
||||
}
|
||||
.abar {
|
||||
position: relative;
|
||||
width: 16px; height: 48px;
|
||||
background: #232c36;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
overflow: hidden; /* clip the fill inside the rounded bar */
|
||||
display: inline-block;
|
||||
}
|
||||
.abar-fill {
|
||||
position: absolute; bottom: 0; left: 0; right: 0; height: 0;
|
||||
position: absolute; bottom: 0; left: 0; right: 0; height: 0%;
|
||||
background: linear-gradient(#4db6ff, #0d6eb4);
|
||||
border-radius: 0 0 3px 3px;
|
||||
transition: height 0.15s;
|
||||
}
|
||||
.abar-fill.limit { /* under/overflow: red limit fill */
|
||||
background: linear-gradient(#ff6b5e, #c62828);
|
||||
}
|
||||
.lim-led {
|
||||
width: 8px; height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #3a2b2b;
|
||||
}
|
||||
.lim-led.lim-over { order: 0; } /* directly above the bar */
|
||||
.lim-led.lim-under { order: 2; } /* directly below the bar */
|
||||
.abar-label.top-label { order: -1; } /* I/O code above the overflow LED */
|
||||
.abar-label.bottom-label { order: 3; } /* I/O code below the underflow LED */
|
||||
.abar { order: 1; }
|
||||
.abar-num {
|
||||
position: absolute; top: 50px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.55rem; color: #33414f;
|
||||
order: 4;
|
||||
font-size: 0.55rem; color: #33414f; white-space: nowrap;
|
||||
}
|
||||
.lim-led.on {
|
||||
background: #ff3b30;
|
||||
box-shadow: 0 0 6px 1px rgba(255, 59, 48, 0.8);
|
||||
}
|
||||
.rail-detail { margin-top: 10px; font-size: 0.8rem; color: #55606c; }
|
||||
|
||||
@@ -248,5 +301,16 @@ button.primary:hover { background: #1b5e20; }
|
||||
padding: 5px 8px; border: 1px solid #c5ccd4; border-radius: 4px;
|
||||
font-size: 0.85rem; width: 100%;
|
||||
}
|
||||
.range-row {
|
||||
display: grid; grid-template-columns: 70px 1fr; gap: 8px;
|
||||
align-items: center; padding: 2px 0;
|
||||
}
|
||||
.range-fields { display: flex; align-items: center; gap: 6px; }
|
||||
.range-fields input {
|
||||
width: 90px; padding: 5px 8px; border: 1px solid #c5ccd4;
|
||||
border-radius: 4px; font-size: 0.85rem;
|
||||
font-family: Consolas, monospace;
|
||||
}
|
||||
.range-sep { color: #6b7684; }
|
||||
.hidden-file { display: none; }
|
||||
#cfg-io-buttons { display: flex; gap: 8px; margin-top: 8px; }
|
||||
@@ -449,3 +449,103 @@ class TestConfigExportImport:
|
||||
r = await c.post("/api/config/import", json={"format": "wago-sim-config",
|
||||
"modules": ["750-430"]})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
class TestChannelRanges:
|
||||
async def test_default_range_in_layout(self, client):
|
||||
r = await client.get("/api/node")
|
||||
ai = next(c for m in r.json()["modules"] if m["index"] == 4
|
||||
for c in m["channels"] if c["kind"] == "ai")
|
||||
assert ai["range"] == {"min": 0, "max": 4095}
|
||||
# digital channels carry no range
|
||||
di = next(c for m in r.json()["modules"] if m["index"] == 0
|
||||
for c in m["channels"] if c["kind"] == "di")
|
||||
assert di["range"] is None
|
||||
|
||||
async def test_put_ranges_and_layout(self, client, app):
|
||||
r = await client.put("/api/config/channel-ranges", json={
|
||||
"channel_ranges": {"AI4.0": {"min": 0, "max": 32000},
|
||||
"AO5.0": {"min": 400, "max": 2000}},
|
||||
"persist": False,
|
||||
})
|
||||
assert r.status_code == 200
|
||||
# layout reflects the configured range
|
||||
r = await client.get("/api/node")
|
||||
ai = next(c for m in r.json()["modules"] if m["index"] == 4
|
||||
for c in m["channels"] if c["kind"] == "ai" and c["address"] == "AI4.0")
|
||||
assert ai["range"] == {"min": 0, "max": 32000}
|
||||
assert app.node.channel_range("ao", 5, 0) == (400, 2000)
|
||||
|
||||
async def test_default_range_dropped_from_store(self, client, app):
|
||||
r = await client.put("/api/config/channel-ranges", json={
|
||||
"channel_ranges": {"AI4.0": {"min": 0, "max": 4095}}, # == default
|
||||
"persist": False,
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert app.node.channel_ranges == {} # kept clean
|
||||
|
||||
async def test_range_validation(self, client):
|
||||
base = {"persist": False}
|
||||
r = await client.put("/api/config/channel-ranges", json={
|
||||
"channel_ranges": {"XX4.0": {"min": 0, "max": 100}}, **base})
|
||||
assert r.status_code == 400 # bad code
|
||||
r = await client.put("/api/config/channel-ranges", json={
|
||||
"channel_ranges": {"AI4.0": {"min": 100, "max": 50}}, **base})
|
||||
assert r.status_code == 400 # min >= max
|
||||
r = await client.put("/api/config/channel-ranges", json={
|
||||
"channel_ranges": {"AI4.0": {"min": 0, "max": 70000}}, **base})
|
||||
assert r.status_code == 400 # > 65535
|
||||
r = await client.put("/api/config/channel-ranges", json={
|
||||
"channel_ranges": {"AI4.0": {"min": 0}}, **base})
|
||||
assert r.status_code == 400 # missing max
|
||||
|
||||
async def test_ranges_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-ranges", json={
|
||||
"channel_ranges": {"AI4.0": {"min": 0, "max": 32000}}, "persist": True,
|
||||
})
|
||||
assert r.status_code == 200
|
||||
text = cfg_file.read_text(encoding="utf-8")
|
||||
assert "channel_ranges:" in text
|
||||
assert "min: 0, max: 32000" in text
|
||||
# loads back
|
||||
from wago_sim.scenario.dsl import load_yaml, load_channel_ranges
|
||||
ranges = load_channel_ranges(load_yaml(cfg_file))
|
||||
assert ranges == {"AI4.0": (0, 32000)}
|
||||
|
||||
async def test_ranges_survive_module_save(self, client, app, tmp_path):
|
||||
app.node_config_path = str(tmp_path / "node.yaml")
|
||||
await client.put("/api/config/channel-ranges", json={
|
||||
"channel_ranges": {"AI4.0": {"min": 0, "max": 32000}}, "persist": True,
|
||||
})
|
||||
r = await client.put("/api/config/modules", json={
|
||||
"modules": ["750-430", "750-467"], "persist": True,
|
||||
})
|
||||
assert r.status_code == 200
|
||||
# AI_2 is at slot 1 now; the AI4.0 range became stale -> carried
|
||||
# over but no longer matches a channel; the store keeps it (names
|
||||
# semantics differ: stale name codes are dropped on import only)
|
||||
text = (tmp_path / "node.yaml").read_text(encoding="utf-8")
|
||||
assert "channel_ranges:" in text
|
||||
|
||||
async def test_export_import_carries_ranges(self, client, app, tmp_path):
|
||||
await client.put("/api/config/channel-ranges", json={
|
||||
"channel_ranges": {"AI4.0": {"min": 600, "max": 2000}}, "persist": False,
|
||||
})
|
||||
exported = (await client.get("/api/config/export")).json()
|
||||
assert exported["channel_ranges"]["AI4.0"] == {"min": 600, "max": 2000}
|
||||
app.node_config_path = str(tmp_path / "node.yaml")
|
||||
r = await client.post("/api/config/import", json=exported)
|
||||
assert r.status_code == 200
|
||||
assert app.node.channel_range("ai", 4, 0) == (600, 2000)
|
||||
text = (tmp_path / "node.yaml").read_text(encoding="utf-8")
|
||||
assert "min: 600, max: 2000" in text
|
||||
|
||||
async def test_readonly_blocks_ranges(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-ranges", json={"channel_ranges": {}})
|
||||
assert r.status_code == 403
|
||||
Reference in New Issue
Block a user