🚧 Change module numbering

This commit is contained in:
Jonas Bengtsson
2026-10-04 13:05:47 +02:00
parent 7948b794f5
commit e5df0b04ed
17 changed files with 644 additions and 138 deletions
+88 -27
View File
@@ -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
View File
@@ -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),
+46 -12
View File
@@ -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 (&gt; ${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 (&lt; ${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">&#9650;</button>
<button class="mini" onclick="moveModule(${i}, +1)"
${i === mc.modules.length - 1 ? "disabled" : ""}
title="Move down">&#9660;</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>&nbsp; ${a}
<span class="row-buttons">
<button class="mini" onclick="moveModule(${i}, -1)"
${i === 0 ? "disabled" : ""} title="Move up">&#9650;</button>
<button class="mini" onclick="moveModule(${i}, +1)"
${i === MODULE_ARTICLES.length - 1 ? "disabled" : ""}
title="Move down">&#9660;</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) => {
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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">
+8
View File
@@ -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; }