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, "isBackground": false,
"problemMatcher": [] "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/ `--web-port`, env `WAGO_SIM_WEB_USER/PASSWORD/READONLY/
NO_WEB/WEB_PORT`; login page + SPA shell. NO_WEB/WEB_PORT`; login page + SPA shell.
- [x] M2: node overview page — DIN-rail strip with coupler block - [x] M2: node overview page — DIN-rail strip with coupler block
(MS/NS/BF/SF LEDs), module bezels with article numbers plus the (MS/NS/BF/SF LEDs), module bezels with article numbers, the
kind+slot designation (`DI0`, `DO2`, `AI4`) in the header's kind+slot designation (`DI0`, `DO2`, `AI4`) in the module's
upper right corner, per-channel LEDs for DI/DO with the channel lower left corner below the indicators, per-channel LEDs for
number beneath, bar+numeric indicators for AI/AO with the channel DI/DO with the channel number beneath, bar+numeric indicators
number outside the overflow/underflow limit LEDs, live values for AI/AO with the channel number outside the overflow/underflow
via WebSocket (`/api/ws`, 10 Hz delta push), `GET /api/values` limit LEDs, live values via WebSocket (`/api/ws`, 10 Hz delta
snapshot (FR-10.2.2). push), `GET /api/values` snapshot (FR-10.2.2).
- [x] M3: manipulation — DI/DO toggle (field side via - [x] M3: manipulation — DI/DO toggle (field side via
`set_digital_input_channel`, master side via internal `set_digital_input_channel`, master side via internal
`write_bits`/`write_words` dispatch so mirrors/statistics stay `write_bits`/`write_words` dispatch so mirrors/statistics stay
@@ -344,6 +344,11 @@ requirement ID for each item in parentheses.
module saves, export/import, and rebuilds; also fixes the bar module saves, export/import, and rebuilds; also fixes the bar
never filling (it set width instead of height on a vertical bar) never filling (it set width instead of height on a vertical bar)
and the invisible numeric label (clipped by overflow: hidden). 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, - [ ] M6: scenario run view + alias-ownership rejection (FR-10.2.6,
FR-10.2.9) — deferred. FR-10.2.9) — deferred.
- [x] Tests: 46 web-UI tests (`tests/test_webui.py`) via httpx - [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 derived from the same process image the Modbus server serves. On the
node graphic (requested 2026-10-04, refined same day): each module node graphic (requested 2026-10-04, refined same day): each module
shows its kind+slot designation (`DI0`, `DO2`, `AI4`, …) in the shows its kind+slot designation (`DI0`, `DO2`, `AI4`, …) in the
module header's upper right corner — same size and vertical module's **lower left corner, below the channel indicators**, and
alignment as the article number — and the channel indicators carry the channel indicators carry only the 0-based channel number: below
only the 0-based channel number: below each DI/DO LED, and on analog each DI/DO LED, and on analog modules outside the indicator stack,
modules outside the indicator stack, above the overflow and below above the overflow and below the underflow LED (FR-10.2.14) so the
the underflow LED (FR-10.2.14) so the limit LEDs stay adjacent to the limit LEDs stay adjacent to the bar. DI/DO channels lay out in **two
bar. 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), - FR-10.2.3: Values shall update live (WebSocket push or short-polling),
including changes made by the PLC/master over Modbus, by scenarios, by including changes made by the PLC/master over Modbus, by scenarios, by
the console monitor, or by other UI clients — one shared source of truth. the console monitor, or by other UI clients — one shared source of truth.
@@ -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 Underflow/overflow are indicated: the bar fills in the limit color
(red) and a small red LED above the bar lights for overflow, below (red) and a small red LED above the bar lights for overflow, below
the bar for underflow, clearing when the value returns to range. 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) ### 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'" f"channel_ranges entry for {code_up!r} needs 'min' and 'max'"
) )
lo, hi = int(spec["min"]), int(spec["max"]) lo, hi = int(spec["min"]), int(spec["max"])
if not 0 <= lo < hi <= 65535: if not -65534 <= lo < hi <= 65535:
raise ScenarioError( 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) ranges[code_up] = (lo, hi)
return ranges return ranges
+23 -6
View File
@@ -216,13 +216,13 @@ async def set_channel(
return {"ok": True, "value": value} return {"ok": True, "value": value}
if kind == "ai": if kind == "ai":
loc = node.channel_by_module("ai", module, channel) 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) node.datastore.set_input_word(loc.word, value)
return {"ok": True, "value": value} return {"ok": True, "value": value}
if kind == "ao": if kind == "ao":
# master-side force: word write at the OUTPUT image (Table 120) # master-side force: word write at the OUTPUT image (Table 120)
loc = node.channel_by_module("ao", module, channel) 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]) result = node.datastore.write_words(loc.word, [value])
if isinstance(result, Exception): if isinstance(result, Exception):
raise HTTPException(status_code=400, detail=str(result)) 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}") 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: def _payload_bool(payload: dict) -> bool:
value = payload.get("value") value = payload.get("value")
if isinstance(value, bool): if isinstance(value, bool):
@@ -528,10 +545,10 @@ async def put_channel_ranges(
raise HTTPException( raise HTTPException(
status_code=400, detail=f"range for {code_up!r} must be numeric" status_code=400, detail=f"range for {code_up!r} must be numeric"
) from None ) from None
if not 0 <= lo < hi <= 65535: if not -65534 <= lo < hi <= 65535:
raise HTTPException( raise HTTPException(
status_code=400, 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): if (lo, hi) == (0, 4095):
continue # default — keep the store clean continue # default — keep the store clean
@@ -643,9 +660,9 @@ async def import_config(
raise HTTPException(status_code=400, raise HTTPException(status_code=400,
detail=f"range for {code_up!r} needs 'min' and 'max'") detail=f"range for {code_up!r} needs 'min' and 'max'")
lo, hi = int(spec["min"]), int(spec["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, 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): if (lo, hi) != (0, 4095):
ranges[code_up] = (lo, hi) ranges[code_up] = (lo, hi)
coupler = payload.get("coupler_settings") or {} coupler = payload.get("coupler_settings") or {}
+18 -3
View File
@@ -97,7 +97,10 @@ def channel_values(node: VirtualNode) -> list[ChannelValue]:
"name": node.channel_names.get(code), "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: for loc in node.ai:
code = node.io_code("ai", loc.module_index, loc.channel - 1) code = node.io_code("ai", loc.module_index, loc.channel - 1)
out.append( out.append(
@@ -105,7 +108,7 @@ def channel_values(node: VirtualNode) -> list[ChannelValue]:
"kind": "ai", "kind": "ai",
"module": loc.module_index, "module": loc.module_index,
"channel": loc.channel - 1, "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), "name": node.channel_names.get(code),
} }
) )
@@ -116,8 +119,20 @@ def channel_values(node: VirtualNode) -> list[ChannelValue]:
"kind": "ao", "kind": "ao",
"module": loc.module_index, "module": loc.module_index,
"channel": loc.channel - 1, "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), "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)"> onchange="listWrite('${ch.kind}', ${mod.index}, ${no}, this)">
<span class="slider"></span></label>`; <span class="slider"></span></label>`;
} else { } 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}" id="val-${ch.kind}-${mod.index}-${no}"
onchange="listWrite('${ch.kind}', ${mod.index}, ${no}, this)">`; onchange="listWrite('${ch.kind}', ${mod.index}, ${no}, this)">`;
} }
@@ -103,7 +103,7 @@ async function listWrite(kind, module, channel, el) {
if (READONLY) return; if (READONLY) return;
const payload = (kind === "di" || kind === "do") const payload = (kind === "di" || kind === "do")
? {action: "set", value: el.checked} ? {action: "set", value: el.checked}
: {action: "set", value: Number(el.value) & 0xFFFF}; : {action: "set", value: Math.trunc(Number(el.value))};
try { try {
await api(`/api/channels/${kind}/${module}/${channel}`, { await api(`/api/channels/${kind}/${module}/${channel}`, {
method: "POST", method: "POST",
@@ -147,9 +147,9 @@ function renderRangeEditor() {
const rg = ch.range || {min: 0, max: 4095}; const rg = ch.range || {min: 0, max: 4095};
html += `<label class="range-row"><span class="row-io">${ch.address}</span> html += `<label class="range-row"><span class="row-io">${ch.address}</span>
<span class="range-fields"> <span class="range-fields">
<input type="number" class="range-min" data-code="${ch.address}" min="0" max="65534" value="${rg.min}"> <input type="number" class="range-min" data-code="${ch.address}" min="-65534" max="65534" value="${rg.min}">
<span class="range-sep">–</span> <span class="range-sep">&ndash;</span>
<input type="number" class="range-max" data-code="${ch.address}" min="1" max="65535" value="${rg.max}"> <input type="number" class="range-max" data-code="${ch.address}" min="-65533" max="65535" value="${rg.max}">
</span></label>`; </span></label>`;
} }
} }
@@ -271,8 +271,9 @@ function renderRail() {
const el = document.createElement("div"); const el = document.createElement("div");
el.className = "module"; el.className = "module";
el.dataset.index = mod.index; el.dataset.index = mod.index;
// width: per-channel LED + channel-number label, min 110px // width: 8 channels per row (two-deck layout), min 110px
el.style.width = `${Math.max(110, 30 * Math.min(mod.channels.length, 16))}px`; 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"; const kind = mod.channels[0] ? mod.channels[0].kind : "di";
el.classList.add(`mod-${kind.startsWith("d") ? kind : "analog"}`); el.classList.add(`mod-${kind.startsWith("d") ? kind : "analog"}`);
@@ -281,17 +282,21 @@ function renderRail() {
const firstCode = mod.channels[0] ? mod.channels[0].address : ""; const firstCode = mod.channels[0] ? mod.channels[0].address : "";
const designation = firstCode.slice(0, firstCode.indexOf(".")); const designation = firstCode.slice(0, firstCode.indexOf("."));
let html = `<div class="mod-head" title="${mod.description}">${mod.article} let html = `<div class="mod-head" title="${mod.description}">${mod.article}</div>`;
<span class="mod-designation">${designation}</span></div>`;
html += `<div class="mod-channels">`; html += `<div class="mod-channels">`;
let chIndex = 0; // digital channels: two rows, 0-7 then 8-15
for (const ch of mod.channels) { for (const ch of mod.channels) {
const id = `ch-${ch.kind}-${mod.index}-${channelNo(ch.address)}`; const id = `ch-${ch.kind}-${mod.index}-${channelNo(ch.address)}`;
const chNo = String(channelNo(ch.address)); // 0-based channel only const chNo = String(channelNo(ch.address)); // 0-based channel only
if (ch.kind === "di" || ch.kind === "do") { 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"> html += `<span class="led-cell">
<span class="led" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})" <span class="led" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})"
onclick="clickChannel('${ch.kind}', ${mod.index}, ${channelNo(ch.address)})"></span> onclick="clickChannel('${ch.kind}', ${mod.index}, ${channelNo(ch.address)})"></span>
<span class="led-label">${chNo}</span></span>`; <span class="led-label">${chNo}</span></span>`;
chIndex += 1;
} else { } else {
const rg = ch.range || {min: 0, max: 4095}; const rg = ch.range || {min: 0, max: 4095};
// channel number sits OUTSIDE the limit LEDs: above the overflow // channel number sits OUTSIDE the limit LEDs: above the overflow
@@ -307,6 +312,9 @@ function renderRail() {
} }
} }
html += `</div>`; 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; el.innerHTML = html;
rail.appendChild(el); 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 /* label stacks (LED + code, or code + lim LEDs + bar + num) are
taller than the bare indicators; keep the rail visually even */ taller than the bare indicators; keep the rail visually even */
min-height: 124px; min-height: 124px;
flex-shrink: 0; /* keep the computed width — do not squeeze rows */
} }
.mod-head { .mod-head {
position: absolute; top: 2px; left: 5px; position: absolute; top: 2px; left: 5px;
@@ -98,11 +99,11 @@ h2 { margin: 4px 0 14px; }
letter-spacing: 0.02em; white-space: nowrap; letter-spacing: 0.02em; white-space: nowrap;
} }
.mod-designation { .mod-designation {
/* kind+slot designation (DI0, DO2, AI4) — upper right corner, /* kind+slot designation (DI0, DO2, AI4) — module's lower left
same size and vertical alignment as the article number */ corner, below the channel indicators (FR-10.2.2) */
position: absolute; top: 2px; right: 5px; position: absolute; bottom: 3px; left: 5px;
font-size: 0.62rem; color: #33414f; font-weight: 600; 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 { min-width: 96px; background: linear-gradient(#0d5db4, #003a7a); }
.coupler .mod-head { color: #fff; font-size: 0.75rem; } .coupler .mod-head { color: #fff; font-size: 0.75rem; }
@@ -118,7 +119,12 @@ h2 { margin: 4px 0 14px; }
.module { display: flex; flex-direction: column; justify-content: flex-end; } .module { display: flex; flex-direction: column; justify-content: flex-end; }
.mod-channels { .mod-channels {
display: flex; gap: 5px; flex-wrap: wrap; align-items: flex-end; 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) */ /* 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}) "channel_ranges": {"AI4.0": {"min": 0}}, **base})
assert r.status_code == 400 # missing max 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): async def test_ranges_persist_to_yaml(self, client, app, tmp_path):
cfg_file = tmp_path / "node.yaml" cfg_file = tmp_path / "node.yaml"
app.node_config_path = str(cfg_file) app.node_config_path = str(cfg_file)