summaryrefslogtreecommitdiff
path: root/ui/windows
diff options
context:
space:
mode:
authorChristian Kolset <ckolset@colostate.edu>2026-08-10 16:12:05 -0600
committerChristian Kolset <ckolset@colostate.edu>2026-08-10 16:12:05 -0600
commit5d5bcaed8639a162fda85c8cd0d340c296a054b8 (patch)
treea7a1a62fb607c9c85127dab4aab17ccf8e22c906 /ui/windows
parent46231c00994cd681d598961f2056dbda31c3e483 (diff)
Unify Signals/Channels around auto-naming, plot-by-channel, and shared vars
Devices: auto-name from OS-reported port description (Device Manager / udev string) instead of a generic label; default Serial #Channels 1; consistent simulate-mode checkbox across backends; inline device rename. Channels: calibration/filters move out of Signals into a Channel "Operation" (adds Scale/Offset alongside the existing math kinds); single-physical-source Channels get an embedded Filters block; new Channels auto-name/auto-ID from their source and reject duplicate names/IDs on apply; "Create Channel" button on each Signal row; renamed "Sources" -> "Signals" in the Channels UI; Signals' READ/WRITE dir badge (was silently-broken IN/OUT) now reflects the real writable flag. Plot: default layout is one pane per Channel (not per device, not raw Signals) titled by channel name; "+ Add Channel" is a checkbox dialog instead of an inline dropdown; traces get up/down reorder; live unit changes (e.g. a Mark-10 gauge switching lb/kgF/N/ozF) propagate to an open pane's axis label without clobbering a manually-typed one. Control Panel: hardware assignment now picks from the same writable- Signals list instead of a separate device/channel combo with a hardcoded Arduino pin list; stale visual indicator when a control's target device/channel disconnects or disappears; documented the `vars` bridge (Control Panel On Action Script <-> Channel scripts) in both directions' help text and script templates. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat (limited to 'ui/windows')
-rw-r--r--ui/windows/channels_window.py305
-rw-r--r--ui/windows/config_window.py113
-rw-r--r--ui/windows/devices_window.py20
-rw-r--r--ui/windows/plot_window.py132
4 files changed, 406 insertions, 164 deletions
diff --git a/ui/windows/channels_window.py b/ui/windows/channels_window.py
index 38d477b..2388b83 100644
--- a/ui/windows/channels_window.py
+++ b/ui/windows/channels_window.py
@@ -215,7 +215,8 @@ class ChannelPipelineBlock(QFrame):
removed = pyqtSignal(object)
def __init__(self, dev_id: str, ch_id: str, ch_name: str, unit: str,
- processor: SignalProcessor):
+ processor: SignalProcessor, show_calibration: bool = True,
+ show_header: bool = True):
super().__init__()
self.dev_id = dev_id
self.ch_id = ch_id
@@ -224,6 +225,8 @@ class ChannelPipelineBlock(QFrame):
self._cal_rows: List[FilterRow] = []
self._cur_pipeline: Optional[ChannelPipeline] = None
self._expanded = False
+ self._show_calibration = show_calibration
+ self._show_header = show_header
self.setObjectName("plotBlock")
self._build(ch_name, unit)
self._load()
@@ -233,26 +236,28 @@ class ChannelPipelineBlock(QFrame):
self._unit = unit
outer = QVBoxLayout(self); outer.setContentsMargins(0, 0, 0, 0); outer.setSpacing(0)
- # Header
- hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32)
- hl = QHBoxLayout(hdr); hl.setContentsMargins(8, 0, 6, 0)
- title = f"{ch_name} ({self.dev_id}/{self.ch_id})"
- if unit:
- title += f" [{unit}]"
- self._title_lbl = QLabel(title); self._title_lbl.setObjectName("traceSource")
- hl.addWidget(self._title_lbl, 1)
-
- rm_btn = QToolButton(); rm_btn.setText("✕")
- rm_btn.setObjectName("traceRemoveBtn"); rm_btn.setFixedSize(22, 22)
- rm_btn.clicked.connect(lambda: self.removed.emit(self))
- hl.addWidget(rm_btn)
-
- self._toggle_btn = QToolButton(); self._toggle_btn.setText("▶")
- self._toggle_btn.setObjectName("traceRemoveBtn"); self._toggle_btn.setFixedSize(22, 22)
- self._toggle_btn.clicked.connect(self._toggle)
- hl.addWidget(self._toggle_btn)
- outer.addWidget(hdr)
- hdr.mousePressEvent = lambda e: self._toggle()
+ # Header (omitted when embedded in another block, e.g. DerivedBlock's
+ # Filters section — the parent already provides a header/toggle)
+ if self._show_header:
+ hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32)
+ hl = QHBoxLayout(hdr); hl.setContentsMargins(8, 0, 6, 0)
+ title = f"{ch_name} ({self.dev_id}/{self.ch_id})"
+ if unit:
+ title += f" [{unit}]"
+ self._title_lbl = QLabel(title); self._title_lbl.setObjectName("traceSource")
+ hl.addWidget(self._title_lbl, 1)
+
+ rm_btn = QToolButton(); rm_btn.setText("✕")
+ rm_btn.setObjectName("traceRemoveBtn"); rm_btn.setFixedSize(22, 22)
+ rm_btn.clicked.connect(lambda: self.removed.emit(self))
+ hl.addWidget(rm_btn)
+
+ self._toggle_btn = QToolButton(); self._toggle_btn.setText("▶")
+ self._toggle_btn.setObjectName("traceRemoveBtn"); self._toggle_btn.setFixedSize(22, 22)
+ self._toggle_btn.clicked.connect(self._toggle)
+ hl.addWidget(self._toggle_btn)
+ outer.addWidget(hdr)
+ hdr.mousePressEvent = lambda e: self._toggle()
# Body
self._body = QWidget(); self._body.setObjectName("plotBlockSettings")
@@ -281,21 +286,25 @@ class ChannelPipelineBlock(QFrame):
div.setObjectName("devWindowDivider")
bl.addWidget(div)
- # ── Calibration section ───────────────────────────────────────────────
- cal_lbl = QLabel("Calibration"); cal_lbl.setObjectName("pwmLabel")
- bl.addWidget(cal_lbl)
-
+ # ── Calibration section — hidden when embedded (calibration is now
+ # configured as a Channel "Scale/Offset" Operation instead), but the
+ # widgets still exist so a legacy scale_offset filter already saved
+ # on this pipeline (from an older profile) is preserved by _push()
+ # rather than silently dropped the next time a filter is edited.
self._cal_area = QWidget()
self._cal_lay = QVBoxLayout(self._cal_area)
self._cal_lay.setContentsMargins(0, 0, 0, 0); self._cal_lay.setSpacing(3)
- bl.addWidget(self._cal_area)
-
- cal_add_bar = QHBoxLayout()
- cal_add_btn = QPushButton("+ Add Calibration"); cal_add_btn.setObjectName("addTraceBtn")
- cal_add_btn.clicked.connect(self._add_calibrate)
- cal_add_bar.addWidget(cal_add_btn)
- cal_add_bar.addStretch()
- bl.addLayout(cal_add_bar)
+ if self._show_calibration:
+ cal_lbl = QLabel("Calibration"); cal_lbl.setObjectName("pwmLabel")
+ bl.addWidget(cal_lbl)
+ bl.addWidget(self._cal_area)
+
+ cal_add_bar = QHBoxLayout()
+ cal_add_btn = QPushButton("+ Add Calibration"); cal_add_btn.setObjectName("addTraceBtn")
+ cal_add_btn.clicked.connect(self._add_calibrate)
+ cal_add_bar.addWidget(cal_add_btn)
+ cal_add_bar.addStretch()
+ bl.addLayout(cal_add_bar)
ap_row = QHBoxLayout(); ap_row.addStretch()
ap_btn = QPushButton("✓ Apply"); ap_btn.setObjectName("applyButton")
@@ -304,9 +313,12 @@ class ChannelPipelineBlock(QFrame):
bl.addLayout(ap_row)
outer.addWidget(self._body)
- self._body.setVisible(False)
+ self._body.setVisible(self._show_header is False)
+ self._expanded = not self._show_header
def _refresh_title(self):
+ if not self._show_header:
+ return
title = f"{self._ch_name} ({self.dev_id}/{self.ch_id})"
if self._unit:
title += f" [{self._unit}]"
@@ -447,7 +459,7 @@ class PipelineTab(QWidget):
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, "Source: none (empty channel)", userData=None)
+ 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")
@@ -521,6 +533,7 @@ class PipelineTab(QWidget):
block = self._blocks.get(key)
if block:
self._on_remove_block(block)
+ self._refresh_src_cb()
def on_device_reconfigured(self, dev_id: str):
dev = self.registry.get_instance(dev_id)
@@ -534,9 +547,10 @@ class PipelineTab(QWidget):
block = self._blocks.get(key)
if block:
self._on_remove_block(block)
+ self._refresh_src_cb()
def on_device_added(self):
- pass
+ self._refresh_src_cb()
def on_channel_name_changed(self, dev_id: str, ch_id: str, ch_name: str):
key = (dev_id, ch_id)
@@ -548,12 +562,42 @@ class PipelineTab(QWidget):
ch = dev.get_channel(ch_id)
if ch and ch.enabled:
self._add_block(dev_id, ch_id, ch_name, ch.unit)
+ self._refresh_src_cb()
def on_channel_unit_changed(self, dev_id: str, ch_id: str, unit: str):
key = (dev_id, ch_id)
if key in self._blocks:
self._blocks[key].update_unit(unit)
+ def _refresh_src_cb(self):
+ """Rebuild the 'Source: …' picker at the top of the Channels pane —
+ it's a persistent combo (not a dialog), so it must be kept in sync
+ whenever devices/channels are added, removed, or renamed, not just
+ built once at construction time."""
+ current = self._src_cb.currentData()
+ self._src_cb.blockSignals(True)
+ self._src_cb.clear()
+ self._src_cb.insertItem(0, "Signal: none (empty channel)", userData=None)
+ for dev in self.registry.all_instances():
+ for ch in dev.info.channels:
+ if not ch.enabled:
+ continue
+ label = f"{ch.name} ({dev.info.device_id}/{ch.channel_id})"
+ self._src_cb.addItem(label, userData=(dev.info.device_id, ch.channel_id))
+ for dc in self.processor.get_derived():
+ label = f"[derived] {dc.channel_id} ({dc.name})"
+ self._src_cb.addItem(label, userData=("derived", dc.channel_id))
+ if current:
+ for i in range(self._src_cb.count()):
+ if self._src_cb.itemData(i) == current:
+ self._src_cb.setCurrentIndex(i)
+ break
+ else:
+ self._src_cb.setCurrentIndex(0)
+ else:
+ self._src_cb.setCurrentIndex(0)
+ self._src_cb.blockSignals(False)
+
def _on_add_channel(self):
dlg = ChannelPickerDialog(self.registry, self._shown, self)
if dlg.exec() != QDialog.DialogCode.Accepted:
@@ -579,14 +623,21 @@ class PipelineTab(QWidget):
def _add_virtual(self):
color = _DERIVED_COLORS[self._color_idx % len(_DERIVED_COLORS)]
self._color_idx += 1
- n = len(self._derived_blocks) + 1
- dc = DerivedChannel(channel_id=f"CH_{n}", name=f"Channel {n}",
+ existing_names = {d.name for d in self.processor.get_derived()}
+ existing_ids = {d.channel_id for d in self.processor.get_derived()}
+ # Pre-seed the source picked in the bar above, if any — otherwise the
+ # channel is created empty and signals can be added manually.
+ src = self._src_cb.currentData()
+ if src:
+ src_name = _channel_display_name(self.registry, self.processor, src[0], src[1])
+ else:
+ src_name = f"Channel {len(self._derived_blocks) + 1}"
+ name = _dedupe_name(src_name, existing_names)
+ channel_id = _slug_id(name, existing_ids)
+ dc = DerivedChannel(channel_id=channel_id, name=name,
kind="expression", color=color)
blk = self._make_derived_block(dc)
self._virt_inner.insertWidget(self._virt_inner.count() - 1, blk)
- # Pre-seed the source picked in the bar above, if any — otherwise the
- # channel is created empty and sources can be added manually.
- src = self._src_cb.currentData()
if src:
blk._add_src(src)
@@ -615,45 +666,85 @@ class PipelineTab(QWidget):
self._virt_inner.insertWidget(self._virt_inner.count() - 1, blk)
self._color_idx = len(self._derived_blocks)
+ self._refresh_src_cb()
# ══════════════════════════════════════════════════════════════════════════════
# Derived editor for one DerivedChannel
# ══════════════════════════════════════════════════════════════════════════════
-_BUILTIN = ["none", "derivative", "second_derivative", "power", "rms", "difference", "sum"]
+_BUILTIN = ["none", "scale_offset", "derivative", "second_derivative", "power", "rms", "difference", "sum"]
_SCRIPT = ["expression", "function", "custom_script"]
_ALL_KINDS = _BUILTIN + _SCRIPT
_KIND_DISPLAY = {k: k.replace("_", " ").title() for k in _ALL_KINDS}
_KIND_DISPLAY["none"] = "None"
+_KIND_DISPLAY["scale_offset"] = "Scale/Offset (Calibration)"
_KIND_HELP = {
- "none": "Pass source through unchanged",
- "derivative": "Backward difference dy/dt from 1 source",
- "second_derivative": "Second derivative d²y/dt² from 1 source",
- "power": "V × I from 2 sources (voltage, current)",
- "rms": "Rolling RMS from 1 source (set window below)",
- "difference": "source[0] − source[1] from 2 sources",
- "sum": "Σ all source values",
+ "none": "Pass signal through unchanged",
+ "scale_offset": "Linear calibration from 1 signal: result = value × scale + offset",
+ "derivative": "Backward difference dy/dt from 1 signal",
+ "second_derivative": "Second derivative d²y/dt² from 1 signal",
+ "power": "V × I from 2 signals (voltage, current)",
+ "rms": "Rolling RMS from 1 signal (set window below)",
+ "difference": "signal[0] − signal[1] from 2 signals",
+ "sum": "Σ all signal values",
"expression": (
"Single-line Python expression.\n"
"Available: x[0]… or use signal names directly (e.g. A0, encoder_0)\n"
" t = elapsed time (s), math, vars, state\n"
- "Example: A0 * 3.14159 / 180"
+ "Example: A0 * 3.14159 / 180\n"
+ "Tip: vars is shared with Control Panel 'On Action Script' — a button\n"
+ " can set vars['specimen_length'] = value; read it here as\n"
+ " vars.get('specimen_length', 1.0)."
),
"function": (
"Multi-line function body — auto-wrapped as def compute(x, t):\n"
"Available globals: x, signal names, t, math, vars, state\n"
- "Example:\n offset = vars.get('zero', 0)\n return x[0] - offset"
+ "Example:\n offset = vars.get('zero', 0)\n return x[0] - offset\n"
+ "Tip: vars is shared with Control Panel 'On Action Script' — a 'Zero'\n"
+ " button can set vars['zero'] = value while this reads it back."
),
"custom_script": (
"Multi-line Python block. Assign computed value to result.\n"
- "Available: x, signal names, t, math, vars, state"
+ "Available: x, signal names, t, math, vars, state\n"
+ "Tip: vars is shared with Control Panel 'On Action Script' — a button\n"
+ " can set vars['key'] = value; read it here as vars.get('key', default)."
),
}
+def _slug_id(name: str, existing: Set[str]) -> str:
+ """Short-hand channel_id derived from a display name, deduplicated against
+ existing derived channel IDs so a new Channel never silently overwrites
+ (goes stale on top of) an existing one."""
+ words = re.findall(r"[A-Za-z0-9]+", name)
+ if not words:
+ base = "CH"
+ elif len(words) == 1:
+ base = words[0][:4].upper()
+ else:
+ base = "".join(w[0] for w in words[:4]).upper()
+ slug = base
+ i = 2
+ while slug in existing:
+ slug = f"{base}_{i}"
+ i += 1
+ return slug
+
+
+def _dedupe_name(name: str, existing: Set[str]) -> str:
+ """Two channels must never share a display name — append ' (2)', ' (3)'...
+ until unique."""
+ if name not in existing:
+ return name
+ i = 2
+ while f"{name} ({i})" in existing:
+ i += 1
+ return f"{name} ({i})"
+
+
def _sub_xrefs(code: str, names: list) -> str:
"""Replace x[0], x[1], ... with named source vars where index is in range."""
def repl(m):
@@ -712,6 +803,22 @@ class DerivedBlock(QFrame):
meta.addWidget(self._unit); meta.addStretch()
bl.addLayout(meta)
+ # Sources — include derived channels as valid sources
+ src_grp = QGroupBox("Signals"); src_grp.setObjectName("cfgGlobalBar")
+ src_lay = QVBoxLayout(src_grp); src_lay.setContentsMargins(6, 4, 6, 4); src_lay.setSpacing(3)
+ self._src_cont = QWidget(); self._src_vlay = QVBoxLayout(self._src_cont)
+ self._src_vlay.setContentsMargins(0, 0, 0, 0); self._src_vlay.setSpacing(3)
+ _is_deriv = self.dc.kind in ("derivative", "second_derivative")
+ if _is_deriv:
+ self._add_src(self.dc.sources[0] if self.dc.sources else None)
+ else:
+ for _s in self.dc.sources:
+ self._add_src(_s)
+ self._add_src_btn = QPushButton("+ Add Signal"); self._add_src_btn.setObjectName("addTraceBtn")
+ self._add_src_btn.clicked.connect(lambda: self._add_src())
+ src_lay.addWidget(self._src_cont); src_lay.addWidget(self._add_src_btn)
+ bl.addWidget(src_grp)
+
# Kind combo + help
k_row = QHBoxLayout(); k_row.addWidget(QLabel("Operation:"))
self._kind_cb = QComboBox(); self._kind_cb.setObjectName("channelPickerCb")
@@ -736,21 +843,30 @@ class DerivedBlock(QFrame):
self._win_widget = QWidget(); self._win_widget.setLayout(self._win_row)
bl.addWidget(self._win_widget)
- # Sources — include derived channels as valid sources
- src_grp = QGroupBox("Sources"); src_grp.setObjectName("cfgGlobalBar")
- src_lay = QVBoxLayout(src_grp); src_lay.setContentsMargins(6, 4, 6, 4); src_lay.setSpacing(3)
- self._src_cont = QWidget(); self._src_vlay = QVBoxLayout(self._src_cont)
- self._src_vlay.setContentsMargins(0, 0, 0, 0); self._src_vlay.setSpacing(3)
- _is_deriv = self.dc.kind in ("derivative", "second_derivative")
- if _is_deriv:
- self._add_src(self.dc.sources[0] if self.dc.sources else None)
- else:
- for _s in self.dc.sources:
- self._add_src(_s)
- self._add_src_btn = QPushButton("+ Add Source"); self._add_src_btn.setObjectName("addTraceBtn")
- self._add_src_btn.clicked.connect(lambda: self._add_src())
- src_lay.addWidget(self._src_cont); src_lay.addWidget(self._add_src_btn)
- bl.addWidget(src_grp)
+ # Scale/Offset params
+ self._so_row = QHBoxLayout()
+ self._so_row.addWidget(QLabel("Scale:"))
+ self._scale_sp = QDoubleSpinBox(); self._scale_sp.setRange(-1e6, 1e6); self._scale_sp.setDecimals(6)
+ self._scale_sp.setValue(self.dc.params.get("scale", 1.0))
+ self._scale_sp.setObjectName("traceWidthSpin"); self._scale_sp.setFixedWidth(90)
+ self._scale_sp.valueChanged.connect(lambda v: self.dc.params.update({"scale": v}))
+ self._so_row.addWidget(self._scale_sp)
+ self._so_row.addWidget(QLabel("Offset:"))
+ self._offset_sp = QDoubleSpinBox(); self._offset_sp.setRange(-1e9, 1e9); self._offset_sp.setDecimals(6)
+ self._offset_sp.setValue(self.dc.params.get("offset", 0.0))
+ self._offset_sp.setObjectName("traceWidthSpin"); self._offset_sp.setFixedWidth(90)
+ self._offset_sp.valueChanged.connect(lambda v: self.dc.params.update({"offset": v}))
+ self._so_row.addWidget(self._offset_sp); self._so_row.addStretch()
+ self._so_widget = QWidget(); self._so_widget.setLayout(self._so_row)
+ bl.addWidget(self._so_widget)
+
+ # Filters — only for a single-physical-source passthrough channel
+ # (kind="none"); the embedded block is (re)built by _refresh_filters()
+ self._filters_grp = QGroupBox("Filters"); self._filters_grp.setObjectName("cfgGlobalBar")
+ self._filters_lay = QVBoxLayout(self._filters_grp)
+ self._filters_lay.setContentsMargins(6, 4, 6, 4)
+ self._filters_block: Optional[ChannelPipelineBlock] = None
+ bl.addWidget(self._filters_grp)
# W.r.t. — only for derivative/second_derivative
self._wrt_grp = QGroupBox("With Respect To"); self._wrt_grp.setObjectName("cfgGlobalBar")
@@ -876,8 +992,10 @@ class DerivedBlock(QFrame):
if wrt_src and wrt_src not in sources:
sources.append(wrt_src)
+ self._refresh_filters()
+
if not sources:
- self._vars_lbl.setText("No sources — add sources above to use named variables")
+ self._vars_lbl.setText("No signals — add signals above to use named variables")
return
display_names = [_channel_display_name(self.registry, self.processor, d, c)
@@ -903,6 +1021,36 @@ class DerivedBlock(QFrame):
self._code.setTextCursor(c)
self._code.blockSignals(False)
+ def _refresh_filters(self):
+ """(Re)build the embedded single-source Filters block when eligible:
+ kind is a plain passthrough ("none") with exactly one physical source."""
+ if not hasattr(self, "_filters_grp"):
+ return # called during initial Sources construction, too early
+ sources = [cb.currentData() for cb in self._src_combos if cb.currentData()]
+ eligible = (self.dc.kind == "none" and len(sources) == 1
+ and sources[0][0] != "derived")
+ if not eligible:
+ self._filters_grp.setVisible(False)
+ if self._filters_block is not None:
+ self._filters_lay.removeWidget(self._filters_block)
+ self._filters_block.deleteLater()
+ self._filters_block = None
+ return
+ dev_id, ch_id = sources[0]
+ if (self._filters_block is not None
+ and (self._filters_block.dev_id, self._filters_block.ch_id) != (dev_id, ch_id)):
+ self._filters_lay.removeWidget(self._filters_block)
+ self._filters_block.deleteLater()
+ self._filters_block = None
+ if self._filters_block is None:
+ ch_name = _channel_display_name(self.registry, self.processor, dev_id, ch_id)
+ self._filters_block = ChannelPipelineBlock(
+ dev_id, ch_id, ch_name, "", self.processor,
+ show_calibration=False, show_header=False,
+ )
+ self._filters_lay.addWidget(self._filters_block)
+ self._filters_grp.setVisible(True)
+
def _on_kind(self, idx: int):
from ui.code_templates import BUILTIN_TEMPLATES, SCRIPT_TEMPLATES
k = _ALL_KINDS[idx]
@@ -958,12 +1106,33 @@ class DerivedBlock(QFrame):
self._code.setReadOnly(is_builtin)
self._edit_as_script_btn.setVisible(is_builtin)
self._win_widget.setVisible(self.dc.kind == "rms")
+ self._so_widget.setVisible(self.dc.kind == "scale_offset")
+ self._refresh_filters()
self._wrt_grp.setVisible(is_deriv)
self._add_src_btn.setVisible(not is_deriv)
if is_deriv:
self._wrt_x_row.setVisible(self._wrt_cb.currentIndex() == 1)
def _apply(self):
+ name = self.dc.name.strip()
+ channel_id = self.dc.channel_id.strip()
+ if not name:
+ self._st.setText("⚠ Name cannot be empty")
+ self._st.setStyleSheet("color:#ef4444;")
+ return
+ if not channel_id:
+ self._st.setText("⚠ ID cannot be empty")
+ self._st.setStyleSheet("color:#ef4444;")
+ return
+ others = [d for d in self.processor.get_derived() if d is not self.dc]
+ if any(d.name == name for d in others):
+ self._st.setText(f"⚠ Name '{name}' is already used by another channel")
+ self._st.setStyleSheet("color:#ef4444;")
+ return
+ if any(d.channel_id == channel_id for d in others):
+ self._st.setText(f"⚠ ID '{channel_id}' is already used by another channel")
+ self._st.setStyleSheet("color:#ef4444;")
+ return
sources = [cb.currentData() for cb in self._src_combos if cb.currentData()]
if self.dc.kind in ("derivative", "second_derivative"):
wrt = "channel" if self._wrt_cb.currentIndex() == 1 else "time"
diff --git a/ui/windows/config_window.py b/ui/windows/config_window.py
index 05cc1fb..64c669d 100644
--- a/ui/windows/config_window.py
+++ b/ui/windows/config_window.py
@@ -5,8 +5,8 @@ Unified Configuration window — Devices | Channels | Plot in one tabbed window.
Channels tab layout (2 columns):
Left — signal table (Device / Signal ID / Dir / On / Name / Unit / Min / Max)
- ⚙ button per row opens that channel's filter/calibration pipeline dialog
- Right — derived / virtual channels
+ Right — derived / virtual channels — operations (filters/calibration/math/script)
+ are configured per Channel here, not on the Signals side.
"""
from __future__ import annotations
@@ -17,17 +17,17 @@ from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QToolButton,
QScrollArea, QFrame, QTabWidget, QTableWidget, QTableWidgetItem,
QHeaderView, QCheckBox, QMessageBox, QAbstractItemView, QSplitter,
- QDoubleSpinBox, QDialog, QDialogButtonBox,
+ QDoubleSpinBox,
)
from PyQt6.QtCore import Qt, pyqtSignal, QTimer
from PyQt6.QtGui import QCloseEvent, QColor
from devices.device_registry import DeviceRegistry
from core.acquisition import AcquisitionEngine
-from core.signal_processor import SignalProcessor
+from core.signal_processor import SignalProcessor, DerivedChannel
from ui.windows.devices_window import DeviceRow, ChannelsTab
-from ui.windows.channels_window import PipelineTab, ChannelPipelineBlock
+from ui.windows.channels_window import PipelineTab, _slug_id, _dedupe_name
from ui.windows.plot_window import (
PlotWindow, LayoutConfig, LayoutCanvas, PaneBlock,
build_default_layout,
@@ -35,37 +35,12 @@ from ui.windows.plot_window import (
# ══════════════════════════════════════════════════════════════════════════════
-# Pipeline dialog — per-channel filter/calibration popup
-# ══════════════════════════════════════════════════════════════════════════════
-
-class _PipelineDialog(QDialog):
- """Shows one ChannelPipelineBlock inside a dialog for a single signal."""
-
- def __init__(self, dev_id: str, ch_id: str, ch_name: str, unit: str,
- processor: SignalProcessor, parent=None):
- super().__init__(parent)
- self.setWindowTitle(f"Pipeline — {dev_id} / {ch_id}")
- self.setMinimumWidth(460)
- self.resize(500, 420)
-
- lay = QVBoxLayout(self)
- lay.setContentsMargins(0, 0, 0, 12)
-
- self._block = ChannelPipelineBlock(dev_id, ch_id, ch_name, unit, processor)
- self._block._toggle() # start expanded
- lay.addWidget(self._block, 1)
-
- btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
- btns.rejected.connect(self.accept)
- lay.addWidget(btns)
-
-
-# ══════════════════════════════════════════════════════════════════════════════
# Enhanced signals table — adds Dir badge and pipeline ⚙ button
# ══════════════════════════════════════════════════════════════════════════════
class _SignalsTab(ChannelsTab):
- """ChannelsTab with Dir (IN/OUT) badge and per-row ⚙ pipeline button."""
+ """ChannelsTab with a Dir (READ/WRITE) badge. Operations (filters/calibration/math)
+ are configured per Channel in the Channels pane, not here."""
_C_DEVICE = 0
_C_CH_ID = 1
@@ -75,7 +50,9 @@ class _SignalsTab(ChannelsTab):
_C_UNIT = 5
_C_MIN = 6
_C_MAX = 7
- _C_PIPE = 8 # ⚙ pipeline button
+ _C_MKCH = 8 # "+ Channel" button
+
+ channel_created = pyqtSignal()
def __init__(self, registry: DeviceRegistry, processor: SignalProcessor):
self._processor = processor
@@ -97,7 +74,7 @@ class _SignalsTab(ChannelsTab):
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_PIPE, 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)
@@ -122,8 +99,8 @@ class _SignalsTab(ChannelsTab):
ch_item.setForeground(QColor(ch.color))
self._table.setItem(row, self._C_CH_ID, ch_item)
- is_out = getattr(ch, "is_output", False)
- dir_item = QTableWidgetItem("OUT" if is_out else "IN")
+ is_out = getattr(ch, "writable", False)
+ dir_item = QTableWidgetItem("WRITE" if is_out else "READ")
dir_item.setFlags(dir_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
dir_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
dir_item.setForeground(QColor("#f59e0b") if is_out else QColor("#22c55e"))
@@ -156,33 +133,47 @@ class _SignalsTab(ChannelsTab):
self._table.setItem(row, self._C_MIN, QTableWidgetItem(str(ch.min_value)))
self._table.setItem(row, self._C_MAX, QTableWidgetItem(str(ch.max_value)))
- # Pipeline ⚙ button
- pipe_w = QWidget()
- pipe_l = QHBoxLayout(pipe_w)
- pipe_l.setContentsMargins(2, 0, 2, 0)
- pipe_l.setAlignment(Qt.AlignmentFlag.AlignCenter)
- pipe_btn = QToolButton()
- pipe_btn.setText("⚙")
- pipe_btn.setObjectName("traceRemoveBtn")
- pipe_btn.setFixedSize(22, 22)
- pipe_btn.setToolTip("Configure filters / calibration")
- d_cap, c_cap, n_cap, u_cap = (
- dev.info.device_id, ch.channel_id, ch.name, ch.unit
+ 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.channel_id, ch.name, ch.unit, ch.color
)
- pipe_btn.clicked.connect(
- lambda _=False, d=d_cap, c=c_cap, n=n_cap, u=u_cap:
- self._open_pipeline(d, c, n, u)
+ 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)
)
- pipe_l.addWidget(pipe_btn)
- self._table.setCellWidget(row, self._C_PIPE, pipe_w)
+ mkch_l.addWidget(mkch_btn)
+ self._table.setCellWidget(row, self._C_MKCH, mkch_w)
row += 1
self._table.blockSignals(False)
- def _open_pipeline(self, dev_id: str, ch_id: str, ch_name: str, unit: str):
- dlg = _PipelineDialog(dev_id, ch_id, ch_name, unit, self._processor, self)
- dlg.exec()
+ 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 = DerivedChannel(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()
# ══════════════════════════════════════════════════════════════════════════════
@@ -353,6 +344,7 @@ class ConfigWindow(QWidget):
row = DeviceRow(dev)
row.configure_clicked.connect(self._on_configure_device)
row.remove_clicked.connect(self._on_remove_device)
+ row.name_changed.connect(self._on_device_renamed)
self._dev_inner.insertWidget(self._dev_inner.count() - 1, row)
self._dev_rows[dev.info.device_id] = row
@@ -371,14 +363,20 @@ class ConfigWindow(QWidget):
self.engine.add_device(dev)
self.refresh_devices()
self._signals_tab.refresh()
+ self._pipeline_tab.on_device_added()
self.device_added.emit()
+ def _on_device_renamed(self, device_id: str):
+ self._signals_tab.refresh()
+ self.device_reconfigured.emit(device_id)
+
def _on_configure_device(self, device_id: str):
from ui.config_dialog import DeviceConfigDialog
dev = self.registry.get_instance(device_id)
if dev:
DeviceConfigDialog(dev, self).exec()
self._signals_tab.refresh()
+ self._pipeline_tab.on_device_reconfigured(device_id)
self.device_reconfigured.emit(device_id)
def _on_remove_device(self, device_id: str):
@@ -412,7 +410,7 @@ class ConfigWindow(QWidget):
sig_l = QVBoxLayout(sig_w); sig_l.setContentsMargins(0, 0, 0, 0); sig_l.setSpacing(0)
sig_bar = QWidget(); sig_bar.setObjectName("cfgGlobalBar")
- sb_lay = QHBoxLayout(sig_bar); sb_lay.setContentsMargins(10, 7, 10, 7)
+ sb_lay = QHBoxLayout(sig_bar); sb_lay.setContentsMargins(10, 7, 10, 7); sb_lay.setSpacing(6)
sb_lbl = QLabel("Signals"); sb_lbl.setObjectName("devWindowTitle")
sb_lay.addWidget(sb_lbl, 1)
sig_l.addWidget(sig_bar)
@@ -435,6 +433,7 @@ 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)
splitter.setStretchFactor(0, 2)
splitter.setStretchFactor(1, 1)
diff --git a/ui/windows/devices_window.py b/ui/windows/devices_window.py
index eec65c8..ae88c6b 100644
--- a/ui/windows/devices_window.py
+++ b/ui/windows/devices_window.py
@@ -41,6 +41,7 @@ _STATUS_TEXT = {
class DeviceRow(QFrame):
configure_clicked = pyqtSignal(str)
remove_clicked = pyqtSignal(str)
+ name_changed = pyqtSignal(str)
def __init__(self, device):
super().__init__()
@@ -66,11 +67,14 @@ class DeviceRow(QFrame):
info_col = QVBoxLayout()
info_col.setSpacing(1)
- name = QLabel(self.device.info.name)
- name.setObjectName("deviceName")
+ self._name_edit = QLineEdit(self.device.info.name)
+ self._name_edit.setObjectName("deviceName")
+ self._name_edit.setFrame(False)
+ self._name_edit.setToolTip("Click to rename")
+ self._name_edit.editingFinished.connect(self._on_name_edited)
id_lbl = QLabel(f"{self.device.info.device_id} · {self.device.info.device_type}")
id_lbl.setObjectName("deviceSub")
- info_col.addWidget(name)
+ info_col.addWidget(self._name_edit)
info_col.addWidget(id_lbl)
lay.addLayout(info_col, 1)
@@ -103,6 +107,16 @@ class DeviceRow(QFrame):
self._status_lbl.setText(_STATUS_TEXT.get(self.device.status, "?"))
self._set_status_style()
+ def _on_name_edited(self):
+ new_name = self._name_edit.text().strip()
+ if not new_name:
+ self._name_edit.setText(self.device.info.name)
+ return
+ if new_name == self.device.info.name:
+ return
+ self.device.info.name = new_name
+ self.name_changed.emit(self.device.info.device_id)
+
def _set_dot_color(self):
c = _DOT_COLOR.get(self.device.status, "#475569")
self._dot.setStyleSheet(f"color:{c}; font-size:10px;")
diff --git a/ui/windows/plot_window.py b/ui/windows/plot_window.py
index f311e9e..119efab 100644
--- a/ui/windows/plot_window.py
+++ b/ui/windows/plot_window.py
@@ -26,7 +26,7 @@ from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QScrollArea, QFrame, QLineEdit, QDoubleSpinBox, QSpinBox,
QCheckBox, QComboBox, QColorDialog, QToolButton,
- QFileDialog, QMessageBox,
+ QFileDialog, QMessageBox, QDialog, QDialogButtonBox,
)
from PyQt6.QtCore import Qt, pyqtSignal, QPoint, QRect
from PyQt6.QtGui import QColor, QCloseEvent, QPainter, QPen
@@ -89,18 +89,12 @@ class LayoutConfig:
def build_default_layout(registry: DeviceRegistry,
processor: SignalProcessor) -> LayoutConfig:
+ """One pane per Channel (processor.get_derived()), titled by channel
+ name — raw Signals (physical device channels) are not auto-plotted.
+ Signals are just hardware endpoints; a Channel is what the operator has
+ explicitly chosen to process/observe, so that's what the default plot
+ shows. Use the "+ Add Channel" button on a Signal row to promote it."""
cfg = LayoutConfig()
- for dev in registry.all_instances():
- enabled = [ch for ch in dev.info.channels if ch.enabled]
- if not enabled: continue
- pane = PaneSpec(title=dev.info.name,
- y_label=enabled[0].unit if enabled else "")
- for ch in enabled:
- pane.traces.append(TraceSpec(
- device_id=dev.info.device_id, channel_id=ch.channel_id,
- label=ch.name, color=ch.color,
- ))
- if pane.traces: cfg.panes.append(pane)
for dc in processor.get_derived():
pane = PaneSpec(title=dc.name, y_label=dc.unit)
pane.traces.append(TraceSpec(
@@ -281,8 +275,9 @@ class ColorSwatch(QPushButton):
# ══════════════════════════════════════════════════════════════════════════════
class TraceRow(QFrame):
- removed = pyqtSignal(object)
- changed = pyqtSignal()
+ removed = pyqtSignal(object)
+ changed = pyqtSignal()
+ move_requested = pyqtSignal(object, int) # (self, direction: -1 up / +1 down)
def __init__(self, tr: TraceSpec):
super().__init__(); self.tr = tr; self.setObjectName("traceRow"); self._build()
def _build(self):
@@ -309,12 +304,77 @@ class TraceRow(QFrame):
self._w.setValue(self.tr.width); self._w.setFixedWidth(56)
self._w.valueChanged.connect(lambda v: setattr(self.tr,"width",v))
lay.addWidget(self._w)
+ up = QToolButton(); up.setText("▲"); up.setObjectName("traceRemoveBtn")
+ up.setFixedSize(18,18); up.setToolTip("Move up")
+ up.clicked.connect(lambda: self.move_requested.emit(self, -1))
+ lay.addWidget(up)
+ dn = QToolButton(); dn.setText("▼"); dn.setObjectName("traceRemoveBtn")
+ dn.setFixedSize(18,18); dn.setToolTip("Move down")
+ dn.clicked.connect(lambda: self.move_requested.emit(self, 1))
+ lay.addWidget(dn)
rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn")
rm.setFixedSize(22,22); rm.clicked.connect(lambda: self.removed.emit(self))
lay.addWidget(rm)
# ══════════════════════════════════════════════════════════════════════════════
+# Trace channel picker — checkbox dialog, replaces the old inline dropdown
+# ══════════════════════════════════════════════════════════════════════════════
+
+class TraceChannelPickerDialog(QDialog):
+ """Checkbox list of enabled device + derived channels — same pattern as
+ channels_window.ChannelPickerDialog, so adding traces to a pane is
+ consistent with how channels are added elsewhere in the app."""
+
+ def __init__(self, registry: DeviceRegistry, processor: SignalProcessor, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Add Channels to Pane")
+ self.setMinimumWidth(380)
+ self.resize(420, 320)
+ self._checks: List[Tuple] = [] # (QCheckBox, dev_id, ch_id, name, color)
+
+ lay = QVBoxLayout(self)
+ scroll = QScrollArea(); scroll.setWidgetResizable(True)
+ scroll.setObjectName("deviceScroll")
+ cont = QWidget()
+ cl = QVBoxLayout(cont); cl.setContentsMargins(8, 8, 8, 8); cl.setSpacing(4)
+
+ any_available = False
+ for dev in registry.all_instances():
+ for ch in dev.info.channels:
+ if not ch.enabled:
+ continue
+ any_available = True
+ chk = QCheckBox(f"{ch.name} ({dev.info.device_id}/{ch.channel_id})")
+ self._checks.append((chk, dev.info.device_id, ch.channel_id, ch.name, ch.color))
+ cl.addWidget(chk)
+ for dc in processor.get_derived():
+ if not dc.enabled:
+ continue
+ any_available = True
+ chk = QCheckBox(f"{dc.name} ([virtual]/{dc.channel_id})")
+ self._checks.append((chk, "derived", dc.channel_id, dc.name, dc.color))
+ cl.addWidget(chk)
+
+ if not any_available:
+ cl.addWidget(QLabel("No channels available."))
+
+ cl.addStretch()
+ scroll.setWidget(cont)
+ lay.addWidget(scroll, 1)
+
+ btns = QDialogButtonBox(
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
+ )
+ btns.accepted.connect(self.accept)
+ btns.rejected.connect(self.reject)
+ lay.addWidget(btns)
+
+ def selected(self) -> List[Tuple[str, str, str, str]]:
+ return [(d, c, n, col) for chk, d, c, n, col in self._checks if chk.isChecked()]
+
+
+# ══════════════════════════════════════════════════════════════════════════════
# Pane block
# ══════════════════════════════════════════════════════════════════════════════
@@ -411,30 +471,15 @@ class PaneBlock(QFrame):
for tr in self.spec.traces: self._add_row(tr)
pick_row = QHBoxLayout()
- self._picker = self._make_picker(); pick_row.addWidget(self._picker,1)
ab = QPushButton("+ Add Channel"); ab.setObjectName("addTraceBtn")
- ab.clicked.connect(self._on_add); pick_row.addWidget(ab)
+ ab.clicked.connect(self._on_add); pick_row.addWidget(ab); pick_row.addStretch()
self._tlay.addLayout(pick_row)
outer.addWidget(tr_w)
- def _make_picker(self):
- cb = QComboBox(); cb.setObjectName("channelPickerCb")
- for dev in self.registry.all_instances():
- for ch in dev.info.channels:
- if not ch.enabled:
- continue
- cb.addItem(f"{ch.name} ({dev.info.device_id}/{ch.channel_id})",
- userData=(dev.info.device_id, ch.channel_id, ch.name, ch.color))
- for dc in self.processor.get_derived():
- if not dc.enabled:
- continue
- cb.addItem(f"{dc.name} ([virtual]/{dc.channel_id})",
- userData=("derived", dc.channel_id, dc.name, dc.color))
- return cb
-
def _add_row(self, tr: TraceSpec):
row = TraceRow(tr); row.removed.connect(self._rm_row)
row.changed.connect(self.changed)
+ row.move_requested.connect(self._move_row)
self._rows.append(row)
self._tlay.insertWidget(self._tlay.count()-1, row)
@@ -443,12 +488,27 @@ class PaneBlock(QFrame):
self._rows.remove(row); self._tlay.removeWidget(row); row.deleteLater()
self.changed.emit()
+ def _move_row(self, row: TraceRow, direction: int):
+ if row not in self._rows:
+ return
+ idx = self._rows.index(row)
+ new_idx = idx + direction
+ if new_idx < 0 or new_idx >= len(self._rows):
+ return
+ self._rows[idx], self._rows[new_idx] = self._rows[new_idx], self._rows[idx]
+ self.spec.traces[idx], self.spec.traces[new_idx] = self.spec.traces[new_idx], self.spec.traces[idx]
+ self._tlay.removeWidget(row)
+ self._tlay.insertWidget(new_idx, row)
+ self.changed.emit()
+
def _on_add(self):
- data = self._picker.currentData()
- if not data: return
- d,c,n,col = data
- tr = TraceSpec(device_id=d,channel_id=c,label=n,color=col)
- self.spec.traces.append(tr); self._add_row(tr); self.changed.emit()
+ dlg = TraceChannelPickerDialog(self.registry, self.processor, self)
+ if dlg.exec() != QDialog.DialogCode.Accepted:
+ return
+ for d, c, n, col in dlg.selected():
+ tr = TraceSpec(device_id=d, channel_id=c, label=n, color=col)
+ self.spec.traces.append(tr); self._add_row(tr)
+ self.changed.emit()
def _on_auto(self, v):
self.spec.y_auto = v; self._ymin.setEnabled(not v); self._ymax.setEnabled(not v)