diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 4451b1b..e95e65b 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -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": [] } ] } \ No newline at end of file diff --git a/README.md b/README.md index 07de953..6dc762f 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/spec/software-spec.md b/docs/spec/software-spec.md index d1957b7..9973184 100644 --- a/docs/spec/software-spec.md +++ b/docs/spec/software-spec.md @@ -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) diff --git a/src/wago_sim/app.py b/src/wago_sim/app.py index 8657c2e..6d432a7 100644 --- a/src/wago_sim/app.py +++ b/src/wago_sim/app.py @@ -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, diff --git a/src/wago_sim/cli.py b/src/wago_sim/cli.py index 9fa90bf..40d2ad7 100644 --- a/src/wago_sim/cli.py +++ b/src/wago_sim/cli.py @@ -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: diff --git a/src/wago_sim/modules.py b/src/wago_sim/modules.py index 05aa747..0932a21 100644 --- a/src/wago_sim/modules.py +++ b/src/wago_sim/modules.py @@ -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).""" diff --git a/src/wago_sim/scenario/dsl.py b/src/wago_sim/scenario/dsl.py index dfb2aaa..05b1f04 100644 --- a/src/wago_sim/scenario/dsl.py +++ b/src/wago_sim/scenario/dsl.py @@ -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] = [] diff --git a/src/wago_sim/webui/api.py b/src/wago_sim/webui/api.py index eb31f88..51c20b9 100644 --- a/src/wago_sim/webui/api.py +++ b/src/wago_sim/webui/api.py @@ -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, diff --git a/src/wago_sim/webui/api_models.py b/src/wago_sim/webui/api_models.py index d0b812d..aabecae 100644 --- a/src/wago_sim/webui/api_models.py +++ b/src/wago_sim/webui/api_models.py @@ -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): diff --git a/src/wago_sim/webui/persist.py b/src/wago_sim/webui/persist.py index c0b6ab2..f463409 100644 --- a/src/wago_sim/webui/persist.py +++ b/src/wago_sim/webui/persist.py @@ -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") \ No newline at end of file diff --git a/src/wago_sim/webui/state.py b/src/wago_sim/webui/state.py index 04c3199..319abb1 100644 --- a/src/wago_sim/webui/state.py +++ b/src/wago_sim/webui/state.py @@ -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( diff --git a/src/wago_sim/webui/static/app.js b/src/wago_sim/webui/static/app.js index d566a19..1d5597f 100644 --- a/src/wago_sim/webui/static/app.js +++ b/src/wago_sim/webui/static/app.js @@ -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 += ``; + } + } + 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 = `
${mod.article}
`; + // 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 = `
${mod.article} + ${designation}
`; html += `
`; 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 += ``; + html += ` + + ${chNo}`; } else { - html += ` - 0`; + 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 += ` + ${chNo} + + + + + ${chNo} + 0`; } } html += `
`; @@ -261,12 +327,40 @@ 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) { if (READONLY) return; try { @@ -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()); diff --git a/src/wago_sim/webui/static/index.html b/src/wago_sim/webui/static/index.html index 00780ae..979e358 100644 --- a/src/wago_sim/webui/static/index.html +++ b/src/wago_sim/webui/static/index.html @@ -78,6 +78,15 @@
+

Analog ranges

+

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.

+
+ +
+ +

Import / export

The complete configuration (modules, channel names, coupler settings) as one JSON file.

diff --git a/src/wago_sim/webui/static/style.css b/src/wago_sim/webui/static/style.css index 40a868a..c3584ad 100644 --- a/src/wago_sim/webui/static/style.css +++ b/src/wago_sim/webui/static/style.css @@ -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; } \ No newline at end of file diff --git a/tests/test_webui.py b/tests/test_webui.py index 04e9316..9c94e1e 100644 --- a/tests/test_webui.py +++ b/tests/test_webui.py @@ -448,4 +448,104 @@ class TestConfigExportImport: 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 + + +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 \ No newline at end of file