summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-09-20 22:30:13 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-09-20 22:30:13 -0600
commit006366ef8099c8313ae596bf6267e9f98dfbeb5e (patch)
treecc3f76db630c21ec6422a6fd4f3053903a47d186
parentbb8be051ffffa56a01093c9f3aaa80e84748568f (diff)
Make Channel the single source of truth for a reading's name
Device tab stays hardware-only (pins, wiring, may vary per workstation); the Channels window owns the profile-facing identity. Concretely: - Fix a real regression: SerialDevice generic-format Apply & Reconnect nulled out custom channel_names/units every time (no fallback, unlike every other optional field in reconfigure()), silently reverting to sig0/sig1/... ids. - Plot legend now live-tracks the current Signal/Channel name instead of freezing tr.label at add-time, so a rename shows up without re-adding the trace. - Add an inline quick-reconnect (combo + button, pre-selects the actual current source) directly on each Channel block in the Channels window, for the common single-source case — rewiring hardware no longer means touching every Plot/pipeline that referenced it. - Collapse the Channel ID field behind an "ID ▸" advanced toggle; channel_id is already auto-derived from the name (_slug_id) and rarely needs to be seen. - Devices tab's Signal "Name" column relabeled "Label" with a tooltip — de-emphasized now that Channel name is the real identity. - Move "create a Channel from this Signal" from a column on the Devices tab's signal table to a ⊕ button on each Signal row in the Channels tab's Namespace panel — the action now lives where the identity is created, not on the hardware tab. Only that instance opts in (on_create_channel); the read-only Namespace dock on the main window is unaffected. - Floor the Namespace panel's width at its columns' natural size so dragging the Channels-tab splitter narrower can't push the ⊕ column behind the horizontal scrollbar. - Breadcrumb log when a profile-saved Signal override can't be applied because the signal_id no longer exists. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
-rw-r--r--core/profile.py2
-rw-r--r--devices/serial_device.py4
-rw-r--r--ui/namespace_panel.py55
-rw-r--r--ui/strip_chart.py15
-rw-r--r--ui/windows/channels_window.py114
-rw-r--r--ui/windows/config_window.py93
-rw-r--r--ui/windows/devices_window.py8
7 files changed, 214 insertions, 77 deletions
diff --git a/core/profile.py b/core/profile.py
index c77e646..da0f478 100644
--- a/core/profile.py
+++ b/core/profile.py
@@ -393,6 +393,8 @@ class ProfileManager:
continue
ch = dev.get_signal(ch_data["signal_id"])
if not ch:
+ print(f"[Profile] '{ch_data['device_id']}': signal '{ch_data['signal_id']}' "
+ f"no longer exists — saved override for '{ch_data.get('name', '?')}' dropped")
continue
ch.name = ch_data.get("name", ch.name)
ch.unit = ch_data.get("unit", ch.unit)
diff --git a/devices/serial_device.py b/devices/serial_device.py
index 53f9275..fe95dfe 100644
--- a/devices/serial_device.py
+++ b/devices/serial_device.py
@@ -265,8 +265,8 @@ class SerialDevice(BaseDevice):
self._fmt = fmt
self.simulate = simulate
self._num_channels = num_channels
- self._chan_names = channel_names
- self._units = units
+ self._chan_names = channel_names or self._chan_names
+ self._units = units or self._units
self._scpi_channels = scpi_channels or self._scpi_channels
self._slave_addr = slave_addr
self._parity = parity
diff --git a/ui/namespace_panel.py b/ui/namespace_panel.py
index 9616db7..e2b8809 100644
--- a/ui/namespace_panel.py
+++ b/ui/namespace_panel.py
@@ -5,19 +5,24 @@ NAMESPACE panel — dockable, live browser of everything referenceable in a
script: Signals (raw device endpoints), Channels (processed/derived
values), and vars (the shared dict bridging Control Panel On Action
Scripts and Channel scripts). Read-only reference, not an editor — editing
-still happens in Configuration > Channels.
+still happens in Configuration > Channels — with one narrow exception:
+when constructed with `on_create_channel`, each Signal row gets a ⊕
+button to create a passthrough Channel from it directly. Only
+ConfigWindow's Channels-tab instance opts into this (config_window.py);
+the dockable instance on the main window (ui/main_window.py) does not
+pass a callback, so it stays exactly the plain read-only reference.
Docked opposite the Control Panel (ui/main_window.py), built the same way:
a QDockWidget you can drag, float, redock, or close.
"""
from __future__ import annotations
-from typing import Dict, Tuple
+from typing import Callable, Dict, Optional, Tuple
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView,
- QApplication,
+ QToolButton, QApplication,
)
from PyQt6.QtCore import QTimer, pyqtSlot
from PyQt6.QtGui import QColor
@@ -33,6 +38,7 @@ _C_NAME = 2
_C_DIR = 3
_C_VALUE = 4
_C_SOURCE = 5
+_C_CREATE = 6 # only present when on_create_channel is set
def _fmt_value(v) -> str:
@@ -54,10 +60,14 @@ def _fmt_ref(dev_id: str, ch_id: str) -> str:
class NamespacePanel(QWidget):
- def __init__(self, registry: DeviceRegistry, processor: SignalProcessor):
+ def __init__(self, registry: DeviceRegistry, processor: SignalProcessor,
+ on_create_channel: Optional[Callable[[str, str, str, str, str], None]] = None):
super().__init__()
self.registry = registry
self.processor = processor
+ # (dev_id, ch_id, name, unit, color) -> None, called when a Signal
+ # row's ⊕ button is clicked. None hides the column entirely.
+ self.on_create_channel = on_create_channel
# row key -> row index. Keys:
# ("signal", dev_id, ch_id) | ("channel", channel_id) | ("var", name)
self._row_index: Dict[Tuple, int] = {}
@@ -84,10 +94,11 @@ class NamespacePanel(QWidget):
self._table = QTableWidget()
self._table.setObjectName("channelTable")
- self._table.setColumnCount(6)
- self._table.setHorizontalHeaderLabels(
- ["Kind", "ID", "Name", "Dir", "Value", "Source"]
- )
+ labels = ["Kind", "ID", "Name", "Dir", "Value", "Source"]
+ if self.on_create_channel:
+ labels.append("")
+ self._table.setColumnCount(len(labels))
+ self._table.setHorizontalHeaderLabels(labels)
hdr = self._table.horizontalHeader()
hdr.setSectionResizeMode(_C_SOURCE, QHeaderView.ResizeMode.Stretch)
hdr.setSectionResizeMode(_C_KIND, QHeaderView.ResizeMode.ResizeToContents)
@@ -95,6 +106,8 @@ class NamespacePanel(QWidget):
hdr.setSectionResizeMode(_C_NAME, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(_C_DIR, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(_C_VALUE, QHeaderView.ResizeMode.ResizeToContents)
+ if self.on_create_channel:
+ hdr.setSectionResizeMode(_C_CREATE, QHeaderView.ResizeMode.ResizeToContents)
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self._table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self._table.setAlternatingRowColors(True)
@@ -102,6 +115,17 @@ class NamespacePanel(QWidget):
self._table.cellDoubleClicked.connect(self._on_double_clicked)
lay.addWidget(self._table)
+ def _apply_min_width(self):
+ """Floor the panel's width at the sum of its columns' natural sizes
+ (Source gets a fixed placeholder rather than its live stretched
+ width, which would be circular) — so dragging the Channels-tab
+ splitter divider narrower can never push the ⊕ create-channel
+ column (or any other column) behind the horizontal scrollbar."""
+ total = 0
+ for i in range(self._table.columnCount()):
+ total += 80 if i == _C_SOURCE else self._table.columnWidth(i)
+ self.setMinimumWidth(total + 24)
+
# ── Row-set (structural) rebuild — devices/channels/vars added or removed ──
def _desired_keys(self):
@@ -125,9 +149,13 @@ class NamespacePanel(QWidget):
for row, key in enumerate(keys):
self._row_index[key] = row
self._paint_row(row, key)
+ self._apply_min_width()
def _paint_row(self, row: int, key: Tuple):
kind = key[0]
+ if self.on_create_channel:
+ self._table.removeCellWidget(row, _C_CREATE) # row's kind may have
+ # changed since last paint
if kind == "signal":
_, dev_id, ch_id = key
dev = self.registry.get_instance(dev_id)
@@ -136,6 +164,17 @@ class NamespacePanel(QWidget):
is_wr = bool(ch and ch.writable)
self._set_row(row, "Signal", ch_id, name, "WRITE" if is_wr else "READ",
None, _fmt_ref(dev_id, ch_id))
+ if self.on_create_channel and ch is not None:
+ btn = QToolButton()
+ btn.setText("⊕")
+ btn.setObjectName("addTraceBtn")
+ btn.setFixedSize(24, 22)
+ btn.setToolTip("Create a Channel from this Signal")
+ btn.clicked.connect(
+ lambda _=False, d=dev_id, c=ch_id, n=ch.name, u=ch.unit, col=ch.color:
+ self.on_create_channel(d, c, n, u, col)
+ )
+ self._table.setCellWidget(row, _C_CREATE, btn)
elif kind == "channel":
_, channel_id = key
dc = next((d for d in self.processor.get_derived()
diff --git a/ui/strip_chart.py b/ui/strip_chart.py
index b295a8c..e77c092 100644
--- a/ui/strip_chart.py
+++ b/ui/strip_chart.py
@@ -180,7 +180,7 @@ class StripChartWidget(QWidget):
pen = pg.mkPen(color="#6b7280" if missing else tr.color, width=tr.width,
style=Qt.PenStyle.DashLine if missing
else _STYLES.get(tr.style, Qt.PenStyle.SolidLine))
- label = tr.label or tr.channel_id
+ label = tr.label or self._lookup_channel_name(tr.device_id, tr.channel_id)
curve = plot.plot([], [], pen=pen,
name=f"{label} (missing)" if missing else label)
key = (tr.device_id, tr.channel_id)
@@ -216,6 +216,19 @@ class StripChartWidget(QWidget):
dev = self.registry.get_instance(device_id)
return dev is None or dev.get_signal(channel_id) is None
+ def _lookup_channel_name(self, device_id: str, channel_id: str) -> str:
+ """Live display name for a trace with no user-typed label, so a
+ Signal/Channel rename shows up on the chart without re-adding the
+ trace — mirrors _lookup_channel_unit()."""
+ if device_id == "derived":
+ for dc in self.processor.get_derived():
+ if dc.channel_id == channel_id:
+ return dc.name
+ return channel_id
+ dev = self.registry.get_instance(device_id)
+ ch = dev.get_signal(channel_id) if dev else None
+ return (ch.name if ch and ch.name else channel_id)
+
def _lookup_channel_unit(self, device_id: str, channel_id: str) -> str:
if device_id == "derived":
for dc in self.processor.get_derived():
diff --git a/ui/windows/channels_window.py b/ui/windows/channels_window.py
index 31d1458..cc83b3b 100644
--- a/ui/windows/channels_window.py
+++ b/ui/windows/channels_window.py
@@ -431,8 +431,18 @@ class PipelineTab(QWidget):
phys_bar = QWidget(); phys_bar.setObjectName("cfgGlobalBar")
pb_lay = QHBoxLayout(phys_bar); pb_lay.setContentsMargins(10, 7, 10, 7); pb_lay.setSpacing(6)
pb_lbl = QLabel("Signals"); pb_lbl.setObjectName("devWindowTitle")
- pb_lay.addWidget(pb_lbl, 1)
+ pb_lay.addWidget(pb_lbl)
+ self._src_cb = _channel_combo(self.registry, self.processor, include_derived=True)
+ self._src_cb.setObjectName("channelPickerCb")
+ self._src_cb.insertItem(0, "Signal: none (empty channel)", userData=None)
+ self._src_cb.setCurrentIndex(0)
+ pb_lay.addWidget(self._src_cb, 1)
+ add_virt = QPushButton("+ New Channel"); add_virt.setObjectName("addTraceBtn")
+ add_virt.setToolTip("Create a Channel from the selected Signal")
+ add_virt.clicked.connect(self._add_virtual)
+ pb_lay.addWidget(add_virt)
add_btn = QPushButton("+ Add"); add_btn.setObjectName("addTraceBtn")
+ add_btn.setToolTip("Add an existing Signal to this list")
add_btn.clicked.connect(self._on_add_channel)
pb_lay.addWidget(add_btn)
phys_l.addWidget(phys_bar)
@@ -458,15 +468,7 @@ class PipelineTab(QWidget):
virt_bar = QWidget(); virt_bar.setObjectName("cfgGlobalBar")
vb_lay = QHBoxLayout(virt_bar); vb_lay.setContentsMargins(10, 7, 10, 7); vb_lay.setSpacing(6)
vb_lbl = QLabel("Channels"); vb_lbl.setObjectName("devWindowTitle")
- vb_lay.addWidget(vb_lbl)
- self._src_cb = _channel_combo(self.registry, self.processor, include_derived=True)
- self._src_cb.setObjectName("channelPickerCb")
- self._src_cb.insertItem(0, "Signal: none (empty channel)", userData=None)
- self._src_cb.setCurrentIndex(0)
- vb_lay.addWidget(self._src_cb, 1)
- add_virt = QPushButton("+ Add Channel"); add_virt.setObjectName("addTraceBtn")
- add_virt.clicked.connect(self._add_virtual)
- vb_lay.addWidget(add_virt)
+ vb_lay.addWidget(vb_lbl, 1)
virt_l.addWidget(virt_bar)
div2 = QFrame(); div2.setFrameShape(QFrame.Shape.HLine)
@@ -861,15 +863,45 @@ class ChannelBlock(QFrame):
outer.addWidget(hdr)
hdr.mousePressEvent = lambda e: self._toggle()
+ # Subtitle — always visible (collapsed or expanded): what this
+ # Channel is currently wired to, plus a one-click rebind for the
+ # common single-source case. Lets a rewire be fixed (or a channel
+ # proactively repointed at a different Signal) without expanding
+ # the block or hunting for the per-source combo in the editor body.
+ sub = QWidget(); sub.setObjectName("plotBlockSubtitle")
+ sub_lay = QHBoxLayout(sub)
+ sub_lay.setContentsMargins(30, 0, 6, 4); sub_lay.setSpacing(6)
+ self._src_lbl = QLabel(""); self._src_lbl.setObjectName("traceSource")
+ sub_lay.addWidget(self._src_lbl, 1)
+ self._reconnect_cb = _channel_combo(self.registry, include_derived=False)
+ self._reconnect_cb.setFixedWidth(220)
+ sub_lay.addWidget(self._reconnect_cb)
+ self._reconnect_btn = QPushButton("↺ Reconnect")
+ self._reconnect_btn.setObjectName("addTraceBtn")
+ self._reconnect_btn.setToolTip("Point this channel at a different physical Signal")
+ self._reconnect_btn.clicked.connect(self._do_reconnect)
+ sub_lay.addWidget(self._reconnect_btn)
+ outer.addWidget(sub)
+
# Body
body = QWidget(); body.setObjectName("plotBlockSettings")
bl = QVBoxLayout(body); bl.setContentsMargins(10, 8, 10, 10); bl.setSpacing(6)
- # ID / unit row
+ # ID / unit row — the id is auto-derived from the name at creation
+ # (_slug_id()) and normally never needs to be seen, let alone typed;
+ # tucked behind a toggle so Name is the only identity field most
+ # users ever interact with.
meta = QHBoxLayout()
- meta.addWidget(QLabel("ID:"))
+ self._id_toggle = QToolButton(); self._id_toggle.setText("ID ▸")
+ self._id_toggle.setObjectName("traceRemoveBtn"); self._id_toggle.setCheckable(True)
+ self._id_toggle.setToolTip(f"Technical id: {self.dc.channel_id} — only needed to "
+ f"reference this channel from an expression/script by id, "
+ f"or to rename it. Click to edit.")
+ self._id_toggle.toggled.connect(self._toggle_id_box)
+ meta.addWidget(self._id_toggle)
self._id = QLineEdit(self.dc.channel_id)
self._id.setObjectName("traceLabel"); self._id.setFixedWidth(100)
+ self._id.setVisible(False)
self._id.textChanged.connect(lambda t: setattr(self.dc, "channel_id", t))
meta.addWidget(self._id)
meta.addWidget(QLabel("Unit:"))
@@ -1023,6 +1055,58 @@ class ChannelBlock(QFrame):
self._update_vars_hint()
self._update_visibility()
self.refresh_missing_sources()
+ self._refresh_source_subtitle()
+
+ def _refresh_source_subtitle(self):
+ """Update the always-visible '← wired-to' line and show/hide the
+ inline quick-reconnect control — only offered for the unambiguous
+ single-source case; multi-source channels use the per-source combos
+ in the expanded editor instead."""
+ names = [f"{_channel_display_name(self.registry, self.processor, d, c)} ({d}/{c})"
+ for d, c in self.dc.sources]
+ self._src_lbl.setText("← " + ", ".join(names) if names else "no source")
+ single = len(self.dc.sources) == 1
+ self._reconnect_cb.setVisible(single)
+ self._reconnect_btn.setVisible(single)
+ # Pre-select the actual current source — otherwise the combo always
+ # shows whatever landed first in registry iteration order, which
+ # looks like that's what's connected even when it isn't.
+ if single:
+ cur = self.dc.sources[0]
+ self._reconnect_cb.blockSignals(True)
+ for i in range(self._reconnect_cb.count()):
+ if self._reconnect_cb.itemData(i) == cur:
+ self._reconnect_cb.setCurrentIndex(i)
+ break
+ else:
+ self._reconnect_cb.setCurrentIndex(-1)
+ self._reconnect_cb.blockSignals(False)
+
+ def _do_reconnect(self):
+ """Inline one-click rebind: point this channel's sole source at
+ whatever Signal is currently selected in the quick-reconnect combo —
+ no need to expand the block or use the per-source editor combos."""
+ new_src = self._reconnect_cb.currentData()
+ if not new_src or len(self.dc.sources) != 1:
+ return
+ self.dc.sources = [new_src]
+ self.dc.source_names = [_channel_display_name(self.registry, self.processor, *new_src)]
+ if self._src_combos:
+ cb = self._src_combos[0]
+ for i in range(cb.count()):
+ if cb.itemData(i) == new_src:
+ cb.setCurrentIndex(i)
+ break
+ err = self.processor.add_derived(self.dc)
+ self._refresh_source_subtitle()
+ self.refresh_missing_sources()
+ if err:
+ self._st.setText(f"⚠ {err[:100]}")
+ self._st.setStyleSheet("color:#ef4444;")
+ else:
+ self._st.setText("✓ Reconnected")
+ self._st.setStyleSheet("color:#22c55e;")
+ self.applied.emit(self.dc)
def refresh_missing_sources(self):
"""Update the header warning icon for a source signal that no longer
@@ -1046,6 +1130,10 @@ class ChannelBlock(QFrame):
self._body.setVisible(self._expanded)
self._toggle_btn.setText("▼" if self._expanded else "▶")
+ def _toggle_id_box(self, checked: bool):
+ self._id.setVisible(checked)
+ self._id_toggle.setText("ID ▾" if checked else "ID ▸")
+
def _add_src(self, src=None):
row = QHBoxLayout()
cb = _channel_combo(self.registry, self.processor,
@@ -1247,6 +1335,8 @@ class ChannelBlock(QFrame):
elif self.dc.kind in ("function", "custom_script"):
self.dc.script = self._code.toPlainText()
err = self.processor.add_derived(self.dc)
+ self._refresh_source_subtitle()
+ self.refresh_missing_sources()
# Emitted regardless of whether this channel's own expression/script
# compiles — propagate_rename() (via id_renamed) still needs to run
# so other channels' sources / plot traces / controls don't dangle.
diff --git a/ui/windows/config_window.py b/ui/windows/config_window.py
index 62789f1..7ec0355 100644
--- a/ui/windows/config_window.py
+++ b/ui/windows/config_window.py
@@ -5,10 +5,15 @@ Unified Configuration window — Devices | Channels | Plot in one tabbed window.
Devices tab layout (2 columns):
Left — physical devices (add/configure/remove)
- Right — signal table (Device / Signal ID / Dir / On / Name / Unit / Min / Max)
+ Right — signal table (Device / Signal ID / Dir / On / Label / Unit / Min / Max)
Channels tab layout (2 columns):
- Left — Namespace browser (read-only reference: Signals / Channels / vars)
+ Left — Namespace browser (Signals / Channels / vars). Mostly a read-only
+ reference, with one exception: each Signal row has a ⊕ button to
+ create a passthrough Channel from it directly — the quick "make
+ this Signal a Channel" path. See ui/namespace_panel.py — only
+ this instance opts into that button (on_create_channel), not the
+ read-only dockable Namespace panel on the main window.
Right — derived / virtual channels — operations (filters/calibration/math/script)
are configured per Channel here, not on the Signals side.
"""
@@ -18,7 +23,7 @@ from copy import deepcopy
from typing import List, Optional, Set, Tuple
from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QToolButton,
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QScrollArea, QFrame, QTabWidget, QTableWidget, QTableWidgetItem,
QHeaderView, QCheckBox, QMessageBox, QAbstractItemView, QSplitter,
QDoubleSpinBox,
@@ -55,32 +60,31 @@ class _SignalsTab(SignalsTab):
_C_UNIT = 5
_C_MIN = 6
_C_MAX = 7
- _C_MKCH = 8 # "+ Channel" button
- channel_created = pyqtSignal()
channel_renamed = pyqtSignal(str, str, str, str) # old_dev, old_id, new_dev, new_id
- def __init__(self, registry: DeviceRegistry, processor: SignalProcessor):
- self._processor = processor
- super().__init__(registry)
-
def _build(self):
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
self._table = QTableWidget()
self._table.setObjectName("channelTable")
- self._table.setColumnCount(9)
+ self._table.setColumnCount(8)
self._table.setHorizontalHeaderLabels(
- ["Device", "Signal ID", "Dir", "On", "Name", "Unit", "Min", "Max", ""]
+ ["Device", "Signal ID", "Dir", "On", "Label", "Unit", "Min", "Max"]
)
+ name_hdr_item = self._table.horizontalHeaderItem(self._C_NAME)
+ if name_hdr_item is not None:
+ name_hdr_item.setToolTip(
+ "Technical label only — for the profile-facing name, create a Channel "
+ "in the Channels window."
+ )
hdr = self._table.horizontalHeader()
hdr.setSectionResizeMode(self._C_NAME, QHeaderView.ResizeMode.Stretch)
hdr.setSectionResizeMode(self._C_DEVICE, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(self._C_CH_ID, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(self._C_DIR, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(self._C_ENABLED, QHeaderView.ResizeMode.ResizeToContents)
- hdr.setSectionResizeMode(self._C_MKCH, QHeaderView.ResizeMode.ResizeToContents)
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self._table.setAlternatingRowColors(True)
self._table.verticalHeader().setVisible(False)
@@ -139,25 +143,6 @@ class _SignalsTab(SignalsTab):
self._table.setItem(row, self._C_MIN, QTableWidgetItem(str(ch.min_value)))
self._table.setItem(row, self._C_MAX, QTableWidgetItem(str(ch.max_value)))
- mkch_w = QWidget()
- mkch_l = QHBoxLayout(mkch_w)
- mkch_l.setContentsMargins(2, 0, 2, 0)
- mkch_l.setAlignment(Qt.AlignmentFlag.AlignCenter)
- mkch_btn = QToolButton()
- mkch_btn.setText("⊕")
- mkch_btn.setObjectName("addTraceBtn")
- mkch_btn.setFixedSize(24, 22)
- mkch_btn.setToolTip("Create a Channel from this Signal")
- d_cap, c_cap, n_cap, u_cap, col_cap = (
- dev.info.device_id, ch.signal_id, ch.name, ch.unit, ch.color
- )
- mkch_btn.clicked.connect(
- lambda _=False, d=d_cap, c=c_cap, n=n_cap, u=u_cap, col=col_cap:
- self._on_create_channel(d, c, n, u, col)
- )
- mkch_l.addWidget(mkch_btn)
- self._table.setCellWidget(row, self._C_MKCH, mkch_w)
-
row += 1
self._table.blockSignals(False)
@@ -205,25 +190,6 @@ class _SignalsTab(SignalsTab):
name_item.setData(Qt.ItemDataRole.UserRole, (dev_id, new_id))
self.channel_renamed.emit(dev_id, old_id, dev_id, new_id)
- def _on_create_channel(self, dev_id: str, ch_id: str, ch_name: str, unit: str, color: str):
- """Create a passthrough Channel (kind="none") from this Signal,
- named after it — the "1 Signal -> 1 Channel" quick path. No-ops if
- a passthrough Channel for this exact Signal already exists."""
- existing = self._processor.get_derived()
- for dc in existing:
- if dc.kind == "none" and dc.sources == [(dev_id, ch_id)]:
- return
- existing_names = {d.name for d in existing}
- existing_ids = {d.channel_id for d in existing}
- name = _dedupe_name(ch_name, existing_names)
- channel_id = _slug_id(name, existing_ids)
- dc = Channel(channel_id=channel_id, name=name, kind="none",
- sources=[(dev_id, ch_id)], source_names=[ch_name],
- unit=unit, color=color or "#00d4ff")
- err = self._processor.add_derived(dc)
- if err is None:
- self.channel_created.emit()
-
# ══════════════════════════════════════════════════════════════════════════════
# Plot tab — PlotWindow embedded without floating chrome
@@ -401,7 +367,7 @@ class ConfigWindow(QWidget):
sig_div = QFrame(); sig_div.setFrameShape(QFrame.Shape.HLine)
sig_div.setObjectName("devWindowDivider"); sig_l.addWidget(sig_div)
- self._signals_tab = _SignalsTab(self.registry, self.processor)
+ self._signals_tab = _SignalsTab(self.registry)
self._signals_tab.channel_visibility_changed.connect(self._on_sig_visibility)
self._signals_tab.channel_name_changed.connect(self._on_sig_name)
self._signals_tab.channel_unit_changed.connect(self._on_sig_unit)
@@ -505,7 +471,8 @@ class ConfigWindow(QWidget):
ns_div = QFrame(); ns_div.setFrameShape(QFrame.Shape.HLine)
ns_div.setObjectName("devWindowDivider"); ns_l.addWidget(ns_div)
- self._namespace_panel = NamespacePanel(self.registry, self.processor)
+ self._namespace_panel = NamespacePanel(self.registry, self.processor,
+ on_create_channel=self._on_create_channel)
self.processor.processed_data.connect(self._namespace_panel.on_processed_data)
ns_l.addWidget(self._namespace_panel, 1)
splitter.addWidget(ns_w)
@@ -521,7 +488,6 @@ class ConfigWindow(QWidget):
if inner_splitters:
inner_splitters[0].widget(0).setVisible(False)
splitter.addWidget(self._pipeline_tab)
- self._signals_tab.channel_created.connect(self._pipeline_tab.refresh_derived)
# Channels side larger by default
splitter.setStretchFactor(0, 1)
@@ -529,6 +495,27 @@ class ConfigWindow(QWidget):
self.tabs.addTab(w, " Channels ")
+ def _on_create_channel(self, dev_id: str, ch_id: str, ch_name: str, unit: str, color: str):
+ """Create a passthrough Channel (kind="none") from this Signal,
+ named after it — the "1 Signal -> 1 Channel" quick path, triggered
+ from the ⊕ button on a Signal row in the Channels tab's Namespace
+ panel (ui/namespace_panel.py). No-ops if a passthrough Channel for
+ this exact Signal already exists."""
+ existing = self.processor.get_derived()
+ for dc in existing:
+ if dc.kind == "none" and dc.sources == [(dev_id, ch_id)]:
+ return
+ existing_names = {d.name for d in existing}
+ existing_ids = {d.channel_id for d in existing}
+ name = _dedupe_name(ch_name, existing_names)
+ channel_id = _slug_id(name, existing_ids)
+ dc = Channel(channel_id=channel_id, name=name, kind="none",
+ sources=[(dev_id, ch_id)], source_names=[ch_name],
+ unit=unit, color=color or "#00d4ff")
+ err = self.processor.add_derived(dc)
+ if err is None:
+ self._pipeline_tab.refresh_derived()
+
def _on_sig_visibility(self, dev_id: str, ch_id: str, enabled: bool):
self._pipeline_tab.on_channel_enabled_changed(dev_id, ch_id, enabled)
self.channel_visibility_changed.emit(dev_id, ch_id, enabled)
diff --git a/ui/windows/devices_window.py b/ui/windows/devices_window.py
index 5d47681..15549f1 100644
--- a/ui/windows/devices_window.py
+++ b/ui/windows/devices_window.py
@@ -157,8 +157,14 @@ class SignalsTab(QWidget):
self._table.setObjectName("channelTable")
self._table.setColumnCount(6)
self._table.setHorizontalHeaderLabels(
- ["Device", "On", "Name", "Unit", "Min", "Max"]
+ ["Device", "On", "Label", "Unit", "Min", "Max"]
)
+ name_hdr_item = self._table.horizontalHeaderItem(self._C_NAME)
+ if name_hdr_item is not None:
+ name_hdr_item.setToolTip(
+ "Technical label only — for the profile-facing name, create a Channel "
+ "in the Channels window."
+ )
hdr = self._table.horizontalHeader()
hdr.setSectionResizeMode(self._C_NAME, QHeaderView.ResizeMode.Stretch)
hdr.setSectionResizeMode(self._C_DEVICE, QHeaderView.ResizeMode.ResizeToContents)