🚧 WIP
This commit is contained in:
Vendored
+166
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
@@ -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">–</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);
|
||||
}
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user