This commit is contained in:
Jonas Bengtsson
2026-10-04 10:59:03 +02:00
parent c1d232581f
commit d55a8b4718
9 changed files with 314 additions and 39 deletions
+166
View File
@@ -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": []
}
]
}
+12 -7
View File
@@ -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
+13 -6
View File
@@ -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)
+2 -2
View File
@@ -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
+23 -6
View File
@@ -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 {}
+19 -4
View File
@@ -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
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
+17 -9
View File
@@ -85,7 +85,7 @@ function renderChannelList() {
onchange="listWrite('${ch.kind}', ${mod.index}, ${no}, this)">
<span class="slider"></span></label>`;
} else {
control = `<input type="number" class="row-value num" min="0" max="65535"
control = `<input type="number" class="row-value num" min="-65534" max="65535"
id="val-${ch.kind}-${mod.index}-${no}"
onchange="listWrite('${ch.kind}', ${mod.index}, ${no}, this)">`;
}
@@ -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 += `<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}">
<input type="number" class="range-min" data-code="${ch.address}" min="-65534" max="65534" value="${rg.min}">
<span class="range-sep">&ndash;</span>
<input type="number" class="range-max" data-code="${ch.address}" min="-65533" max="65535" value="${rg.max}">
</span></label>`;
}
}
@@ -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 = `<div class="mod-head" title="${mod.description}">${mod.article}
<span class="mod-designation">${designation}</span></div>`;
let html = `<div class="mod-head" title="${mod.description}">${mod.article}</div>`;
html += `<div class="mod-channels">`;
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 += `<span class="row-break"></span>`; // start the 8-15 row
}
html += `<span class="led-cell">
<span class="led" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})"
onclick="clickChannel('${ch.kind}', ${mod.index}, ${channelNo(ch.address)})"></span>
<span class="led-label">${chNo}</span></span>`;
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 += `</div>`;
// kind+slot designation in the module's lower left, below the
// indicators (FR-10.2.2)
html += `<div class="mod-designation">${designation}</div>`;
el.innerHTML = html;
rail.appendChild(el);
}
+11 -5
View File
@@ -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) */
+51
View File
@@ -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)