diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e95e65b..95ec3cc 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -671,6 +671,172 @@ ], "isBackground": false, "problemMatcher": [] + }, + { + "label": "wago-sim: serve desig-fix", + "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": "kill port holders", + "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('killed')" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "wago-sim: serve desig-fix2", + "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": "wago-sim: pytest desig-fix", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "pytest", + "tests", + "-q", + "--tb=short" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "stop desig-fix 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 signed", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "pytest", + "tests", + "-q", + "--tb=short" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "wago-sim: serve signed", + "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": "kill port holders 2", + "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('killed')" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "wago-sim: serve signed2", + "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": "wago-sim: pytest final signed", + "type": "shell", + "command": ".venv\\Scripts\\python.exe", + "args": [ + "-m", + "pytest", + "tests", + "-q", + "--tb=short" + ], + "isBackground": false, + "problemMatcher": [] + }, + { + "label": "stop signed 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 6dc762f..eb29c54 100644 --- a/README.md +++ b/README.md @@ -307,13 +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 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). + (MS/NS/BF/SF LEDs), module bezels with article numbers, the + kind+slot designation (`DI0`, `DO2`, `AI4`) in the module's + lower left corner below the indicators, 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 @@ -344,6 +344,11 @@ requirement ID for each item in parentheses. 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). + Signed support (2026-10-04): range bounds and simulated values + may be negative (-65534–65535; stored as 16-bit two's + complement), for signed plant quantities. DI/DO channels render + in two rows (0–7 upper, 8–15 lower), mirroring two-deck WAGO + modules. - [ ] M6: scenario run view + alias-ownership rejection (FR-10.2.6, FR-10.2.9) — deferred. - [x] Tests: 46 web-UI tests (`tests/test_webui.py`) via httpx diff --git a/docs/spec/software-spec.md b/docs/spec/software-spec.md index 9973184..f72559a 100644 --- a/docs/spec/software-spec.md +++ b/docs/spec/software-spec.md @@ -893,12 +893,13 @@ Terminal monitors (§9) suffice for single engineers, but FAT benches are 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. + module's **lower left corner, below the channel indicators**, 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. DI/DO channels lay out in **two + rows** — channels 0–7 on the upper row, 8–15 on the lower row — + mirroring the physical two-deck layout of 16-channel WAGO modules. - 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. @@ -965,6 +966,12 @@ Terminal monitors (§9) suffice for single engineers, but FAT benches are 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. + **Signed values** (2026-10-04): range bounds may be negative — + `-65534 <= min < max <= 65535` (the raw 16-bit image holds + 0–65535; a negative display bound lets signed plant quantities map + onto the bar). Simulated AI values and AO forces accept negative + numbers within the same window; values are stored in the image + as their 16-bit two's-complement representation. ### 10.3 Implementation decisions (binding, 2026-10-03) diff --git a/src/wago_sim/scenario/dsl.py b/src/wago_sim/scenario/dsl.py index 05b1f04..682bccb 100644 --- a/src/wago_sim/scenario/dsl.py +++ b/src/wago_sim/scenario/dsl.py @@ -96,9 +96,9 @@ def load_channel_ranges(node_config: dict) -> dict[str, tuple[int, int]]: 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: + if not -65534 <= lo < hi <= 65535: raise ScenarioError( - f"channel_ranges for {code_up!r}: need 0 <= min < max <= 65535" + f"channel_ranges for {code_up!r}: need -65534 <= min < max <= 65535" ) ranges[code_up] = (lo, hi) return ranges diff --git a/src/wago_sim/webui/api.py b/src/wago_sim/webui/api.py index 51c20b9..e270ab0 100644 --- a/src/wago_sim/webui/api.py +++ b/src/wago_sim/webui/api.py @@ -216,13 +216,13 @@ async def set_channel( return {"ok": True, "value": value} if kind == "ai": loc = node.channel_by_module("ai", module, channel) - value = int(payload.get("value", 0)) & 0xFFFF + value = _analog_word(payload.get("value", 0)) node.datastore.set_input_word(loc.word, value) return {"ok": True, "value": value} if kind == "ao": # master-side force: word write at the OUTPUT image (Table 120) loc = node.channel_by_module("ao", module, channel) - value = int(payload.get("value", 0)) & 0xFFFF + value = _analog_word(payload.get("value", 0)) result = node.datastore.write_words(loc.word, [value]) if isinstance(result, Exception): raise HTTPException(status_code=400, detail=str(result)) @@ -234,6 +234,23 @@ async def set_channel( raise HTTPException(status_code=400, detail=f"unknown channel kind {kind!r}") +def _analog_word(value: object) -> int: + """Coerce a UI analog value to its 16-bit image word (FR-10.2.14). + + Negative values (down to -65534) are stored as two's complement; + the window matches the accepted range bounds -65534..65535. + """ + try: + n = int(value) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="value must be numeric") from None + if not -65534 <= n <= 65535: + raise HTTPException( + status_code=400, detail="value must be within -65534..65535" + ) + return n & 0xFFFF + + def _payload_bool(payload: dict) -> bool: value = payload.get("value") if isinstance(value, bool): @@ -528,10 +545,10 @@ async def put_channel_ranges( raise HTTPException( status_code=400, detail=f"range for {code_up!r} must be numeric" ) from None - if not 0 <= lo < hi <= 65535: + if not -65534 <= lo < hi <= 65535: raise HTTPException( status_code=400, - detail=f"range for {code_up!r}: need 0 <= min < max <= 65535", + detail=f"range for {code_up!r}: need -65534 <= min < max <= 65535", ) if (lo, hi) == (0, 4095): continue # default — keep the store clean @@ -643,9 +660,9 @@ async def import_config( 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: + if not -65534 <= lo < hi <= 65535: raise HTTPException(status_code=400, - detail=f"range for {code_up!r}: need 0 <= min < max <= 65535") + detail=f"range for {code_up!r}: need -65534 <= min < max <= 65535") if (lo, hi) != (0, 4095): ranges[code_up] = (lo, hi) coupler = payload.get("coupler_settings") or {} diff --git a/src/wago_sim/webui/state.py b/src/wago_sim/webui/state.py index 319abb1..711ebc4 100644 --- a/src/wago_sim/webui/state.py +++ b/src/wago_sim/webui/state.py @@ -97,7 +97,10 @@ def channel_values(node: VirtualNode) -> list[ChannelValue]: "name": node.channel_names.get(code), } ) - # Analog channels: word indices in the process image. + # Analog channels: word indices in the process image. When the + # channel's display range has a negative min (signed quantity, + # FR-10.2.14), the raw word is shown as its 16-bit two's-complement + # value so the UI, the bar, and the configured range agree. for loc in node.ai: code = node.io_code("ai", loc.module_index, loc.channel - 1) out.append( @@ -105,7 +108,7 @@ def channel_values(node: VirtualNode) -> list[ChannelValue]: "kind": "ai", "module": loc.module_index, "channel": loc.channel - 1, - "value": ds.get_input_word(loc.word), + "value": _display_value(node, "ai", loc, ds.get_input_word(loc.word)), "name": node.channel_names.get(code), } ) @@ -116,8 +119,20 @@ def channel_values(node: VirtualNode) -> list[ChannelValue]: "kind": "ao", "module": loc.module_index, "channel": loc.channel - 1, - "value": ds.get_output_word(loc.word), + "value": _display_value(node, "ao", loc, ds.get_output_word(loc.word)), "name": node.channel_names.get(code), } ) - return out \ No newline at end of file + return out + + +def _display_value(node: VirtualNode, kind: str, loc, raw: int) -> int: + """Signed display value for analog channels with a negative range min. + + Unsigned channels (min >= 0, incl. the 0-4095 default) return the + raw word unchanged. + """ + lo, _hi = node.channel_range(kind, loc.module_index, loc.channel - 1) + if lo < 0 and raw > 32767: + return raw - 65536 + return raw \ No newline at end of file diff --git a/src/wago_sim/webui/static/app.js b/src/wago_sim/webui/static/app.js index 1d5597f..40b06ed 100644 --- a/src/wago_sim/webui/static/app.js +++ b/src/wago_sim/webui/static/app.js @@ -85,7 +85,7 @@ function renderChannelList() { onchange="listWrite('${ch.kind}', ${mod.index}, ${no}, this)"> `; } else { - control = ``; } @@ -103,7 +103,7 @@ async function listWrite(kind, module, channel, el) { if (READONLY) return; const payload = (kind === "di" || kind === "do") ? {action: "set", value: el.checked} - : {action: "set", value: Number(el.value) & 0xFFFF}; + : {action: "set", value: Math.trunc(Number(el.value))}; try { await api(`/api/channels/${kind}/${module}/${channel}`, { method: "POST", @@ -147,9 +147,9 @@ function renderRangeEditor() { const rg = ch.range || {min: 0, max: 4095}; html += ``; } } @@ -271,8 +271,9 @@ function renderRail() { const el = document.createElement("div"); el.className = "module"; el.dataset.index = mod.index; - // width: per-channel LED + channel-number label, min 110px - el.style.width = `${Math.max(110, 30 * Math.min(mod.channels.length, 16))}px`; + // width: 8 channels per row (two-deck layout), min 110px + const perRow = Math.min(mod.channels.length, 8); + el.style.width = `${Math.max(110, 30 * perRow)}px`; const kind = mod.channels[0] ? mod.channels[0].kind : "di"; el.classList.add(`mod-${kind.startsWith("d") ? kind : "analog"}`); @@ -281,17 +282,21 @@ function renderRail() { const firstCode = mod.channels[0] ? mod.channels[0].address : ""; const designation = firstCode.slice(0, firstCode.indexOf(".")); - let html = `