This commit is contained in:
Jonas Bengtsson
2026-10-04 09:28:02 +02:00
parent 87c1c8db7b
commit 4adbb4171e
16 changed files with 788 additions and 29 deletions
+9 -4
View File
@@ -42,7 +42,8 @@ class SimulatorApp:
return self.node.modules
def rebuild_node(app: SimulatorApp, modules: list[ModuleType]) -> None:
def rebuild_node(app: SimulatorApp, modules: list[ModuleType],
channel_names: dict[str, str] | None = None) -> None:
"""Hot-configure the module list: rebuild the process image in place.
Implements the §11 FR-11.2.1–11.2.3 semantics for the web UI: the
@@ -51,11 +52,14 @@ def rebuild_node(app: SimulatorApp, modules: list[ModuleType]) -> None:
recomputed, and the register bank is updated so 0x1022-0x1025
(image sizes) and 0x2030+ (module description) reflect the new
configuration. Values written so far are lost — like pulling the
KBUS and re-configuring on a real node.
KBUS and re-configuring on a real node. Channel names (FR-10.2.11)
are carried over when `channel_names` is None (unchanged module
list) or replaced with the given mapping.
"""
datastore = app.datastore
names = channel_names if channel_names is not None else app.node.channel_names
datastore.reset_images()
app.node = VirtualNode(datastore, modules)
app.node = VirtualNode(datastore, modules, channel_names=names)
app.registers.set_process_image_bits(
analog_in=app.node.analog_in_bits,
analog_out=app.node.analog_out_bits,
@@ -74,6 +78,7 @@ def build_app(
firmware_description: str = "WAGO 750-362 Simulator",
web: WebUIConfig | None = None,
node_config_path: str | None = None,
channel_names: dict[str, str] | None = None,
) -> SimulatorApp:
"""Assemble all simulator components for a node configuration."""
stats = ModbusStatistics()
@@ -116,7 +121,7 @@ def build_app(
)
datastore._config = registers # late binding to break the cycle
node = VirtualNode(datastore, modules)
node = VirtualNode(datastore, modules, channel_names=channel_names)
registers.set_process_image_bits(
analog_in=node.analog_in_bits,
analog_out=node.analog_out_bits,
+4 -2
View File
@@ -40,7 +40,8 @@ import yaml
from .app import WebUIConfig, build_app
from .modules import ChannelMismatch, ModuleUnknown
from .scenario.dsl import load_node_modules, load_yaml, validate_scenario
from .scenario.dsl import (load_channel_names, load_node_modules,
load_yaml, validate_scenario)
from .scenario.engine import ScenarioEngine, ScenarioError
from .scenario.report import result_summary, write_csv, write_jsonl
@@ -180,6 +181,7 @@ def _prepare_report_path(path: str, option: str) -> Path:
async def _serve(args: argparse.Namespace) -> int:
node_config = _load_yaml_file(args.node, "node config")
modules = load_node_modules(node_config)
channel_names = load_channel_names(node_config)
web_cfg = None
if args.web:
web_cfg = WebUIConfig(
@@ -190,7 +192,7 @@ async def _serve(args: argparse.Namespace) -> int:
readonly=_env_flag("WAGO_SIM_WEB_READONLY"),
)
app = build_app(modules=modules, host=args.host, port=args.port, web=web_cfg,
node_config_path=args.node)
node_config_path=args.node, channel_names=channel_names)
await app.server.start()
web_server = None
if web_cfg is not None:
+27 -2
View File
@@ -119,12 +119,17 @@ class VirtualNode:
"""A configured bus coupler with its I/O modules.
Computes the WAGO process-image layout for a module list and binds
it to a :class:`WagoDatastore`.
it to a :class:`WagoDatastore`. Optional per-channel application
names (spec FR-10.2.11) live here — not on ModuleType, whose
catalog objects are shared per article — keyed by I/O code
("DI0.0", "AI4.1", ...).
"""
def __init__(self, datastore: WagoDatastore, modules: list[ModuleType]) -> None:
def __init__(self, datastore: WagoDatastore, modules: list[ModuleType],
channel_names: dict[str, str] | None = None) -> None:
self.datastore = datastore
self.modules = modules
self.channel_names: dict[str, str] = dict(channel_names or {})
self._di_channels: list[ChannelLocation] = []
self._do_channels: list[ChannelLocation] = []
self._ai_channels: list[ChannelLocation] = []
@@ -188,6 +193,26 @@ class VirtualNode:
"""Text for firmware-info register 0x2030 (module description)."""
return ", ".join(f"{i}:{m.article}" for i, m in enumerate(self.modules))
# -- 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 channel_name(self, kind: str, module: int, channel: int) -> str | None:
"""Configured application name for a channel, or None."""
return self.channel_names.get(self.io_code(kind, module, channel))
def validate_channel_names(self) -> list[str]:
"""Names whose I/O code does not exist on this node (after a
module-list change, stale entries are dropped on save)."""
known: set[str] = set()
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))
return sorted(set(self.channel_names) - known)
# -- named channel access (scenario engine API) -----------------------------
@property
+21
View File
@@ -52,6 +52,27 @@ def load_node_modules(node_config: dict) -> list[ModuleType]:
return modules
def load_channel_names(node_config: dict) -> dict[str, str]:
"""Per-channel application names from the node configuration.
Shape (FR-10.2.11)::
channel_names:
DI0.0: start_button
AI4.0: tank_level
Keys are I/O codes (module-based addressing per FR-4.3.11). No
validation against the module list here — that happens when the
names are bound to a VirtualNode.
"""
raw = node_config.get("channel_names") or {}
if not isinstance(raw, dict):
raise ScenarioError("channel_names must be a mapping of I/O code -> name")
return {str(code).strip().upper(): str(name).strip()
for code, name in raw.items()
if str(name).strip()}
def validate_scenario(scenario: dict) -> list[str]:
"""Structural validation; returns a list of problems (empty == valid)."""
problems: list[str] = []
+192 -1
View File
@@ -8,6 +8,7 @@ All handlers touch the simulator's internal memory handlers only —
from __future__ import annotations
import asyncio
import re
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
@@ -397,7 +398,8 @@ async def put_module_config(
if payload.get("persist", True) and sim.node_config_path:
try:
save_node_config(sim.node_config_path, modules,
name=Path(sim.node_config_path).stem)
name=Path(sim.node_config_path).stem,
channel_names=sim.node.channel_names)
except OSError as exc:
raise HTTPException(
status_code=500,
@@ -411,6 +413,195 @@ async def put_module_config(
}
# -- channel names (FR-10.2.11) ----------------------------------------------
_NAME_CODE = re.compile(r"^(DI|DO|AI|AO)(\d+)\.(\d+)$")
@router.get("/api/config/channel-names")
async def get_channel_names(
sim: SimulatorApp = Depends(get_sim),
_: None = Depends(require_session),
) -> dict:
return {"channel_names": dict(sim.node.channel_names)}
@router.put("/api/config/channel-names")
async def put_channel_names(
payload: dict,
request: Request,
sim: SimulatorApp = Depends(get_sim),
sessions: SessionStore = Depends(get_sessions),
) -> dict:
"""Set the full channel-name mapping (empty name clears an entry).
Codes are validated against the current module layout; unknown
codes are rejected so typos don't linger invisibly. Passing
`persist: true` (default when a node config path exists) also
writes the YAML.
"""
_require_write(request, sessions)
raw = payload.get("channel_names")
if not isinstance(raw, dict):
raise HTTPException(status_code=400, detail="'channel_names' must be a mapping")
names: dict[str, str] = {}
for code, name in raw.items():
code_up = str(code).strip().upper()
if not _NAME_CODE.match(code_up):
raise HTTPException(
status_code=400,
detail=f"invalid I/O code {code!r} (expected e.g. DI0.0)",
)
name_s = str(name).strip()
if name_s:
names[code_up] = name_s
stale = sim.node.validate_channel_names() # against the NEW mapping
# replace the mapping on the live node
sim.node.channel_names = names
stale_now = sim.node.validate_channel_names()
if payload.get("persist", True) and sim.node_config_path:
from .persist import save_node_config
try:
save_node_config(
sim.node_config_path, sim.node.modules,
name=Path(sim.node_config_path).stem,
channel_names=names,
)
except OSError as exc:
raise HTTPException(
status_code=500,
detail=f"names applied, but saving {sim.node_config_path} failed: {exc}",
) from None
return {"ok": True, "channel_names": names, "unknown_codes": stale_now}
# -- configuration import/export (FR-10.2.12) ---------------------------------
def _coupler_snapshot(sim: SimulatorApp) -> dict:
r = sim.registers
wd = r.read_config(0x1000, 12)
return {
"watchdog": {
"time_units": wd[0],
"mask_fc_1_16": wd[1],
"mask_fc_17_32": wd[2],
"close_on_timeout": wd[9],
"type": wd[10],
},
"response_delay_ms": r.read_config(0x1037, 1)[0],
"idle_timeout_units": r.read_config(0x1030, 1)[0],
}
@router.get("/api/config/export")
async def export_config(
sim: SimulatorApp = Depends(get_sim),
_: None = Depends(require_session),
) -> dict:
"""Complete configuration as one JSON payload (downloadable)."""
return {
"format": "wago-sim-config",
"version": 1,
"coupler": "750-362",
"modules": [m.article for m in sim.node.modules],
"channel_names": dict(sim.node.channel_names),
"coupler_settings": _coupler_snapshot(sim),
}
@router.post("/api/config/import")
async def import_config(
payload: dict,
request: Request,
sim: SimulatorApp = Depends(get_sim),
sessions: SessionStore = Depends(get_sessions),
) -> dict:
"""Atomically apply an exported configuration: module rebuild +
channel names + coupler settings, then persist (like SAVE CONFIG).
Import is all-or-nothing: validation happens before any change is
applied; on the first problem nothing is touched.
"""
_require_write(request, sessions)
if payload.get("format") != "wago-sim-config":
raise HTTPException(status_code=400, detail="not a wago-sim configuration")
articles = payload.get("modules")
if not isinstance(articles, list) or not articles:
raise HTTPException(status_code=400, detail="'modules' must be a non-empty list")
if len(articles) > 64:
raise HTTPException(status_code=400, detail="too many modules (max 64)")
try:
modules = [resolve_module(str(a)) for a in articles]
except ModuleUnknown as exc:
raise HTTPException(status_code=400, detail=str(exc)) from None
names_raw = payload.get("channel_names") or {}
if not isinstance(names_raw, dict):
raise HTTPException(status_code=400, detail="'channel_names' must be a mapping")
names: dict[str, str] = {}
for code, name in names_raw.items():
code_up = str(code).strip().upper()
if not _NAME_CODE.match(code_up):
raise HTTPException(status_code=400,
detail=f"invalid I/O code {code!r}")
if str(name).strip():
names[code_up] = str(name).strip()
coupler = payload.get("coupler_settings") or {}
# -- everything validated: apply atomically -------------------------
rebuild_node(sim, modules, channel_names=names)
stale = sim.node.validate_channel_names()
if stale:
# codes that don't exist on the imported module list are dropped
for code in stale:
names.pop(code, None)
rebuild_node(sim, modules, channel_names=names)
errors: list[str] = []
wd = coupler.get("watchdog") or {}
writes: list[tuple[int, int]] = []
if "time_units" in wd:
writes.append((0x1000, int(wd["time_units"])))
if "mask_fc_1_16" in wd:
writes.append((0x1001, int(wd["mask_fc_1_16"])))
if "mask_fc_17_32" in wd:
writes.append((0x1002, int(wd["mask_fc_17_32"])))
if "close_on_timeout" in wd:
writes.append((0x1009, 1 if wd["close_on_timeout"] else 0))
if "type" in wd:
writes.append((0x100A, 1 if wd["type"] in (1, "alternative") else 0))
if "response_delay_ms" in coupler:
writes.append((0x1037, int(coupler["response_delay_ms"])))
if "idle_timeout_units" in coupler:
writes.append((0x1030, int(coupler["idle_timeout_units"])))
for address, value in writes:
try:
sim.registers.write_config(address, [value])
except RegisterWriteRefused as exc:
errors.append(f"{address:#06x}: {exc}")
if payload.get("persist", True) and sim.node_config_path:
try:
save_node_config(
sim.node_config_path, modules,
name=Path(sim.node_config_path).stem,
channel_names=names,
)
except OSError as exc:
raise HTTPException(
status_code=500,
detail=f"imported, but saving {sim.node_config_path} failed: {exc}",
) from None
return {
"ok": True,
"modules": [m.article for m in modules],
"channel_names": names,
"coupler_writes_applied": len(writes) - len(errors),
"coupler_write_errors": errors,
"dropped_codes": stale,
"saved_to": sim.node_config_path if payload.get("persist", True) else None,
}
@router.websocket("/api/ws")
async def ws_values(websocket: WebSocket) -> None:
sim: SimulatorApp = websocket.app.state.sim
+3 -1
View File
@@ -11,6 +11,7 @@ class ChannelJSON(TypedDict):
kind: str
address: str
modbus: str
channel_name: str | None
class ModuleJSON(TypedDict):
@@ -37,4 +38,5 @@ class ChannelValue(TypedDict):
kind: str
module: int
channel: int
value: bool | int
value: bool | int
name: str | None
+20 -5
View File
@@ -1,8 +1,9 @@
"""Node-config persistence for the web UI (spec §11, FR-11.5).
Writes the module list back to the node-config YAML. A small custom
emitter is used instead of `yaml.dump` so the file stays human-readable
with a header comment — bench engineers edit this file by hand.
Writes the module list (and, since FR-10.2.11, per-channel names)
back to the node-config YAML. A small custom emitter is used instead
of `yaml.dump` so the file stays human-readable with a header comment
— bench engineers edit this file by hand.
"""
from __future__ import annotations
@@ -21,12 +22,26 @@ coupler: 750-362
modules:
"""
_NAMES_HEADER = """\
# Application names for module channels (I/O code -> name)
channel_names:
"""
def save_node_config(
path: str | Path, modules: list[ModuleType], *, name: str = "node"
path: str | Path,
modules: list[ModuleType],
*,
name: str = "node",
channel_names: dict[str, str] | None = None,
) -> None:
"""Write the module list to the node-config YAML (custom emitter)."""
"""Write the module list + channel names to the node-config YAML."""
lines = [_HEADER.format(name=name)]
for m in modules:
lines.append(f' - "{m.article}" # {m.description}\n')
if channel_names:
lines.append(_NAMES_HEADER)
for code in sorted(channel_names):
lines.append(f" {code}: {channel_names[code]}\n")
Path(path).write_text("".join(lines), encoding="utf-8", newline="\n")
+11 -1
View File
@@ -21,12 +21,14 @@ def node_layout(node: VirtualNode) -> NodeLayoutJSON:
# 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}"
channels.append(
{
"name": ch.name,
"kind": ch.kind,
"address": f"{kind_upper}{index}.{channel_no}",
"address": io_code,
"modbus": _modbus_address(node, ch.kind, index, channel_no),
"channel_name": node.channel_names.get(io_code),
}
)
modules_json.append(
@@ -70,40 +72,48 @@ def channel_values(node: VirtualNode) -> list[ChannelValue]:
# Digital channels: global 1-based numbers, bit-packed after analog.
for i, loc in enumerate(node.di, start=1):
code = node.io_code("di", loc.module_index, loc.channel - 1)
out.append(
{
"kind": "di",
"module": loc.module_index,
"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)
out.append(
{
"kind": "do",
"module": loc.module_index,
"channel": loc.channel - 1,
"value": ds.digital_output_channel(i),
"name": node.channel_names.get(code),
}
)
# Analog channels: word indices in the process image.
for loc in node.ai:
code = node.io_code("ai", loc.module_index, loc.channel - 1)
out.append(
{
"kind": "ai",
"module": loc.module_index,
"channel": loc.channel - 1,
"value": 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)
out.append(
{
"kind": "ao",
"module": loc.module_index,
"channel": loc.channel - 1,
"value": ds.get_output_word(loc.word),
"name": node.channel_names.get(code),
}
)
return out
+161
View File
@@ -26,6 +26,7 @@ async function init() {
NODE = await api("/api/node");
const diag = await api("/api/diagnostics").catch(() => null);
renderRail();
renderChannelList();
if (diag) renderDiagnostics(diag);
openWS();
}
@@ -44,7 +45,158 @@ function openWS() {
function applyValues(values) {
for (const v of values) {
VALUES[key(v.kind, v.module, v.channel)] = v.value;
if (v.name !== undefined) NAMES[key(v.kind, v.module, v.channel)] = v.name;
updateChannel(v.kind, v.module, v.channel, v.value);
updateListRow(v.kind, v.module, v.channel, v.value, v.name);
}
}
// ---------------- channel list (FR-10.2.13) ----------------
let NAMES = {}; // "kind:module:channel" -> configured name
function updateListRow(kind, module, channel, value, name) {
const row = document.getElementById(`row-${kind}-${module}-${channel}`);
if (!row) return;
const ctl = row.querySelector(".row-value");
if (kind === "di" || kind === "do") {
ctl.checked = !!value;
} else {
if (document.activeElement !== ctl) ctl.value = value;
}
const nameEl = row.querySelector(".row-name");
if (nameEl && name !== undefined) nameEl.textContent = name || "—";
}
function renderChannelList() {
const list = document.getElementById("chlist");
if (!NODE) return;
let html = "";
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 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)">
<span class="slider"></span></label>`;
} else {
control = `<input type="number" class="row-value num" min="0" max="65535"
id="val-${ch.kind}-${mod.index}-${no}"
onchange="listWrite('${ch.kind}', ${mod.index}, ${no}, this)">`;
}
html += `<div class="chlist-row" id="${id}" title="${ch.name} · MB ${ch.modbus}">
<span class="row-io">${ch.address}</span>
${named}
${control}
</div>`;
}
}
list.innerHTML = html;
}
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};
try {
await api(`/api/channels/${kind}/${module}/${channel}`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload),
});
const res = await api("/api/values");
applyValues(res.values);
} catch (e) {
console.warn("write rejected:", e.message);
const res = await api("/api/values").catch(() => null);
if (res) applyValues(res.values);
}
}
// ---------------- channel names (FR-10.2.11) ----------------
function renderNameEditor() {
const editor = document.getElementById("name-editor");
if (!NODE || !editor) return;
let html = "";
for (const mod of NODE.modules) {
for (const ch of mod.channels) {
html += `<label class="name-row"><span class="row-io">${ch.address}</span>
<input type="text" class="name-input" data-code="${ch.address}"
value="${ch.channel_name || ""}"
placeholder="${ch.name}"></label>`;
}
}
editor.innerHTML = html;
}
async function saveChannelNames() {
const msg = document.getElementById("name-msg");
msg.classList.add("hidden");
const names = {};
document.querySelectorAll(".name-input").forEach((el) => {
if (el.value.trim()) names[el.dataset.code] = el.value.trim();
});
try {
await api("/api/config/channel-names", {
method: "PUT",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({channel_names: names}),
});
msg.textContent = "Saved.";
msg.classList.remove("hidden", "error");
msg.classList.add("ok");
NODE = null; // re-fetch layout with names
await init();
await renderConfig();
} catch (e) {
msg.textContent = e.message;
msg.classList.remove("hidden", "ok");
msg.classList.add("error");
}
}
// ---------------- import / export (FR-10.2.12) ----------------
async function exportConfig() {
const data = await api("/api/config/export");
const blob = new Blob([JSON.stringify(data, null, 2)], {type: "application/json"});
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "wago-sim-config.json";
a.click();
URL.revokeObjectURL(a.href);
}
async function importConfig(file) {
const msg = document.getElementById("io-msg");
msg.classList.add("hidden");
try {
const text = await file.text();
const payload = JSON.parse(text);
const r = await api("/api/config/import", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload),
});
msg.textContent = `Imported ${r.modules.length} modules` +
(r.dropped_codes && r.dropped_codes.length
? ` (dropped ${r.dropped_codes.length} stale name codes)` : "");
msg.classList.remove("hidden", "error");
msg.classList.add("ok");
NODE = null;
await init();
await renderConfig();
} catch (e) {
msg.textContent = e.message;
msg.classList.remove("hidden", "ok");
msg.classList.add("error");
}
}
@@ -230,6 +382,7 @@ async function renderConfig() {
const mc = await api("/api/config/modules");
if (!mc) return;
renderNameEditor();
const list = document.getElementById("module-list");
list.innerHTML = mc.modules.map((m, i) => `
<label class="module-row">
@@ -313,6 +466,14 @@ async function saveModules() {
document.addEventListener("DOMContentLoaded", () => {
document.getElementById("coupler-save").addEventListener("click", saveCoupler);
document.getElementById("names-save").addEventListener("click", saveChannelNames);
document.getElementById("cfg-export").addEventListener("click", exportConfig);
document.getElementById("cfg-import").addEventListener("click", () =>
document.getElementById("cfg-file").click());
document.getElementById("cfg-file").addEventListener("change", (ev) => {
if (ev.target.files.length) importConfig(ev.target.files[0]);
ev.target.value = "";
});
document.getElementById("module-add").addEventListener("click", () => {
MODULE_ARTICLES.push(document.getElementById("catalog-pick").value);
renderModuleRows();
+24
View File
@@ -32,6 +32,12 @@
<h2>Node overview</h2>
<div id="rail" class="rail"></div>
<div id="rail-detail" class="rail-detail"></div>
<h3 class="list-title">Channels</h3>
<div class="chlist-head">
<span>I/O</span><span>Name</span><span>Value</span>
</div>
<div id="chlist" class="chlist"></div>
</section>
<!-- ============ Configuration ============ -->
@@ -63,6 +69,24 @@
<button id="module-save" class="primary">SAVE CONFIG</button>
<span id="module-path" class="cfg-path"></span>
</div>
<h4>Channel names</h4>
<p class="cfg-note">Rename channels for the bench (I/O code →
application name). Names show in the overview list and tooltips
and persist with the node config.</p>
<div id="name-editor" class="name-editor"></div>
<div id="name-msg" class="error hidden"></div>
<div class="fault-buttons">
<button id="names-save">Save channel names</button>
</div>
<h4>Import / export</h4>
<p class="cfg-note">The complete configuration (modules, channel
names, coupler settings) as one JSON file.</p>
<div class="fault-buttons">
<button id="cfg-export">Export configuration</button>
<input type="file" id="cfg-file" accept=".json" class="hidden-file">
<button id="cfg-import">Import configuration…</button>
</div>
<div id="io-msg" class="error hidden"></div>
</div>
</div>
</section>
+65 -1
View File
@@ -185,4 +185,68 @@ h2 { margin: 4px 0 14px; }
#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; }
button.primary:hover { background: #1b5e20; }
button.primary:hover { background: #1b5e20; }
/* ---------- channel list (FR-10.2.13) ---------- */
.list-title { margin: 22px 0 8px; }
.chlist-head, .chlist-row {
display: grid;
grid-template-columns: 70px 1fr 90px;
gap: 10px;
align-items: center;
}
.chlist-head {
font-size: 0.72rem; text-transform: uppercase; color: #6b7684;
letter-spacing: 0.06em;
padding: 0 10px 4px;
}
.chlist {
max-height: 420px;
overflow-y: auto;
border: 1px solid #d8dee5;
border-radius: 6px;
background: #fff;
}
.chlist-row { padding: 4px 10px; border-bottom: 1px solid #eef1f4; }
.chlist-row:last-child { border-bottom: 0; }
.chlist-row:hover { background: #f2f6fa; }
.row-io {
font-family: Consolas, monospace; font-size: 0.82rem;
color: #004a99; font-weight: 600;
}
.row-name { font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; }
.row-name.unnamed { color: #9aa4ae; }
.row-value.num {
width: 100%; padding: 4px 6px; border: 1px solid #c5ccd4;
border-radius: 4px; font-family: Consolas, monospace; font-size: 0.85rem;
}
/* toggle switch */
.switch { position: relative; display: inline-block; width: 40px; height: 22px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute; inset: 0; background: #b9c2cb;
border-radius: 22px; transition: 0.15s; cursor: pointer;
}
.slider::before {
content: ""; position: absolute; width: 16px; height: 16px;
left: 3px; top: 3px; background: #fff; border-radius: 50%;
transition: 0.15s;
}
.switch input:checked + .slider { background: #37b34a; }
.switch input:checked + .slider::before { transform: translateX(18px); }
/* ---------- channel-name editor ---------- */
.name-editor { max-height: 300px; overflow-y: auto; margin-bottom: 10px; }
.name-row {
display: grid; grid-template-columns: 70px 1fr; gap: 8px;
align-items: center; padding: 2px 0;
}
.name-input {
padding: 5px 8px; border: 1px solid #c5ccd4; border-radius: 4px;
font-size: 0.85rem; width: 100%;
}
.hidden-file { display: none; }
#cfg-io-buttons { display: flex; gap: 8px; margin-top: 8px; }