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
+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) */