From d55a8b47189620de3123a5a40a2abd85cee902bf Mon Sep 17 00:00:00 2001 From: Jonas Bengtsson Date: Sun, 4 Oct 2026 10:59:03 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/tasks.json | 166 ++++++++++++++++++++++++++++ README.md | 19 ++-- docs/spec/software-spec.md | 19 +++- src/wago_sim/scenario/dsl.py | 4 +- src/wago_sim/webui/api.py | 29 ++++- src/wago_sim/webui/state.py | 23 +++- src/wago_sim/webui/static/app.js | 26 +++-- src/wago_sim/webui/static/style.css | 16 ++- tests/test_webui.py | 51 +++++++++ 9 files changed, 314 insertions(+), 39 deletions(-) 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 = `
${mod.article} - ${designation}
`; + let html = `
${mod.article}
`; html += `
`; + let chIndex = 0; // digital channels: two rows, 0-7 then 8-15 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") { + if (chIndex > 0 && chIndex % 8 === 0) { + html += ``; // start the 8-15 row + } html += ` ${chNo}`; + chIndex += 1; } else { const rg = ch.range || {min: 0, max: 4095}; // channel number sits OUTSIDE the limit LEDs: above the overflow @@ -307,6 +312,9 @@ function renderRail() { } } html += `
`; + // kind+slot designation in the module's lower left, below the + // indicators (FR-10.2.2) + html += `
${designation}
`; el.innerHTML = html; rail.appendChild(el); } diff --git a/src/wago_sim/webui/static/style.css b/src/wago_sim/webui/static/style.css index c3584ad..5f4204b 100644 --- a/src/wago_sim/webui/static/style.css +++ b/src/wago_sim/webui/static/style.css @@ -91,6 +91,7 @@ h2 { margin: 4px 0 14px; } /* label stacks (LED + code, or code + lim LEDs + bar + num) are taller than the bare indicators; keep the rail visually even */ min-height: 124px; + flex-shrink: 0; /* keep the computed width — do not squeeze rows */ } .mod-head { position: absolute; top: 2px; left: 5px; @@ -98,11 +99,11 @@ h2 { margin: 4px 0 14px; } 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; + /* kind+slot designation (DI0, DO2, AI4) — module's lower left + corner, below the channel indicators (FR-10.2.2) */ + position: absolute; bottom: 3px; left: 5px; font-size: 0.62rem; color: #33414f; font-weight: 600; - letter-spacing: 0.02em; + letter-spacing: 0.02em; line-height: 1; } .coupler { min-width: 96px; background: linear-gradient(#0d5db4, #003a7a); } .coupler .mod-head { color: #fff; font-size: 0.75rem; } @@ -118,7 +119,12 @@ h2 { margin: 4px 0 14px; } .module { display: flex; flex-direction: column; justify-content: flex-end; } .mod-channels { display: flex; gap: 5px; flex-wrap: wrap; align-items: flex-end; - padding: 8px 6px 8px; + padding: 8px 6px 16px; /* bottom room for the designation label */ +} +.row-break { + /* two-deck layout: force channels 8-15 onto a second row */ + flex-basis: 100%; + height: 0; } /* DI/DO LED cells with channel-number labels (FR-10.2.2) */ diff --git a/tests/test_webui.py b/tests/test_webui.py index 9c94e1e..6834416 100644 --- a/tests/test_webui.py +++ b/tests/test_webui.py @@ -499,6 +499,57 @@ class TestChannelRanges: "channel_ranges": {"AI4.0": {"min": 0}}, **base}) assert r.status_code == 400 # missing max + async def test_negative_range_accepted(self, client, app): + r = await client.put("/api/config/channel-ranges", json={ + "channel_ranges": {"AI4.0": {"min": -32000, "max": 32000}}, + "persist": False, + }) + assert r.status_code == 200 + assert app.node.channel_range("ai", 4, 0) == (-32000, 32000) + # layout exposes the signed 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["address"] == "AI4.0") + assert ai["range"] == {"min": -32000, "max": 32000} + + async def test_range_floor_is_minus_65534(self, client): + r = await client.put("/api/config/channel-ranges", json={ + "channel_ranges": {"AI4.0": {"min": -65535, "max": 0}}, "persist": False}) + assert r.status_code == 400 # below -65534 + + async def test_signed_value_display_and_write(self, client, app, datastore): + # channel with a negative-min range -> signed two's-complement view + await client.put("/api/config/channel-ranges", json={ + "channel_ranges": {"AI4.0": {"min": -32000, "max": 32000}}, + "persist": False}) + r = await client.post("/api/channels/ai/4/0", json={"value": -2000}) + assert r.status_code == 200 + # raw image holds the two's-complement word + assert datastore.get_input_word(0) == (-2000) & 0xFFFF + # values API shows the signed number again + r = await client.get("/api/values") + entry = next(v for v in r.json()["values"] + if (v["kind"], v["module"], v["channel"]) == ("ai", 4, 0)) + assert entry["value"] == -2000 + + async def test_negative_ao_force(self, client, datastore): + r = await client.post("/api/channels/ao/5/0", json={"value": -3000}) + assert r.status_code == 200 + assert datastore.get_output_word(0) == (-3000) & 0xFFFF + # unsigned AO (default range) still displays raw + r = await client.get("/api/values") + entry = next(v for v in r.json()["values"] + if (v["kind"], v["module"], v["channel"]) == ("ao", 5, 0)) + assert entry["value"] == (-3000) & 0xFFFF + + async def test_value_bounds_rejected(self, client): + r = await client.post("/api/channels/ai/4/0", json={"value": -65535}) + assert r.status_code == 400 + r = await client.post("/api/channels/ai/4/0", json={"value": 65536}) + assert r.status_code == 400 + r = await client.post("/api/channels/ai/4/0", json={"value": "banana"}) + assert r.status_code == 400 + 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)