🚧 Change module numbering
This commit is contained in:
+88
-27
@@ -202,21 +202,32 @@ class VirtualNode:
|
||||
|
||||
# -- channel naming (spec FR-10.2.11) ----------------------------------
|
||||
|
||||
def io_code(self, kind: str, module: int, channel: int) -> str:
|
||||
"""I/O code for a module channel, e.g. ('di', 0, 0) -> 'DI0.0'."""
|
||||
return f"{kind.upper()}{module}.{channel}"
|
||||
def io_code_at_slot(self, kind: str, module_slot: int, channel: int) -> str:
|
||||
"""I/O code for a channel identified by PHYSICAL slot, e.g.
|
||||
('di', 0, 0) -> 'DI0.0' (first DI module). Internal callers that
|
||||
walk the channel pools (slot-keyed) use this; the code itself
|
||||
carries the per-type module number (FR-4.3.11 v1.5)."""
|
||||
try:
|
||||
per_type = self.kind_index(module_slot).get(kind)
|
||||
except IndexError:
|
||||
return f"{kind.upper()}?.{channel}"
|
||||
if per_type is None:
|
||||
return f"{kind.upper()}?.{channel}"
|
||||
return f"{kind.upper()}{per_type}.{channel}"
|
||||
|
||||
def channel_name(self, kind: str, module: int, channel: int) -> str | None:
|
||||
def channel_name(self, kind: str, module_slot: int, channel: int) -> str | None:
|
||||
"""Configured application name for a channel, or None."""
|
||||
return self.channel_names.get(self.io_code(kind, module, channel))
|
||||
return self.channel_names.get(
|
||||
self.io_code_at_slot(kind, module_slot, channel)
|
||||
)
|
||||
|
||||
def channel_range(self, kind: str, module: int, channel: int) -> tuple[int, int]:
|
||||
def channel_range(self, kind: str, module_slot: int, channel: int) -> tuple[int, int]:
|
||||
"""Display range (min, max) raw counts for an analog channel.
|
||||
|
||||
Defaults to (0, 4095) when unconfigured (FR-10.2.14).
|
||||
"""
|
||||
return self.channel_ranges.get(
|
||||
self.io_code(kind, module, channel), self.DEFAULT_RANGE
|
||||
self.io_code_at_slot(kind, module_slot, channel), self.DEFAULT_RANGE
|
||||
)
|
||||
|
||||
def validate_channel_names(self) -> list[str]:
|
||||
@@ -226,7 +237,7 @@ class VirtualNode:
|
||||
for kind, pool in (("di", self._di_channels), ("do", self._do_channels),
|
||||
("ai", self._ai_channels), ("ao", self._ao_channels)):
|
||||
for loc in pool:
|
||||
known.add(self.io_code(kind, loc.module_index, loc.channel - 1))
|
||||
known.add(self.io_code_at_slot(kind, loc.module_index, loc.channel - 1))
|
||||
return sorted(set(self.channel_names) - known)
|
||||
|
||||
# -- named channel access (scenario engine API) -----------------------------
|
||||
@@ -256,24 +267,57 @@ class VirtualNode:
|
||||
raise IndexError(f"no {kind} channel {index} (have {len(pool)})")
|
||||
return pool[index - 1]
|
||||
|
||||
def kind_modules(self, kind: str) -> list[ModuleType]:
|
||||
"""Modules providing the given kind, in physical order.
|
||||
|
||||
`channel_by_module` numbers modules 0-based within this list
|
||||
(FR-4.3.11 v1.5: DI0, DO0, AI0, AO0 start each kind's sequence).
|
||||
"""
|
||||
kind_field = {
|
||||
"di": "input_bits", "do": "output_bits",
|
||||
"ai": "input_words", "ao": "output_words",
|
||||
}
|
||||
if kind not in kind_field:
|
||||
raise ValueError(f"unknown channel kind: {kind!r}")
|
||||
field = kind_field[kind]
|
||||
return [m for m in self.modules
|
||||
if getattr(m, kind_field[kind]) > 0]
|
||||
|
||||
def kind_index(self, module_index: int) -> dict[str, int]:
|
||||
"""Per-type number (FR-4.3.11 v1.5) for the module at the given
|
||||
physical slot: {'di': 0, ...} for the first DI module etc."""
|
||||
mod = self.modules[module_index]
|
||||
out: dict[str, int] = {}
|
||||
for kind in ("di", "do", "ai", "ao"):
|
||||
field = {"di": "input_bits", "do": "output_bits",
|
||||
"ai": "input_words", "ao": "output_words"}[kind]
|
||||
if getattr(mod, field) == 0:
|
||||
continue
|
||||
same_kind = [i for i, m in enumerate(self.modules)
|
||||
if getattr(m, field) > 0]
|
||||
out[kind] = same_kind.index(module_index)
|
||||
return out
|
||||
|
||||
def channel_by_module(self, kind: str, module: int, channel: int) -> ChannelLocation:
|
||||
"""Access a channel by module position and channel number, both 0-based.
|
||||
"""Access a channel by module number and channel number, both 0-based.
|
||||
|
||||
Addresses a channel as *module type + module number + channel
|
||||
number*, e.g. DI0.1 = digital input, module slot 0, channel 1.
|
||||
The module number is the position in the configured module list;
|
||||
the channel number is the channel on that module (0-based), i.e.
|
||||
DI0.1 selects the module's 2nd digital input.
|
||||
number*, e.g. DI1.0 = digital inputs, the 2nd DI module, channel 0
|
||||
(0-based) — **the module number counts within the module's type**
|
||||
(FR-4.3.11 v1.5): DI modules are numbered DI0, DI1, … regardless
|
||||
of their physical slots among DO/AI/AO modules.
|
||||
|
||||
The channel kind must match what the module actually provides —
|
||||
DI0.1 on a module with no inputs is a configuration error.
|
||||
DI1.0 on a module with no inputs is a configuration error.
|
||||
"""
|
||||
if not 0 <= module < len(self.modules):
|
||||
modules_of_kind = self.kind_modules(kind)
|
||||
if not 0 <= module < len(modules_of_kind):
|
||||
raise IndexError(
|
||||
f"no module {module} (node has {len(self.modules)} modules, "
|
||||
f"numbered 0-{len(self.modules) - 1})"
|
||||
f"no {kind.upper()} module {module} "
|
||||
f"(node has {len(modules_of_kind)} {kind.upper()} modules, "
|
||||
f"numbered 0-{len(modules_of_kind) - 1} within the type)"
|
||||
)
|
||||
mod = self.modules[module]
|
||||
mod = modules_of_kind[module]
|
||||
pool_map = {
|
||||
"di": (self._di_channels, mod.input_bits),
|
||||
"do": (self._do_channels, mod.output_bits),
|
||||
@@ -285,24 +329,41 @@ class VirtualNode:
|
||||
pool, module_channel_count = pool_map[kind]
|
||||
if module_channel_count == 0:
|
||||
raise ChannelMismatch(
|
||||
f"module {module} ({mod.article}) has no {kind.upper()} channels"
|
||||
f"{kind.upper()} module {module} ({mod.article}) has no "
|
||||
f"{kind.upper()} channels"
|
||||
)
|
||||
if not 0 <= channel < module_channel_count:
|
||||
raise IndexError(
|
||||
f"module {module} ({mod.article}) has no {kind.upper()} "
|
||||
f"channel {channel} (has {module_channel_count}: 0-"
|
||||
f"{module_channel_count - 1})"
|
||||
f"{kind.upper()} module {module} ({mod.article}) has no "
|
||||
f"{kind.upper()} channel {channel} (has {module_channel_count}: "
|
||||
f"0-{module_channel_count - 1})"
|
||||
)
|
||||
# find the location: channels of one module are contiguous in the
|
||||
# kind pool; locate the first channel of this module in the pool
|
||||
# channels of one module are contiguous in the kind pool; find the
|
||||
# physical slot of the per-type module and its matching location
|
||||
physical_slot = [
|
||||
i for i, m in enumerate(self.modules) if m is mod
|
||||
][0]
|
||||
for loc in pool:
|
||||
if loc.module_index == module and loc.channel == channel + 1:
|
||||
if (loc.module_index == physical_slot
|
||||
and loc.channel == channel + 1):
|
||||
return loc
|
||||
raise ChannelMismatch(
|
||||
f"module {module} ({mod.article}) channel {channel} is not a "
|
||||
f"{kind.upper()} channel"
|
||||
f"{kind.upper()} module {module} ({mod.article}) channel "
|
||||
f"{channel} is not a {kind.upper()} channel"
|
||||
)
|
||||
|
||||
def io_code(self, kind: str, module: int, channel: int) -> str:
|
||||
"""I/O code with the PER-TYPE module number, e.g. ('di', 0, 0) -> 'DI0.0'
|
||||
where the module number is the index within the module's type
|
||||
(FR-4.3.11 v1.5)."""
|
||||
modules_of_kind = self.kind_modules(kind)
|
||||
if not 0 <= module < len(modules_of_kind):
|
||||
raise IndexError(
|
||||
f"no {kind.upper()} module {module} "
|
||||
f"(node has {len(modules_of_kind)} {kind.upper()} modules)"
|
||||
)
|
||||
return f"{kind.upper()}{module}.{channel}"
|
||||
|
||||
# -- field-side setters (simulated world) -------------------------------------
|
||||
|
||||
def set_di(self, channel: int, value: bool) -> None:
|
||||
|
||||
+18
-12
@@ -16,12 +16,12 @@ def node_layout(node: VirtualNode) -> NodeLayoutJSON:
|
||||
modules_json = []
|
||||
for index, mod in enumerate(node.modules):
|
||||
channels = []
|
||||
# per-type module number for this slot (FR-4.3.11 v1.5):
|
||||
# a DI module at physical slot 2 with no earlier DI module is DI0
|
||||
per_type = node.kind_index(index)
|
||||
for ch in mod.channels:
|
||||
kind_upper = ch.kind.upper()
|
||||
# module-based addressing per FR-4.3.11: {KIND}{module}.{channel}
|
||||
# `module` is the 0-based slot; `channel` is 0-based on module.
|
||||
channel_no = int(ch.name.split()[-1]) - 1
|
||||
io_code = f"{kind_upper}{index}.{channel_no}"
|
||||
io_code = f"{ch.kind.upper()}{per_type[ch.kind]}.{channel_no}"
|
||||
lo, hi = (0, 4095)
|
||||
if ch.kind in ("ai", "ao"):
|
||||
lo, hi = node.channel_ranges.get(io_code, (0, 4095))
|
||||
@@ -75,23 +75,27 @@ def channel_values(node: VirtualNode) -> list[ChannelValue]:
|
||||
out: list[ChannelValue] = []
|
||||
|
||||
# Digital channels: global 1-based numbers, bit-packed after analog.
|
||||
# `module` in each entry is the PER-TYPE module number (FR-4.3.11
|
||||
# v1.5) so the UI round-trips the same number the codes carry.
|
||||
for i, loc in enumerate(node.di, start=1):
|
||||
code = node.io_code("di", loc.module_index, loc.channel - 1)
|
||||
kind_no = node.kind_index(loc.module_index)["di"]
|
||||
code = node.io_code_at_slot("di", loc.module_index, loc.channel - 1)
|
||||
out.append(
|
||||
{
|
||||
"kind": "di",
|
||||
"module": loc.module_index,
|
||||
"module": kind_no,
|
||||
"channel": loc.channel - 1,
|
||||
"value": ds.digital_input_channel(i),
|
||||
"name": node.channel_names.get(code),
|
||||
}
|
||||
)
|
||||
for i, loc in enumerate(node.do, start=1):
|
||||
code = node.io_code("do", loc.module_index, loc.channel - 1)
|
||||
kind_no = node.kind_index(loc.module_index)["do"]
|
||||
code = node.io_code_at_slot("do", loc.module_index, loc.channel - 1)
|
||||
out.append(
|
||||
{
|
||||
"kind": "do",
|
||||
"module": loc.module_index,
|
||||
"module": kind_no,
|
||||
"channel": loc.channel - 1,
|
||||
"value": ds.digital_output_channel(i),
|
||||
"name": node.channel_names.get(code),
|
||||
@@ -102,22 +106,24 @@ def channel_values(node: VirtualNode) -> list[ChannelValue]:
|
||||
# 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)
|
||||
kind_no = node.kind_index(loc.module_index)["ai"]
|
||||
code = node.io_code_at_slot("ai", loc.module_index, loc.channel - 1)
|
||||
out.append(
|
||||
{
|
||||
"kind": "ai",
|
||||
"module": loc.module_index,
|
||||
"module": kind_no,
|
||||
"channel": loc.channel - 1,
|
||||
"value": _display_value(node, "ai", loc, ds.get_input_word(loc.word)),
|
||||
"name": node.channel_names.get(code),
|
||||
}
|
||||
)
|
||||
for loc in node.ao:
|
||||
code = node.io_code("ao", loc.module_index, loc.channel - 1)
|
||||
kind_no = node.kind_index(loc.module_index)["ao"]
|
||||
code = node.io_code_at_slot("ao", loc.module_index, loc.channel - 1)
|
||||
out.append(
|
||||
{
|
||||
"kind": "ao",
|
||||
"module": loc.module_index,
|
||||
"module": kind_no,
|
||||
"channel": loc.channel - 1,
|
||||
"value": _display_value(node, "ao", loc, ds.get_output_word(loc.word)),
|
||||
"name": node.channel_names.get(code),
|
||||
|
||||
@@ -75,19 +75,20 @@ function renderChannelList() {
|
||||
for (const mod of NODE.modules) {
|
||||
for (const ch of mod.channels) {
|
||||
const no = channelNo(ch.address);
|
||||
const id = `row-${ch.kind}-${mod.index}-${no}`;
|
||||
const mno = moduleNo(ch.address); // per-type module number
|
||||
const id = `row-${ch.kind}-${mno}-${no}`;
|
||||
const named = ch.channel_name ? `<span class="row-name">${ch.channel_name}</span>`
|
||||
: `<span class="row-name unnamed">—</span>`;
|
||||
let control;
|
||||
if (ch.kind === "di" || ch.kind === "do") {
|
||||
control = `<label class="switch"><input type="checkbox" class="row-value"
|
||||
id="val-${ch.kind}-${mod.index}-${no}"
|
||||
onchange="listWrite('${ch.kind}', ${mod.index}, ${no}, this)">
|
||||
id="val-${ch.kind}-${mno}-${no}"
|
||||
onchange="listWrite('${ch.kind}', ${mno}, ${no}, this)">
|
||||
<span class="slider"></span></label>`;
|
||||
} else {
|
||||
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)">`;
|
||||
id="val-${ch.kind}-${mno}-${no}"
|
||||
onchange="listWrite('${ch.kind}', ${mno}, ${no}, this)">`;
|
||||
}
|
||||
html += `<div class="chlist-row" id="${id}" title="${ch.name} · MB ${ch.modbus}">
|
||||
<span class="row-io">${ch.address}</span>
|
||||
@@ -253,7 +254,6 @@ async function importConfig(file) {
|
||||
function renderRail() {
|
||||
const rail = document.getElementById("rail");
|
||||
rail.innerHTML = "";
|
||||
|
||||
// coupler block on the left
|
||||
const coupler = document.createElement("div");
|
||||
coupler.className = "coupler";
|
||||
@@ -293,7 +293,7 @@ function renderRail() {
|
||||
html += `<div class="mod-channels ${isDigital ? "mod-channels-v" : ""}">`;
|
||||
let chIndex = 0;
|
||||
for (const ch of mod.channels) {
|
||||
const id = `ch-${ch.kind}-${mod.index}-${channelNo(ch.address)}`;
|
||||
const id = `ch-${ch.kind}-${moduleNo(ch.address)}-${channelNo(ch.address)}`;
|
||||
const chNo = String(channelNo(ch.address)); // 0-based channel only
|
||||
if (isDigital) {
|
||||
if (chIndex > 0 && chIndex % 8 === 0) {
|
||||
@@ -302,7 +302,7 @@ function renderRail() {
|
||||
}
|
||||
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>
|
||||
onclick="clickChannel('${ch.kind}', ${moduleNo(ch.address)}, ${channelNo(ch.address)})"></span>
|
||||
<span class="led-label">${chNo}</span></span>`;
|
||||
chIndex += 1;
|
||||
} else {
|
||||
@@ -313,7 +313,7 @@ function renderRail() {
|
||||
<span class="abar-label top-label">${chNo}</span>
|
||||
<span class="lim-led lim-over" id="${id}-over" title="overflow (> ${rg.max})"></span>
|
||||
<span class="abar" id="${id}" title="${ch.name} (${ch.address}, MB ${ch.modbus})"
|
||||
onclick="clickBar('${ch.kind}', ${mod.index}, ${channelNo(ch.address)}, event)">
|
||||
onclick="clickBar('${ch.kind}', ${moduleNo(ch.address)}, ${channelNo(ch.address)}, event)">
|
||||
<span class="abar-fill"></span></span>
|
||||
<span class="lim-led lim-under" id="${id}-under" title="underflow (< ${rg.min})"></span>
|
||||
<span class="abar-label bottom-label">${chNo}</span>
|
||||
@@ -337,6 +337,13 @@ function channelNo(address) {
|
||||
return dot >= 0 ? Number(address.slice(dot + 1)) : 0;
|
||||
}
|
||||
|
||||
function moduleNo(address) {
|
||||
// address is like "DI2.1" — the module part before the dot;
|
||||
// per FR-4.3.11 v1.5 this is the PER-TYPE module number (DI0, DO0…)
|
||||
const dot = address.indexOf(".");
|
||||
return dot >= 0 ? Number(address.slice(2, dot)) : 0;
|
||||
}
|
||||
|
||||
function updateChannel(kind, module, channel, value) {
|
||||
const id = `ch-${kind}-${module}-${channel}`;
|
||||
const el = document.getElementById(id);
|
||||
@@ -371,9 +378,12 @@ function updateChannel(kind, module, channel, value) {
|
||||
function findChannel(kind, module, channel) {
|
||||
if (!NODE) return null;
|
||||
for (const mod of NODE.modules) {
|
||||
if (mod.index !== module) continue;
|
||||
return mod.channels.find((c) => c.kind === kind &&
|
||||
channelNo(c.address) === channel) || null;
|
||||
for (const c of mod.channels) {
|
||||
if (c.kind === kind && moduleNo(c.address) === module
|
||||
&& channelNo(c.address) === channel) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -525,6 +535,13 @@ async function renderConfig() {
|
||||
${m.output_bits ? m.output_bits + " DO" : ""}
|
||||
${m.input_words ? m.input_words + " AIw" : ""}
|
||||
${m.output_words ? m.output_words + " AOW" : ""}</span>
|
||||
<span class="row-buttons">
|
||||
<button class="mini" onclick="moveModule(${i}, -1)"
|
||||
${i === 0 ? "disabled" : ""} title="Move up">▲</button>
|
||||
<button class="mini" onclick="moveModule(${i}, +1)"
|
||||
${i === mc.modules.length - 1 ? "disabled" : ""}
|
||||
title="Move down">▼</button>
|
||||
</span>
|
||||
</label>`).join("");
|
||||
const pick = document.getElementById("catalog-pick");
|
||||
pick.innerHTML = mc.catalog.map((c) =>
|
||||
@@ -629,9 +646,26 @@ function renderModuleRows() {
|
||||
<label class="module-row">
|
||||
<input type="radio" name="mod" value="${i}">
|
||||
<b>${i}</b> ${a}
|
||||
<span class="row-buttons">
|
||||
<button class="mini" onclick="moveModule(${i}, -1)"
|
||||
${i === 0 ? "disabled" : ""} title="Move up">▲</button>
|
||||
<button class="mini" onclick="moveModule(${i}, +1)"
|
||||
${i === MODULE_ARTICLES.length - 1 ? "disabled" : ""}
|
||||
title="Move down">▼</button>
|
||||
</span>
|
||||
</label>`).join("");
|
||||
}
|
||||
|
||||
async function moveModule(index, delta) {
|
||||
// FR-11.2.6: reorder by swapping with the neighbor, then SAVE CONFIG
|
||||
// (rebuild + persist) so the rail reflects the new physical order
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= MODULE_ARTICLES.length) return;
|
||||
[MODULE_ARTICLES[index], MODULE_ARTICLES[target]] =
|
||||
[MODULE_ARTICLES[target], MODULE_ARTICLES[index]];
|
||||
await saveModules();
|
||||
}
|
||||
|
||||
// ---------------- tabs ----------------
|
||||
|
||||
document.querySelectorAll(".tab").forEach((btn) => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<title>WAGO 750-362 Simulator</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=2">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
@@ -111,6 +111,6 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
<script src="/static/app.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<title>WAGO Simulator — Login</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=2">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="login-card">
|
||||
|
||||
@@ -259,6 +259,14 @@ h2 { margin: 4px 0 14px; }
|
||||
}
|
||||
.module-row:hover { background: #eaf1f8; }
|
||||
.mod-io { color: #55606c; margin-left: auto; font-size: 0.78rem; white-space: nowrap; }
|
||||
.row-buttons { display: inline-flex; gap: 3px; margin-left: 6px; }
|
||||
button.mini {
|
||||
padding: 1px 6px; border: 1px solid #c5ccd4; border-radius: 3px;
|
||||
background: #f0f3f6; color: #33414f; font-size: 0.6rem;
|
||||
line-height: 1.2; cursor: pointer;
|
||||
}
|
||||
button.mini:hover:not(:disabled) { background: #dce7f2; }
|
||||
button.mini:disabled { opacity: 0.35; cursor: default; }
|
||||
#catalog-pick { padding: 6px; border: 1px solid #c5ccd4; border-radius: 4px; flex: 1; }
|
||||
.cfg-path { font-size: 0.75rem; color: #6b7684; align-self: center; }
|
||||
button.primary { background: #2e7d32; }
|
||||
|
||||
Reference in New Issue
Block a user