This commit is contained in:
Jonas Bengtsson
2026-10-04 10:01:49 +02:00
parent 4adbb4171e
commit c1d232581f
15 changed files with 649 additions and 39 deletions
+135
View File
@@ -536,6 +536,141 @@
], ],
"isBackground": false, "isBackground": false,
"problemMatcher": [] "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": []
} }
] ]
} }
+21 -7
View File
@@ -307,10 +307,13 @@ requirement ID for each item in parentheses.
`--web-port`, env `WAGO_SIM_WEB_USER/PASSWORD/READONLY/ `--web-port`, env `WAGO_SIM_WEB_USER/PASSWORD/READONLY/
NO_WEB/WEB_PORT`; login page + SPA shell. NO_WEB/WEB_PORT`; login page + SPA shell.
- [x] M2: node overview page — DIN-rail strip with coupler block - [x] M2: node overview page — DIN-rail strip with coupler block
(MS/NS/BF/SF LEDs), module bezels with article numbers, (MS/NS/BF/SF LEDs), module bezels with article numbers plus the
per-channel LEDs for DI/DO, bar+numeric indicators for AI/AO, kind+slot designation (`DI0`, `DO2`, `AI4`) in the header's
live values via WebSocket (`/api/ws`, 10 Hz delta push), upper right corner, per-channel LEDs for DI/DO with the channel
`GET /api/values` snapshot. 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 - [x] M3: manipulation — DI/DO toggle (field side via
`set_digital_input_channel`, master side via internal `set_digital_input_channel`, master side via internal
`write_bits`/`write_words` dispatch so mirrors/statistics stay `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 graphic on top, live scrollable channel list below with I/O
code, configured name, and a switch (DI/DO) / numeric field code, configured name, and a switch (DI/DO) / numeric field
(AI/AO) for display + manipulation. (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, - [ ] M6: scenario run view + alias-ownership rejection (FR-10.2.6,
FR-10.2.9) — deferred. 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: `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; channel-name save + list sync; loader-overlap register fix; channel-name save + list sync;
export/import round-trip with a Modbus read after the rebuild. export/import round-trip with a Modbus read after the rebuild;
Full suite: 216 passing. 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) ### Framework gaps (spec §4 — Scenario & Macro Engine)
+19 -1
View File
@@ -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 - FR-10.2.2: The UI shall present the **node overview**: module list with
article numbers, per-module channel tables (type, module, channel, article numbers, per-module channel tables (type, module, channel,
addressing `DI0.1` per FR-4.3.11, Modbus address, current value/state), 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), - 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 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. 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 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 updates live like the rail (FR-10.2.3) and is the primary working
surface for benches with many channels. 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.3 Implementation decisions (binding, 2026-10-03)
+10 -5
View File
@@ -43,7 +43,8 @@ class SimulatorApp:
def rebuild_node(app: SimulatorApp, modules: list[ModuleType], 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. """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
@@ -53,13 +54,15 @@ def rebuild_node(app: SimulatorApp, modules: list[ModuleType],
(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. Channel names (FR-10.2.11) KBUS and re-configuring on a real node. Channel names (FR-10.2.11)
are carried over when `channel_names` is None (unchanged module and analog display ranges (FR-10.2.14) are carried over when the
list) or replaced with the given mapping. argument is None; an explicit mapping replaces it.
""" """
datastore = app.datastore datastore = app.datastore
names = channel_names if channel_names is not None else app.node.channel_names 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() 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( 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,
@@ -79,6 +82,7 @@ def build_app(
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, channel_names: dict[str, str] | None = None,
channel_ranges: dict[str, tuple[int, int]] | None = None,
) -> SimulatorApp: ) -> SimulatorApp:
"""Assemble all simulator components for a node configuration.""" """Assemble all simulator components for a node configuration."""
stats = ModbusStatistics() stats = ModbusStatistics()
@@ -121,7 +125,8 @@ 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, channel_names=channel_names) node = VirtualNode(datastore, modules, channel_names=channel_names,
channel_ranges=channel_ranges)
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,
+5 -3
View File
@@ -40,8 +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_channel_names, load_node_modules, from .scenario.dsl import (load_channel_names, load_channel_ranges,
load_yaml, validate_scenario) 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
@@ -182,6 +182,7 @@ 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) channel_names = load_channel_names(node_config)
channel_ranges = load_channel_ranges(node_config)
web_cfg = None web_cfg = None
if args.web: if args.web:
web_cfg = WebUIConfig( web_cfg = WebUIConfig(
@@ -192,7 +193,8 @@ 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, channel_names=channel_names) node_config_path=args.node, channel_names=channel_names,
channel_ranges=channel_ranges)
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:
+18 -2
View File
@@ -122,14 +122,21 @@ class VirtualNode:
it to a :class:`WagoDatastore`. Optional per-channel application it to a :class:`WagoDatastore`. Optional per-channel application
names (spec FR-10.2.11) live here — not on ModuleType, whose names (spec FR-10.2.11) live here — not on ModuleType, whose
catalog objects are shared per article — keyed by I/O code 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], 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.datastore = datastore
self.modules = modules self.modules = modules
self.channel_names: dict[str, str] = dict(channel_names or {}) 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._di_channels: list[ChannelLocation] = []
self._do_channels: list[ChannelLocation] = [] self._do_channels: list[ChannelLocation] = []
self._ai_channels: list[ChannelLocation] = [] self._ai_channels: list[ChannelLocation] = []
@@ -203,6 +210,15 @@ class VirtualNode:
"""Configured application name for a channel, or None.""" """Configured application name for a channel, or None."""
return self.channel_names.get(self.io_code(kind, module, channel)) 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]: def validate_channel_names(self) -> list[str]:
"""Names whose I/O code does not exist on this node (after a """Names whose I/O code does not exist on this node (after a
module-list change, stale entries are dropped on save).""" module-list change, stale entries are dropped on save)."""
+31
View File
@@ -73,6 +73,37 @@ def load_channel_names(node_config: dict) -> dict[str, str]:
if str(name).strip()} 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]: 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] = []
+107 -3
View File
@@ -399,7 +399,8 @@ async def put_module_config(
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) channel_names=sim.node.channel_names,
channel_ranges=sim.node.channel_ranges)
except OSError as exc: except OSError as exc:
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
@@ -476,6 +477,86 @@ async def put_channel_names(
return {"ok": True, "channel_names": names, "unknown_codes": stale_now} 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) --------------------------------- # -- configuration import/export (FR-10.2.12) ---------------------------------
@@ -507,6 +588,8 @@ async def export_config(
"coupler": "750-362", "coupler": "750-362",
"modules": [m.article for m in sim.node.modules], "modules": [m.article for m in sim.node.modules],
"channel_names": dict(sim.node.channel_names), "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), "coupler_settings": _coupler_snapshot(sim),
} }
@@ -547,16 +630,34 @@ async def import_config(
detail=f"invalid I/O code {code!r}") detail=f"invalid I/O code {code!r}")
if str(name).strip(): if str(name).strip():
names[code_up] = 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 {} coupler = payload.get("coupler_settings") or {}
# -- everything validated: apply atomically ------------------------- # -- 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() stale = sim.node.validate_channel_names()
if stale: if stale:
# codes that don't exist on the imported module list are dropped # codes that don't exist on the imported module list are dropped
for code in stale: for code in stale:
names.pop(code, None) names.pop(code, None)
rebuild_node(sim, modules, channel_names=names) rebuild_node(sim, modules, channel_names=names, channel_ranges=ranges)
errors: list[str] = [] errors: list[str] = []
wd = coupler.get("watchdog") or {} wd = coupler.get("watchdog") or {}
writes: list[tuple[int, int]] = [] writes: list[tuple[int, int]] = []
@@ -585,6 +686,7 @@ async def import_config(
sim.node_config_path, modules, sim.node_config_path, modules,
name=Path(sim.node_config_path).stem, name=Path(sim.node_config_path).stem,
channel_names=names, channel_names=names,
channel_ranges=ranges,
) )
except OSError as exc: except OSError as exc:
raise HTTPException( raise HTTPException(
@@ -595,6 +697,8 @@ async def import_config(
"ok": True, "ok": True,
"modules": [m.article for m in modules], "modules": [m.article for m in modules],
"channel_names": names, "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_writes_applied": len(writes) - len(errors),
"coupler_write_errors": errors, "coupler_write_errors": errors,
"dropped_codes": stale, "dropped_codes": stale,
+1
View File
@@ -12,6 +12,7 @@ class ChannelJSON(TypedDict):
address: str address: str
modbus: str modbus: str
channel_name: str | None channel_name: str | None
range: dict | None # {"min": int, "max": int} for ai/ao, else None
class ModuleJSON(TypedDict): class ModuleJSON(TypedDict):
+13 -1
View File
@@ -28,6 +28,12 @@ _NAMES_HEADER = """\
channel_names: channel_names:
""" """
_RANGES_HEADER = """\
# Analog display ranges (I/O code -> {min, max} raw counts; default 0-4095)
channel_ranges:
"""
def save_node_config( def save_node_config(
path: str | Path, path: str | Path,
@@ -35,8 +41,9 @@ def save_node_config(
*, *,
name: str = "node", name: str = "node",
channel_names: dict[str, str] | None = None, channel_names: dict[str, str] | None = None,
channel_ranges: dict[str, tuple[int, int]] | None = 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)] 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')
@@ -44,4 +51,9 @@ def save_node_config(
lines.append(_NAMES_HEADER) lines.append(_NAMES_HEADER)
for code in sorted(channel_names): for code in sorted(channel_names):
lines.append(f" {code}: {channel_names[code]}\n") 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") Path(path).write_text("".join(lines), encoding="utf-8", newline="\n")
+4
View File
@@ -22,6 +22,9 @@ def node_layout(node: VirtualNode) -> NodeLayoutJSON:
# `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}" 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( channels.append(
{ {
"name": ch.name, "name": ch.name,
@@ -29,6 +32,7 @@ def node_layout(node: VirtualNode) -> NodeLayoutJSON:
"address": io_code, "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), "channel_name": node.channel_names.get(io_code),
"range": {"min": lo, "max": hi} if ch.kind in ("ai", "ao") else None,
} }
) )
modules_json.append( modules_json.append(
+105 -10
View File
@@ -134,6 +134,54 @@ function renderNameEditor() {
} }
} }
editor.innerHTML = html; 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() { async function saveChannelNames() {
@@ -223,21 +271,39 @@ function renderRail() {
const el = document.createElement("div"); const el = document.createElement("div");
el.className = "module"; el.className = "module";
el.dataset.index = mod.index; el.dataset.index = mod.index;
const width = 1 + Math.min(mod.channels.length, 16) * 0.42; // width: per-channel LED + channel-number label, min 110px
el.style.width = `${Math.max(90, 26 * Math.min(mod.channels.length, 16))}px`; el.style.width = `${Math.max(110, 30 * Math.min(mod.channels.length, 16))}px`;
const kind = mod.channels[0] ? mod.channels[0].kind : "di"; const kind = mod.channels[0] ? mod.channels[0].kind : "di";
el.classList.add(`mod-${kind.startsWith("d") ? kind : "analog"}`); 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">`; html += `<div class="mod-channels">`;
for (const ch of mod.channels) { for (const ch of mod.channels) {
const id = `ch-${ch.kind}-${mod.index}-${channelNo(ch.address)}`; 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") { if (ch.kind === "di" || ch.kind === "do") {
html += `<span class="led" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})" html += `<span class="led-cell">
onclick="clickChannel('${ch.kind}', ${mod.index}, ${channelNo(ch.address)})"></span>`; <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 { } else {
html += `<span class="abar" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})"> const rg = ch.range || {min: 0, max: 4095};
<span class="abar-fill"></span><span class="abar-num">0</span></span>`; // 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 (&gt; ${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 (&lt; ${rg.min})"></span>
<span class="abar-label bottom-label">${chNo}</span>
<span class="abar-num" id="${id}-num">0</span></span>`;
} }
} }
html += `</div>`; html += `</div>`;
@@ -261,10 +327,38 @@ function updateChannel(kind, module, channel, value) {
if (kind === "di" || kind === "do") { if (kind === "di" || kind === "do") {
el.classList.toggle("on", !!value); el.classList.toggle("on", !!value);
} else { } else {
const frac = Math.min(1, value / 4095); // configured display range for this channel (default 0-4095)
el.querySelector(".abar-fill").style.width = `${(frac * 100).toFixed(1)}%`; const chInfo = findChannel(kind, module, channel);
el.querySelector(".abar-num").textContent = value; 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) { async function clickChannel(kind, module, channel) {
@@ -467,6 +561,7 @@ 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("names-save").addEventListener("click", saveChannelNames);
document.getElementById("ranges-save").addEventListener("click", saveChannelRanges);
document.getElementById("cfg-export").addEventListener("click", exportConfig); document.getElementById("cfg-export").addEventListener("click", exportConfig);
document.getElementById("cfg-import").addEventListener("click", () => document.getElementById("cfg-import").addEventListener("click", () =>
document.getElementById("cfg-file").click()); document.getElementById("cfg-file").click());
+9
View File
@@ -78,6 +78,15 @@
<div class="fault-buttons"> <div class="fault-buttons">
<button id="names-save">Save channel names</button> <button id="names-save">Save channel names</button>
</div> </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> <h4>Import / export</h4>
<p class="cfg-note">The complete configuration (modules, channel <p class="cfg-note">The complete configuration (modules, channel
names, coupler settings) as one JSON file.</p> names, coupler settings) as one JSON file.</p>
+71 -7
View File
@@ -87,11 +87,23 @@ h2 { margin: 4px 0 14px; }
position: relative; position: relative;
padding-top: 16px; 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 { .mod-head {
position: absolute; top: 2px; left: 5px; position: absolute; top: 2px; left: 5px;
font-size: 0.62rem; color: #33414f; font-weight: 600; font-size: 0.62rem; color: #33414f; font-weight: 600;
letter-spacing: 0.02em; white-space: nowrap; 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 { min-width: 96px; background: linear-gradient(#0d5db4, #003a7a); }
.coupler .mod-head { color: #fff; font-size: 0.75rem; } .coupler .mod-head { color: #fff; font-size: 0.75rem; }
.coupler-leds { display: flex; gap: 7px; padding: 42px 10px 0; } .coupler-leds { display: flex; gap: 7px; padding: 42px 10px 0; }
@@ -109,6 +121,17 @@ h2 { margin: 4px 0 14px; }
padding: 8px 6px 8px; 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 */ /* channel LEDs */
.led { .led {
width: 12px; height: 12px; border-radius: 50%; 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); 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 { .abar {
position: relative; position: relative;
width: 16px; height: 48px; width: 16px; height: 48px;
background: #232c36; background: #232c36;
border-radius: 3px; border-radius: 3px;
overflow: hidden; overflow: hidden; /* clip the fill inside the rounded bar */
cursor: pointer;
display: inline-block; display: inline-block;
} }
.abar-fill { .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); 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 { .abar-num {
position: absolute; top: 50px; left: 50%; order: 4;
transform: translateX(-50%); font-size: 0.55rem; color: #33414f; white-space: nowrap;
font-size: 0.55rem; color: #33414f; }
.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; } .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; padding: 5px 8px; border: 1px solid #c5ccd4; border-radius: 4px;
font-size: 0.85rem; width: 100%; 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; } .hidden-file { display: none; }
#cfg-io-buttons { display: flex; gap: 8px; margin-top: 8px; } #cfg-io-buttons { display: flex; gap: 8px; margin-top: 8px; }
+100
View File
@@ -449,3 +449,103 @@ class TestConfigExportImport:
r = await c.post("/api/config/import", json={"format": "wago-sim-config", r = await c.post("/api/config/import", json={"format": "wago-sim-config",
"modules": ["750-430"]}) "modules": ["750-430"]})
assert r.status_code == 403 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