🚧 WIP
This commit is contained in:
@@ -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) */
|
||||
|
||||
Reference in New Issue
Block a user