From 0355099aa21918e8f6f7fcd61aba8e3014bf8171 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Wed, 3 Jun 2026 12:35:32 -0600 Subject: Rename signals_window.py → channels_window.py; update docstring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflects current design: split Physical/Virtual Channels layout, no tabs, no legacy Signals list as primary UI. Co-Authored-By: Claude Sonnet 4.6 --- ui/windows/channels_window.py | 887 ++++++++++++++++++++++++++++++++++++++++++ ui/windows/signals_window.py | 878 ----------------------------------------- 2 files changed, 887 insertions(+), 878 deletions(-) create mode 100644 ui/windows/channels_window.py delete mode 100644 ui/windows/signals_window.py (limited to 'ui/windows') diff --git a/ui/windows/channels_window.py b/ui/windows/channels_window.py new file mode 100644 index 0000000..d2a9813 --- /dev/null +++ b/ui/windows/channels_window.py @@ -0,0 +1,887 @@ +""" +ui/windows/channels_window.py + +CHANNELS window — toolbar section 3. + +Layout (QSplitter, vertical): + Physical Channels — per-channel filter pipeline; add/remove channels, + configure moving average, low-pass, derivative, etc. + Virtual Channels — create derived channels (derivative dy/dt or dy/dx, + RMS, power, difference, sum, Python expression/script) + +Classes: + ChannelsWindow — top-level floating window + PipelineTab — splitter content (physical + virtual sections) + ChannelPipelineBlock — collapsible filter stack for one physical channel + DerivedBlock — editor for one virtual/derived channel + FilterRow — single filter stage row inside a pipeline block + SignalsListTab — unused; kept for profile-load compatibility +""" + +from __future__ import annotations +from typing import List, Optional, Set, Tuple + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QScrollArea, QFrame, QTabWidget, QComboBox, QLineEdit, + QDoubleSpinBox, QSpinBox, QCheckBox, QTextEdit, QGroupBox, + QToolButton, QSplitter, +) +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QCloseEvent + +from devices.device_registry import DeviceRegistry +from core.signal_processor import ( + SignalProcessor, ChannelPipeline, DerivedChannel, + FilterBase, FILTER_CLASSES, +) + +_DERIVED_COLORS = [ + "#f72585","#7209b7","#4361ee","#4cc9f0", + "#f77f00","#d62828","#588157","#e9c46a", +] + + +def _channel_combo(registry: DeviceRegistry, + processor: Optional[SignalProcessor] = None, + include_derived: bool = False, + show_unit: bool = False) -> QComboBox: + cb = QComboBox(); cb.setObjectName("channelPickerCb") + for dev in registry.all_instances(): + for ch in dev.info.channels: + if not ch.enabled: + continue + label = f"{dev.info.device_id} / {ch.channel_id} ({ch.name})" + if show_unit and ch.unit: + label += f" [{ch.unit}]" + cb.addItem(label, userData=(dev.info.device_id, ch.channel_id)) + if include_derived and processor: + for dc in processor.get_derived(): + label = f"[derived] {dc.channel_id} ({dc.name})" + if show_unit and dc.unit: + label += f" [{dc.unit}]" + cb.addItem(label, userData=("derived", dc.channel_id)) + return cb + + +# ══════════════════════════════════════════════════════════════════════════════ +# Filter row +# ══════════════════════════════════════════════════════════════════════════════ + +_PARAM_SPECS = { + "moving_average": [("window", "int", 10, 1, 5000)], + "median": [("window", "int", 5, 1, 500)], + "low_pass": [("alpha", "float", 0.1, 0.001, 1.0)], + "high_pass": [("alpha", "float", 0.9, 0.001, 1.0)], + "scale_offset": [("scale", "float", 1.0, -1e6, 1e6), + ("offset", "float", 0.0, -1e9, 1e9)], + "derivative": [], + "integral": [], +} + +class FilterRow(QFrame): + removed = pyqtSignal(object) + changed = pyqtSignal() + + def __init__(self, filt: FilterBase): + super().__init__() + self.filt = filt + self.setObjectName("traceRow") + self._build() + + def _build(self): + lay = QHBoxLayout(self); lay.setContentsMargins(6,4,6,4); lay.setSpacing(8) + + lbl = QLabel(self.filt.name.replace("_"," ").title()) + lbl.setObjectName("traceSource"); lbl.setMinimumWidth(120) + lay.addWidget(lbl) + + for pname, ptype, default, lo, hi in _PARAM_SPECS.get(self.filt.name, []): + pl = QLabel(f"{pname}:"); pl.setObjectName("pwmLabel") + lay.addWidget(pl) + if ptype == "int": + w = QSpinBox(); w.setRange(int(lo), int(hi)) + w.setValue(int(self.filt.params.get(pname, default))) + w.setFixedWidth(70); w.setObjectName("traceWidthSpin") + w.valueChanged.connect(lambda v, p=pname: self._upd(p, v)) + else: + w = QDoubleSpinBox(); w.setRange(lo, hi); w.setDecimals(4) + w.setValue(float(self.filt.params.get(pname, default))) + w.setFixedWidth(88); w.setObjectName("traceWidthSpin") + w.valueChanged.connect(lambda v, p=pname: self._upd(p, v)) + lay.addWidget(w) + + lay.addStretch() + rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn") + rm.setFixedSize(22, 22); rm.clicked.connect(lambda: self.removed.emit(self)) + lay.addWidget(rm) + + def _upd(self, name, value): + self.filt.params[name] = value + cls = FILTER_CLASSES.get(self.filt.name) + if cls: + nf = cls(**self.filt.params); self.filt.__dict__.update(nf.__dict__) + self.changed.emit() + + +# monkey-patch QLabel.also for inline objectName +from PyQt6.QtWidgets import QLabel as _QLabel +def _also(self, fn): fn(self); return self +_QLabel.also = _also + + +# ══════════════════════════════════════════════════════════════════════════════ +# Channel Pipeline Block — collapsible filter stack for one channel +# ══════════════════════════════════════════════════════════════════════════════ + +class ChannelPipelineBlock(QFrame): + changed = pyqtSignal() + removed = pyqtSignal(object) # emits self + + def __init__(self, dev_id: str, ch_id: str, ch_name: str, unit: str, + processor: SignalProcessor): + super().__init__() + self.dev_id = dev_id + self.ch_id = ch_id + self.processor = processor + self._rows: List[FilterRow] = [] + self._cur_pipeline: Optional[ChannelPipeline] = None + self._expanded = False + self.setObjectName("plotBlock") + self._build(ch_name, unit) + self._load() + + def _build(self, ch_name: str, unit: str): + self._ch_name = ch_name + 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"{self.dev_id} / {self.ch_id} ({ch_name})" + 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") + bl = QVBoxLayout(self._body) + bl.setContentsMargins(10,8,10,10); bl.setSpacing(6) + + self._en_chk = QCheckBox("Pipeline enabled"); self._en_chk.setChecked(True) + self._en_chk.toggled.connect(self._on_enable) + bl.addWidget(self._en_chk) + + self._filter_area = QWidget() + self._filter_lay = QVBoxLayout(self._filter_area) + self._filter_lay.setContentsMargins(0,0,0,0); self._filter_lay.setSpacing(3) + bl.addWidget(self._filter_area) + + add_bar = QHBoxLayout() + self._filt_cb = QComboBox(); self._filt_cb.setObjectName("channelPickerCb") + self._filt_cb.addItems([k.replace("_"," ").title() for k in FILTER_CLASSES]) + add_bar.addWidget(self._filt_cb, 1) + add_btn = QPushButton("+ Add Filter"); add_btn.setObjectName("addTraceBtn") + add_btn.clicked.connect(self._add_filter) + add_bar.addWidget(add_btn) + bl.addLayout(add_bar) + + ap_row = QHBoxLayout(); ap_row.addStretch() + ap_btn = QPushButton("✓ Apply"); ap_btn.setObjectName("applyButton") + ap_btn.clicked.connect(self._push) + ap_row.addWidget(ap_btn) + bl.addLayout(ap_row) + + outer.addWidget(self._body) + self._body.setVisible(False) + + def _refresh_title(self): + title = f"{self.dev_id} / {self.ch_id} ({self._ch_name})" + if self._unit: + title += f" [{self._unit}]" + self._title_lbl.setText(title) + + def update_name(self, ch_name: str): + self._ch_name = ch_name + self._refresh_title() + + def update_unit(self, unit: str): + self._unit = unit + self._refresh_title() + + def _toggle(self): + self._expanded = not self._expanded + self._body.setVisible(self._expanded) + self._toggle_btn.setText("▼" if self._expanded else "▶") + + def _load(self): + pipeline = self.processor.get_pipeline(self.dev_id, self.ch_id) + if pipeline is None: + pipeline = ChannelPipeline(device_id=self.dev_id, channel_id=self.ch_id) + self.processor.set_pipeline(pipeline) + self._cur_pipeline = pipeline + self._en_chk.setChecked(pipeline.enabled) + for f in pipeline.filters: + self._add_filter_row(f) + + def _add_filter(self): + key = list(FILTER_CLASSES.keys())[self._filt_cb.currentIndex()] + filt = FILTER_CLASSES[key]() + if self._cur_pipeline: + self._cur_pipeline.filters.append(filt) + self._add_filter_row(filt) + self._push() + + def _add_filter_row(self, filt: FilterBase): + row = FilterRow(filt) + row.removed.connect(self._remove_filter) + row.changed.connect(self._push) + self._rows.append(row) + self._filter_lay.addWidget(row) + + def _remove_filter(self, row: FilterRow): + if self._cur_pipeline and row.filt in self._cur_pipeline.filters: + self._cur_pipeline.filters.remove(row.filt) + self._rows.remove(row) + self._filter_lay.removeWidget(row); row.deleteLater() + self._push() + + def _on_enable(self, v: bool): + if self._cur_pipeline: + self._cur_pipeline.enabled = v + self._push() + + def _push(self): + if self._cur_pipeline: + self.processor.set_pipeline(self._cur_pipeline) + self.changed.emit() + + +# ══════════════════════════════════════════════════════════════════════════════ +# Pipeline Tab — filter pipelines per channel + virtual channel creation +# ══════════════════════════════════════════════════════════════════════════════ + +class PipelineTab(QWidget): + changed = pyqtSignal() + derived_changed = pyqtSignal() + + def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): + super().__init__() + self.registry = registry + self.processor = processor + self._shown: Set[Tuple[str,str]] = set() + self._blocks: dict[Tuple[str,str], ChannelPipelineBlock] = {} + self._derived_blocks: List[DerivedBlock] = [] + self._color_idx = 0 + self._build() + + def _build(self): + root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0); root.setSpacing(0) + + splitter = QSplitter(Qt.Orientation.Vertical) + splitter.setObjectName("channelSplitter") + root.addWidget(splitter) + + # ── Physical Channels pane ────────────────────────────────────────────── + phys_w = QWidget() + phys_l = QVBoxLayout(phys_w); phys_l.setContentsMargins(0,0,0,0); phys_l.setSpacing(0) + + 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("Physical Channels"); pb_lbl.setObjectName("devWindowTitle") + pb_lay.addWidget(pb_lbl, 1) + self._avail_cb = QComboBox(); self._avail_cb.setObjectName("channelPickerCb") + pb_lay.addWidget(self._avail_cb) + add_btn = QPushButton("+ Add"); add_btn.setObjectName("addTraceBtn") + add_btn.clicked.connect(self._on_add_channel) + pb_lay.addWidget(add_btn) + phys_l.addWidget(phys_bar) + + div1 = QFrame(); div1.setFrameShape(QFrame.Shape.HLine) + div1.setObjectName("devWindowDivider"); phys_l.addWidget(div1) + + phys_scroll = QScrollArea(); phys_scroll.setWidgetResizable(True) + phys_scroll.setObjectName("deviceScroll") + phys_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._phys_cont = QWidget() + self._phys_inner = QVBoxLayout(self._phys_cont) + self._phys_inner.setContentsMargins(10,10,10,10); self._phys_inner.setSpacing(6) + self._phys_inner.addStretch() + phys_scroll.setWidget(self._phys_cont) + phys_l.addWidget(phys_scroll, 1) + splitter.addWidget(phys_w) + + # ── Virtual Channels pane ─────────────────────────────────────────────── + virt_w = QWidget() + virt_l = QVBoxLayout(virt_w); virt_l.setContentsMargins(0,0,0,0); virt_l.setSpacing(0) + + 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("Virtual Channels"); vb_lbl.setObjectName("devWindowTitle") + vb_lay.addWidget(vb_lbl, 1) + self._new_kind = QComboBox(); self._new_kind.setObjectName("channelPickerCb") + self._new_kind.addItems([_KIND_DISPLAY[k] for k in _ALL_KINDS]) + vb_lay.addWidget(self._new_kind) + add_virt = QPushButton("+ Add Virtual"); add_virt.setObjectName("addTraceBtn") + add_virt.clicked.connect(self._add_virtual) + vb_lay.addWidget(add_virt) + virt_l.addWidget(virt_bar) + + div2 = QFrame(); div2.setFrameShape(QFrame.Shape.HLine) + div2.setObjectName("devWindowDivider"); virt_l.addWidget(div2) + + virt_scroll = QScrollArea(); virt_scroll.setWidgetResizable(True) + virt_scroll.setObjectName("deviceScroll") + virt_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._virt_cont = QWidget() + self._virt_inner = QVBoxLayout(self._virt_cont) + self._virt_inner.setContentsMargins(10,10,10,10); self._virt_inner.setSpacing(6) + self._virt_inner.addStretch() + virt_scroll.setWidget(self._virt_cont) + virt_l.addWidget(virt_scroll, 1) + splitter.addWidget(virt_w) + + splitter.setSizes([300, 300]) + + for dev in self.registry.all_instances(): + for ch in dev.info.channels: + if ch.enabled: + self._add_block(dev.info.device_id, ch.channel_id, ch.name, ch.unit) + + self._refresh_avail_cb() + + # ── Physical channel block management ───────────────────────────────────── + + def _add_block(self, dev_id: str, ch_id: str, ch_name: str, unit: str): + if (dev_id, ch_id) in self._shown: + return + self._shown.add((dev_id, ch_id)) + block = ChannelPipelineBlock(dev_id, ch_id, ch_name, unit, self.processor) + block.changed.connect(self.changed) + block.removed.connect(self._on_remove_block) + self._blocks[(dev_id, ch_id)] = block + self._phys_inner.insertWidget(self._phys_inner.count() - 1, block) + + def on_channel_enabled_changed(self, dev_id: str, ch_id: str, enabled: bool): + key = (dev_id, ch_id) + if enabled: + if key not in self._shown: + dev = self.registry.get_instance(dev_id) + if dev: + ch = dev.get_channel(ch_id) + if ch: + self._add_block(dev_id, ch_id, ch.name, ch.unit) + else: + block = self._blocks.get(key) + if block: + block.setVisible(True) + else: + block = self._blocks.get(key) + if block: + block.setVisible(False) + self._refresh_avail_cb() + + def on_device_removed(self, dev_id: str): + keys = [(d, c) for (d, c) in list(self._shown) if d == dev_id] + for key in keys: + block = self._blocks.get(key) + if block: + self._on_remove_block(block) + self._refresh_avail_cb() + + def on_device_reconfigured(self, dev_id: str): + dev = self.registry.get_instance(dev_id) + if dev is None: + self.on_device_removed(dev_id) + return + valid = {ch.channel_id for ch in dev.info.channels} + keys = [(d, c) for (d, c) in list(self._shown) + if d == dev_id and c not in valid] + for key in keys: + block = self._blocks.get(key) + if block: + self._on_remove_block(block) + self._refresh_avail_cb() + + def on_device_added(self): + self._refresh_avail_cb() + + def on_channel_name_changed(self, dev_id: str, ch_id: str, ch_name: str): + key = (dev_id, ch_id) + if key in self._blocks: + self._blocks[key].update_name(ch_name) + else: + dev = self.registry.get_instance(dev_id) + if dev: + ch = dev.get_channel(ch_id) + if ch and ch.enabled: + self._add_block(dev_id, ch_id, ch_name, ch.unit) + self._refresh_avail_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 _on_add_channel(self): + data = self._avail_cb.currentData() + if not data: + return + dev_id, ch_id = data + dev = self.registry.get_instance(dev_id) + if not dev: + return + ch = dev.get_channel(ch_id) + if not ch: + return + self._add_block(dev_id, ch_id, ch.name, ch.unit) + self._refresh_avail_cb() + + def _on_remove_block(self, block: ChannelPipelineBlock): + key = (block.dev_id, block.ch_id) + self._shown.discard(key) + self._blocks.pop(key, None) + self._phys_inner.removeWidget(block) + block.deleteLater() + self._refresh_avail_cb() + + def _refresh_avail_cb(self): + self._avail_cb.clear() + for dev in self.registry.all_instances(): + for ch in dev.info.channels: + if not ch.enabled: + continue + if (dev.info.device_id, ch.channel_id) not in self._shown: + label = f"{dev.info.device_id} / {ch.channel_id} ({ch.name})" + if ch.unit: + label += f" [{ch.unit}]" + self._avail_cb.addItem(label, + userData=(dev.info.device_id, ch.channel_id)) + + # ── Virtual channel management ──────────────────────────────────────────── + + def _add_virtual(self): + kind = _ALL_KINDS[self._new_kind.currentIndex()] + 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_V{n}", name=f"Virtual Channel {n}", + kind=kind, color=color) + blk = self._make_derived_block(dc) + self._virt_inner.insertWidget(self._virt_inner.count() - 1, blk) + + def _make_derived_block(self, dc: DerivedChannel) -> DerivedBlock: + blk = DerivedBlock(dc, self.registry, self.processor) + blk.removed.connect(self._rm_virtual) + blk.applied.connect(lambda _: self.derived_changed.emit()) + self._derived_blocks.append(blk) + return blk + + def _rm_virtual(self, blk: DerivedBlock): + self.processor.remove_derived(blk.dc.channel_id) + self._derived_blocks.remove(blk) + self._virt_inner.removeWidget(blk); blk.deleteLater() + self.derived_changed.emit() + + def refresh_derived(self): + """Rebuild DerivedBlock widgets from processor state (called after profile load).""" + for blk in self._derived_blocks: + self._virt_inner.removeWidget(blk); blk.deleteLater() + self._derived_blocks.clear() + self._color_idx = 0 + + for dc in self.processor.get_derived(): + blk = self._make_derived_block(dc) + self._virt_inner.insertWidget(self._virt_inner.count() - 1, blk) + + self._color_idx = len(self._derived_blocks) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Derived editor for one DerivedChannel +# ══════════════════════════════════════════════════════════════════════════════ + +_BUILTIN = ["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_HELP = { + "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", + "expression": "x = list of source values, t = elapsed time (s)\nExample: x[0] * 3.14159 / 180", + "function": "def compute(x, t):\n # x = source values list\n return x[0] * 2.0", + "custom_script":"Full Python. Must define: def compute(x, t): ...", +} + + +class DerivedBlock(QFrame): + removed = pyqtSignal(object) + applied = pyqtSignal(object) # DerivedChannel + + def __init__(self, dc: DerivedChannel, registry: DeviceRegistry, + processor: SignalProcessor): + super().__init__() + self.dc = dc; self.registry = registry; self.processor = processor + self.setObjectName("plotBlock") + self._src_combos: List[QComboBox] = [] + self._build() + + def _build(self): + 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) + self._name = QLineEdit(self.dc.name); self._name.setObjectName("plotBlockTitle") + self._name.textChanged.connect(lambda t: setattr(self.dc,"name",t)) + hl.addWidget(self._name, 1) + self._en = QCheckBox(); self._en.setChecked(self.dc.enabled) + self._en.setToolTip("Enable") + self._en.toggled.connect(lambda v: setattr(self.dc,"enabled",v)) + hl.addWidget(self._en) + rm = QToolButton(); rm.setText("✕"); rm.setObjectName("plotRemoveBtn") + rm.setFixedSize(22,22); rm.clicked.connect(lambda: self.removed.emit(self)) + hl.addWidget(rm) + outer.addWidget(hdr) + + # Body + body = QWidget(); body.setObjectName("plotBlockSettings") + bl = QVBoxLayout(body); bl.setContentsMargins(10,8,10,10); bl.setSpacing(6) + + # ID / unit row + meta = QHBoxLayout() + meta.addWidget(QLabel("ID:")) + self._id = QLineEdit(self.dc.channel_id) + self._id.setObjectName("traceLabel"); self._id.setFixedWidth(100) + self._id.textChanged.connect(lambda t: setattr(self.dc,"channel_id",t)) + meta.addWidget(self._id) + meta.addWidget(QLabel("Unit:")) + self._unit = QLineEdit(self.dc.unit) + self._unit.setObjectName("traceLabel"); self._unit.setFixedWidth(60) + self._unit.textChanged.connect(lambda t: setattr(self.dc,"unit",t)) + meta.addWidget(self._unit); meta.addStretch() + bl.addLayout(meta) + + # Kind combo + help + k_row = QHBoxLayout(); k_row.addWidget(QLabel("Operation:")) + self._kind_cb = QComboBox(); self._kind_cb.setObjectName("channelPickerCb") + self._kind_cb.addItems([_KIND_DISPLAY[k] for k in _ALL_KINDS]) + cur_idx = _ALL_KINDS.index(self.dc.kind) if self.dc.kind in _ALL_KINDS else 0 + self._kind_cb.setCurrentIndex(cur_idx) + self._kind_cb.currentIndexChanged.connect(self._on_kind) + k_row.addWidget(self._kind_cb); k_row.addStretch() + bl.addLayout(k_row) + self._help = QLabel(_KIND_HELP.get(self.dc.kind,"")) + self._help.setObjectName("traceSource"); self._help.setWordWrap(True) + bl.addWidget(self._help) + + # RMS window + self._win_row = QHBoxLayout() + self._win_row.addWidget(QLabel("Window samples:")) + self._win_sp = QSpinBox(); self._win_sp.setRange(2,10000) + self._win_sp.setValue(self.dc.params.get("window",20)) + self._win_sp.setObjectName("traceWidthSpin"); self._win_sp.setFixedWidth(80) + self._win_sp.valueChanged.connect(lambda v: self.dc.params.update({"window":v})) + self._win_row.addWidget(self._win_sp); self._win_row.addStretch() + 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") + src_init = [self.dc.sources[0]] if (_is_deriv and self.dc.sources) else self.dc.sources + for s in src_init: + 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) + + # W.r.t. — only for derivative/second_derivative + self._wrt_grp = QGroupBox("With Respect To"); self._wrt_grp.setObjectName("cfgGlobalBar") + wrt_lay = QVBoxLayout(self._wrt_grp); wrt_lay.setContentsMargins(6,6,6,6); wrt_lay.setSpacing(4) + wrt_top = QHBoxLayout(); wrt_top.addWidget(QLabel("Variable:")) + self._wrt_cb = QComboBox(); self._wrt_cb.setObjectName("channelPickerCb") + self._wrt_cb.addItems(["Time", "Channel"]) + _cur_wrt = self.dc.params.get("deriv_wrt", "time") + self._wrt_cb.setCurrentIndex(1 if _cur_wrt == "channel" else 0) + self._wrt_cb.currentIndexChanged.connect(self._on_wrt_changed) + wrt_top.addWidget(self._wrt_cb); wrt_top.addStretch() + wrt_lay.addLayout(wrt_top) + self._wrt_x_row = QWidget() + wx_lay = QHBoxLayout(self._wrt_x_row); wx_lay.setContentsMargins(0,0,0,0); wx_lay.setSpacing(6) + wx_lay.addWidget(QLabel("Channel (x):")) + self._wrt_x_cb = _channel_combo(self.registry, self.processor, + include_derived=True, show_unit=True) + self._wrt_x_cb.setObjectName("channelPickerCb") + if _cur_wrt == "channel" and len(self.dc.sources) > 1: + x_src = self.dc.sources[1] + for i in range(self._wrt_x_cb.count()): + if self._wrt_x_cb.itemData(i) == x_src: + self._wrt_x_cb.setCurrentIndex(i); break + wx_lay.addWidget(self._wrt_x_cb, 1) + wrt_lay.addWidget(self._wrt_x_row) + bl.addWidget(self._wrt_grp) + + # Code editor + self._code_grp = QGroupBox("Code") + code_lay = QVBoxLayout(self._code_grp); code_lay.setContentsMargins(6,4,6,4) + self._code = QTextEdit(); self._code.setObjectName("codeEditor") + self._code.setMinimumHeight(90); self._code.setMaximumHeight(180) + txt = self.dc.expression if self.dc.kind=="expression" else self.dc.script + self._code.setPlainText(txt) + code_lay.addWidget(self._code) + bl.addWidget(self._code_grp) + + # Status + apply + ap_row = QHBoxLayout() + self._st = QLabel(""); self._st.setObjectName("traceSource") + ap_row.addWidget(self._st, 1) + ap = QPushButton("✓ Apply"); ap.setObjectName("applyButton") + ap.clicked.connect(self._apply) + ap_row.addWidget(ap) + bl.addLayout(ap_row) + + outer.addWidget(body) + self._update_visibility() + + def _add_src(self, src=None): + row = QHBoxLayout() + # Include derived channels as valid sources (enables chaining) + cb = _channel_combo(self.registry, self.processor, + include_derived=True, show_unit=True) + cb.setObjectName("channelPickerCb") + if src: + for i in range(cb.count()): + if cb.itemData(i) == src: + cb.setCurrentIndex(i); break + rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn") + rm.setFixedSize(22,22); rm.clicked.connect(lambda: self._rm_src(row, cb)) + row.addWidget(cb,1); row.addWidget(rm) + self._src_vlay.addLayout(row) + self._src_combos.append(cb) + + def _rm_src(self, row, cb): + if cb in self._src_combos: + self._src_combos.remove(cb) + while row.count(): + it = row.takeAt(0) + if it.widget(): it.widget().deleteLater() + + def _on_kind(self, idx: int): + k = _ALL_KINDS[idx] + self.dc.kind = k + self._help.setText(_KIND_HELP.get(k,"")) + self._update_visibility() + + def _on_wrt_changed(self, idx: int): + self._wrt_x_row.setVisible(idx == 1) + + def _update_visibility(self): + is_deriv = self.dc.kind in ("derivative", "second_derivative") + self._code_grp.setVisible(self.dc.kind in _SCRIPT) + self._win_widget.setVisible(self.dc.kind == "rms") + 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): + 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" + self.dc.params["deriv_wrt"] = wrt + if wrt == "channel": + x_src = self._wrt_x_cb.currentData() + if x_src: + sources.append(x_src) + self.dc.sources = sources + if self.dc.kind == "expression": + self.dc.expression = self._code.toPlainText().strip() + elif self.dc.kind in ("function","custom_script"): + self.dc.script = self._code.toPlainText() + err = self.processor.add_derived(self.dc) + if err: + self._st.setText(f"⚠ {err[:100]}") + self._st.setStyleSheet("color:#ef4444;") + else: + self._st.setText("✓ Applied") + self._st.setStyleSheet("color:#22c55e;") + self.applied.emit(self.dc) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Signals List Tab — flat list of all signals with enable/disable +# ══════════════════════════════════════════════════════════════════════════════ + +class SignalsListTab(QWidget): + visibility_changed = pyqtSignal() + channel_toggled = pyqtSignal(str, str, bool) # dev_id, ch_id, enabled + + def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): + super().__init__() + self.registry = registry + self.processor = processor + self._build() + + def _build(self): + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._cont = QWidget() + self._lay = QVBoxLayout(self._cont) + self._lay.setContentsMargins(8,8,8,8); self._lay.setSpacing(2) + scroll.setWidget(self._cont) + root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0) + root.addWidget(scroll) + self.refresh() + + def refresh(self): + while self._lay.count(): + it = self._lay.takeAt(0) + if it.widget(): it.widget().deleteLater() + + for dev in self.registry.all_instances(): + for ch in dev.info.channels: + dev_id_cap = dev.info.device_id + ch_id_cap = ch.channel_id + self._lay.addWidget(self._make_row( + color = ch.color, + name = f"{ch.channel_id} — {ch.name}", + unit = ch.unit, + source = dev_id_cap, + enabled = ch.enabled, + on_toggle = lambda v, c=ch, d=dev_id_cap, ci=ch_id_cap: ( + setattr(c, "enabled", v), + self.visibility_changed.emit(), + self.channel_toggled.emit(d, ci, v), + ), + )) + + derived = self.processor.get_derived() + if self.registry.all_instances() and derived: + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider") + self._lay.addWidget(div) + + for dc in derived: + self._lay.addWidget(self._make_row( + color = dc.color, + name = f"{dc.channel_id} — {dc.name}", + unit = dc.unit, + source = "virtual", + enabled = dc.enabled, + on_toggle = lambda v, d=dc: (setattr(d, "enabled", v), self.visibility_changed.emit()), + )) + + self._lay.addStretch() + + def _make_row(self, color: str, name: str, unit: str, source: str, + enabled: bool, on_toggle) -> QFrame: + row = QFrame(); row.setObjectName("traceRow") + lay = QHBoxLayout(row); lay.setContentsMargins(8,4,8,4); lay.setSpacing(8) + + dot = QLabel("●"); dot.setStyleSheet(f"color:{color};"); dot.setFixedWidth(14) + lay.addWidget(dot) + + chk = QCheckBox(name); chk.setChecked(enabled) + chk.toggled.connect(on_toggle) + lay.addWidget(chk, 1) + + if unit: + ul = QLabel(unit); ul.setObjectName("traceSource") + ul.setFixedWidth(52) + ul.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + lay.addWidget(ul) + + sl = QLabel(source); sl.setObjectName("traceSource") + sl.setFixedWidth(72) + sl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + lay.addWidget(sl) + + return row + + +# ══════════════════════════════════════════════════════════════════════════════ +# ChannelsWindow — main +# ══════════════════════════════════════════════════════════════════════════════ + +class ChannelsWindow(QWidget): + pipeline_changed = pyqtSignal() + derived_changed = pyqtSignal() + closed = pyqtSignal() + + def __init__(self, registry: DeviceRegistry, + processor: SignalProcessor, parent=None): + super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) + self.registry = registry + self.processor = processor + + self.setWindowTitle("Channels") + self.setMinimumSize(640, 520) + self.resize(740, 640) + self._build() + + def _build(self): + root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0); root.setSpacing(0) + + hdr = QWidget(); hdr.setObjectName("devWindowTitleBar"); hdr.setFixedHeight(44) + hl = QHBoxLayout(hdr); hl.setContentsMargins(14,0,14,0) + hl.addWidget(QLabel("CHANNELS").also(lambda w: w.setObjectName("devWindowTitle")), 1) + root.addWidget(hdr) + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider"); root.addWidget(div) + + self._pip_tab = PipelineTab(self.registry, self.processor) + self._pip_tab.changed.connect(self.pipeline_changed) + self._pip_tab.derived_changed.connect(self.derived_changed) + root.addWidget(self._pip_tab, 1) + + self.refresh_derived() + + def refresh_derived(self): + self._pip_tab.refresh_derived() + + def on_channel_enabled_changed(self, dev_id: str, ch_id: str, enabled: bool): + self._pip_tab.on_channel_enabled_changed(dev_id, ch_id, enabled) + + def on_device_removed(self, dev_id: str): + self._pip_tab.on_device_removed(dev_id) + + def on_device_reconfigured(self, dev_id: str): + self._pip_tab.on_device_reconfigured(dev_id) + + def on_device_added(self): + self._pip_tab.on_device_added() + + def on_channel_name_changed(self, dev_id: str, ch_id: str, ch_name: str): + self._pip_tab.on_channel_name_changed(dev_id, ch_id, ch_name) + + def on_channel_unit_changed(self, dev_id: str, ch_id: str, unit: str): + self._pip_tab.on_channel_unit_changed(dev_id, ch_id, unit) + + def closeEvent(self, e: QCloseEvent): + self.closed.emit(); e.accept() diff --git a/ui/windows/signals_window.py b/ui/windows/signals_window.py deleted file mode 100644 index 8a63bf1..0000000 --- a/ui/windows/signals_window.py +++ /dev/null @@ -1,878 +0,0 @@ -""" -ui/windows/signals_window.py - -SIGNALS window — toolbar section 3. - -Tabs: - Channels — per-channel filter stack; user-curated list with add/remove - Derived — create virtual channels from physical or derived ones - Signals — flat combined list of all physical + derived signals -""" - -from __future__ import annotations -from typing import List, Optional, Set, Tuple - -from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, - QScrollArea, QFrame, QTabWidget, QComboBox, QLineEdit, - QDoubleSpinBox, QSpinBox, QCheckBox, QTextEdit, QGroupBox, - QToolButton, QSplitter, -) -from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtGui import QCloseEvent - -from devices.device_registry import DeviceRegistry -from core.signal_processor import ( - SignalProcessor, ChannelPipeline, DerivedChannel, - FilterBase, FILTER_CLASSES, -) - -_DERIVED_COLORS = [ - "#f72585","#7209b7","#4361ee","#4cc9f0", - "#f77f00","#d62828","#588157","#e9c46a", -] - - -def _channel_combo(registry: DeviceRegistry, - processor: Optional[SignalProcessor] = None, - include_derived: bool = False, - show_unit: bool = False) -> QComboBox: - cb = QComboBox(); cb.setObjectName("channelPickerCb") - for dev in registry.all_instances(): - for ch in dev.info.channels: - if not ch.enabled: - continue - label = f"{dev.info.device_id} / {ch.channel_id} ({ch.name})" - if show_unit and ch.unit: - label += f" [{ch.unit}]" - cb.addItem(label, userData=(dev.info.device_id, ch.channel_id)) - if include_derived and processor: - for dc in processor.get_derived(): - label = f"[derived] {dc.channel_id} ({dc.name})" - if show_unit and dc.unit: - label += f" [{dc.unit}]" - cb.addItem(label, userData=("derived", dc.channel_id)) - return cb - - -# ══════════════════════════════════════════════════════════════════════════════ -# Filter row -# ══════════════════════════════════════════════════════════════════════════════ - -_PARAM_SPECS = { - "moving_average": [("window", "int", 10, 1, 5000)], - "median": [("window", "int", 5, 1, 500)], - "low_pass": [("alpha", "float", 0.1, 0.001, 1.0)], - "high_pass": [("alpha", "float", 0.9, 0.001, 1.0)], - "scale_offset": [("scale", "float", 1.0, -1e6, 1e6), - ("offset", "float", 0.0, -1e9, 1e9)], - "derivative": [], - "integral": [], -} - -class FilterRow(QFrame): - removed = pyqtSignal(object) - changed = pyqtSignal() - - def __init__(self, filt: FilterBase): - super().__init__() - self.filt = filt - self.setObjectName("traceRow") - self._build() - - def _build(self): - lay = QHBoxLayout(self); lay.setContentsMargins(6,4,6,4); lay.setSpacing(8) - - lbl = QLabel(self.filt.name.replace("_"," ").title()) - lbl.setObjectName("traceSource"); lbl.setMinimumWidth(120) - lay.addWidget(lbl) - - for pname, ptype, default, lo, hi in _PARAM_SPECS.get(self.filt.name, []): - pl = QLabel(f"{pname}:"); pl.setObjectName("pwmLabel") - lay.addWidget(pl) - if ptype == "int": - w = QSpinBox(); w.setRange(int(lo), int(hi)) - w.setValue(int(self.filt.params.get(pname, default))) - w.setFixedWidth(70); w.setObjectName("traceWidthSpin") - w.valueChanged.connect(lambda v, p=pname: self._upd(p, v)) - else: - w = QDoubleSpinBox(); w.setRange(lo, hi); w.setDecimals(4) - w.setValue(float(self.filt.params.get(pname, default))) - w.setFixedWidth(88); w.setObjectName("traceWidthSpin") - w.valueChanged.connect(lambda v, p=pname: self._upd(p, v)) - lay.addWidget(w) - - lay.addStretch() - rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn") - rm.setFixedSize(22, 22); rm.clicked.connect(lambda: self.removed.emit(self)) - lay.addWidget(rm) - - def _upd(self, name, value): - self.filt.params[name] = value - cls = FILTER_CLASSES.get(self.filt.name) - if cls: - nf = cls(**self.filt.params); self.filt.__dict__.update(nf.__dict__) - self.changed.emit() - - -# monkey-patch QLabel.also for inline objectName -from PyQt6.QtWidgets import QLabel as _QLabel -def _also(self, fn): fn(self); return self -_QLabel.also = _also - - -# ══════════════════════════════════════════════════════════════════════════════ -# Channel Pipeline Block — collapsible filter stack for one channel -# ══════════════════════════════════════════════════════════════════════════════ - -class ChannelPipelineBlock(QFrame): - changed = pyqtSignal() - removed = pyqtSignal(object) # emits self - - def __init__(self, dev_id: str, ch_id: str, ch_name: str, unit: str, - processor: SignalProcessor): - super().__init__() - self.dev_id = dev_id - self.ch_id = ch_id - self.processor = processor - self._rows: List[FilterRow] = [] - self._cur_pipeline: Optional[ChannelPipeline] = None - self._expanded = False - self.setObjectName("plotBlock") - self._build(ch_name, unit) - self._load() - - def _build(self, ch_name: str, unit: str): - self._ch_name = ch_name - 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"{self.dev_id} / {self.ch_id} ({ch_name})" - 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") - bl = QVBoxLayout(self._body) - bl.setContentsMargins(10,8,10,10); bl.setSpacing(6) - - self._en_chk = QCheckBox("Pipeline enabled"); self._en_chk.setChecked(True) - self._en_chk.toggled.connect(self._on_enable) - bl.addWidget(self._en_chk) - - self._filter_area = QWidget() - self._filter_lay = QVBoxLayout(self._filter_area) - self._filter_lay.setContentsMargins(0,0,0,0); self._filter_lay.setSpacing(3) - bl.addWidget(self._filter_area) - - add_bar = QHBoxLayout() - self._filt_cb = QComboBox(); self._filt_cb.setObjectName("channelPickerCb") - self._filt_cb.addItems([k.replace("_"," ").title() for k in FILTER_CLASSES]) - add_bar.addWidget(self._filt_cb, 1) - add_btn = QPushButton("+ Add Filter"); add_btn.setObjectName("addTraceBtn") - add_btn.clicked.connect(self._add_filter) - add_bar.addWidget(add_btn) - bl.addLayout(add_bar) - - ap_row = QHBoxLayout(); ap_row.addStretch() - ap_btn = QPushButton("✓ Apply"); ap_btn.setObjectName("applyButton") - ap_btn.clicked.connect(self._push) - ap_row.addWidget(ap_btn) - bl.addLayout(ap_row) - - outer.addWidget(self._body) - self._body.setVisible(False) - - def _refresh_title(self): - title = f"{self.dev_id} / {self.ch_id} ({self._ch_name})" - if self._unit: - title += f" [{self._unit}]" - self._title_lbl.setText(title) - - def update_name(self, ch_name: str): - self._ch_name = ch_name - self._refresh_title() - - def update_unit(self, unit: str): - self._unit = unit - self._refresh_title() - - def _toggle(self): - self._expanded = not self._expanded - self._body.setVisible(self._expanded) - self._toggle_btn.setText("▼" if self._expanded else "▶") - - def _load(self): - pipeline = self.processor.get_pipeline(self.dev_id, self.ch_id) - if pipeline is None: - pipeline = ChannelPipeline(device_id=self.dev_id, channel_id=self.ch_id) - self.processor.set_pipeline(pipeline) - self._cur_pipeline = pipeline - self._en_chk.setChecked(pipeline.enabled) - for f in pipeline.filters: - self._add_filter_row(f) - - def _add_filter(self): - key = list(FILTER_CLASSES.keys())[self._filt_cb.currentIndex()] - filt = FILTER_CLASSES[key]() - if self._cur_pipeline: - self._cur_pipeline.filters.append(filt) - self._add_filter_row(filt) - self._push() - - def _add_filter_row(self, filt: FilterBase): - row = FilterRow(filt) - row.removed.connect(self._remove_filter) - row.changed.connect(self._push) - self._rows.append(row) - self._filter_lay.addWidget(row) - - def _remove_filter(self, row: FilterRow): - if self._cur_pipeline and row.filt in self._cur_pipeline.filters: - self._cur_pipeline.filters.remove(row.filt) - self._rows.remove(row) - self._filter_lay.removeWidget(row); row.deleteLater() - self._push() - - def _on_enable(self, v: bool): - if self._cur_pipeline: - self._cur_pipeline.enabled = v - self._push() - - def _push(self): - if self._cur_pipeline: - self.processor.set_pipeline(self._cur_pipeline) - self.changed.emit() - - -# ══════════════════════════════════════════════════════════════════════════════ -# Pipeline Tab — filter pipelines per channel + virtual channel creation -# ══════════════════════════════════════════════════════════════════════════════ - -class PipelineTab(QWidget): - changed = pyqtSignal() - derived_changed = pyqtSignal() - - def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): - super().__init__() - self.registry = registry - self.processor = processor - self._shown: Set[Tuple[str,str]] = set() - self._blocks: dict[Tuple[str,str], ChannelPipelineBlock] = {} - self._derived_blocks: List[DerivedBlock] = [] - self._color_idx = 0 - self._build() - - def _build(self): - root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0); root.setSpacing(0) - - splitter = QSplitter(Qt.Orientation.Vertical) - splitter.setObjectName("channelSplitter") - root.addWidget(splitter) - - # ── Physical Channels pane ────────────────────────────────────────────── - phys_w = QWidget() - phys_l = QVBoxLayout(phys_w); phys_l.setContentsMargins(0,0,0,0); phys_l.setSpacing(0) - - 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("Physical Channels"); pb_lbl.setObjectName("devWindowTitle") - pb_lay.addWidget(pb_lbl, 1) - self._avail_cb = QComboBox(); self._avail_cb.setObjectName("channelPickerCb") - pb_lay.addWidget(self._avail_cb) - add_btn = QPushButton("+ Add"); add_btn.setObjectName("addTraceBtn") - add_btn.clicked.connect(self._on_add_channel) - pb_lay.addWidget(add_btn) - phys_l.addWidget(phys_bar) - - div1 = QFrame(); div1.setFrameShape(QFrame.Shape.HLine) - div1.setObjectName("devWindowDivider"); phys_l.addWidget(div1) - - phys_scroll = QScrollArea(); phys_scroll.setWidgetResizable(True) - phys_scroll.setObjectName("deviceScroll") - phys_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self._phys_cont = QWidget() - self._phys_inner = QVBoxLayout(self._phys_cont) - self._phys_inner.setContentsMargins(10,10,10,10); self._phys_inner.setSpacing(6) - self._phys_inner.addStretch() - phys_scroll.setWidget(self._phys_cont) - phys_l.addWidget(phys_scroll, 1) - splitter.addWidget(phys_w) - - # ── Virtual Channels pane ─────────────────────────────────────────────── - virt_w = QWidget() - virt_l = QVBoxLayout(virt_w); virt_l.setContentsMargins(0,0,0,0); virt_l.setSpacing(0) - - 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("Virtual Channels"); vb_lbl.setObjectName("devWindowTitle") - vb_lay.addWidget(vb_lbl, 1) - self._new_kind = QComboBox(); self._new_kind.setObjectName("channelPickerCb") - self._new_kind.addItems([_KIND_DISPLAY[k] for k in _ALL_KINDS]) - vb_lay.addWidget(self._new_kind) - add_virt = QPushButton("+ Add Virtual"); add_virt.setObjectName("addTraceBtn") - add_virt.clicked.connect(self._add_virtual) - vb_lay.addWidget(add_virt) - virt_l.addWidget(virt_bar) - - div2 = QFrame(); div2.setFrameShape(QFrame.Shape.HLine) - div2.setObjectName("devWindowDivider"); virt_l.addWidget(div2) - - virt_scroll = QScrollArea(); virt_scroll.setWidgetResizable(True) - virt_scroll.setObjectName("deviceScroll") - virt_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self._virt_cont = QWidget() - self._virt_inner = QVBoxLayout(self._virt_cont) - self._virt_inner.setContentsMargins(10,10,10,10); self._virt_inner.setSpacing(6) - self._virt_inner.addStretch() - virt_scroll.setWidget(self._virt_cont) - virt_l.addWidget(virt_scroll, 1) - splitter.addWidget(virt_w) - - splitter.setSizes([300, 300]) - - for dev in self.registry.all_instances(): - for ch in dev.info.channels: - if ch.enabled: - self._add_block(dev.info.device_id, ch.channel_id, ch.name, ch.unit) - - self._refresh_avail_cb() - - # ── Physical channel block management ───────────────────────────────────── - - def _add_block(self, dev_id: str, ch_id: str, ch_name: str, unit: str): - if (dev_id, ch_id) in self._shown: - return - self._shown.add((dev_id, ch_id)) - block = ChannelPipelineBlock(dev_id, ch_id, ch_name, unit, self.processor) - block.changed.connect(self.changed) - block.removed.connect(self._on_remove_block) - self._blocks[(dev_id, ch_id)] = block - self._phys_inner.insertWidget(self._phys_inner.count() - 1, block) - - def on_channel_enabled_changed(self, dev_id: str, ch_id: str, enabled: bool): - key = (dev_id, ch_id) - if enabled: - if key not in self._shown: - dev = self.registry.get_instance(dev_id) - if dev: - ch = dev.get_channel(ch_id) - if ch: - self._add_block(dev_id, ch_id, ch.name, ch.unit) - else: - block = self._blocks.get(key) - if block: - block.setVisible(True) - else: - block = self._blocks.get(key) - if block: - block.setVisible(False) - self._refresh_avail_cb() - - def on_device_removed(self, dev_id: str): - keys = [(d, c) for (d, c) in list(self._shown) if d == dev_id] - for key in keys: - block = self._blocks.get(key) - if block: - self._on_remove_block(block) - self._refresh_avail_cb() - - def on_device_reconfigured(self, dev_id: str): - dev = self.registry.get_instance(dev_id) - if dev is None: - self.on_device_removed(dev_id) - return - valid = {ch.channel_id for ch in dev.info.channels} - keys = [(d, c) for (d, c) in list(self._shown) - if d == dev_id and c not in valid] - for key in keys: - block = self._blocks.get(key) - if block: - self._on_remove_block(block) - self._refresh_avail_cb() - - def on_device_added(self): - self._refresh_avail_cb() - - def on_channel_name_changed(self, dev_id: str, ch_id: str, ch_name: str): - key = (dev_id, ch_id) - if key in self._blocks: - self._blocks[key].update_name(ch_name) - else: - dev = self.registry.get_instance(dev_id) - if dev: - ch = dev.get_channel(ch_id) - if ch and ch.enabled: - self._add_block(dev_id, ch_id, ch_name, ch.unit) - self._refresh_avail_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 _on_add_channel(self): - data = self._avail_cb.currentData() - if not data: - return - dev_id, ch_id = data - dev = self.registry.get_instance(dev_id) - if not dev: - return - ch = dev.get_channel(ch_id) - if not ch: - return - self._add_block(dev_id, ch_id, ch.name, ch.unit) - self._refresh_avail_cb() - - def _on_remove_block(self, block: ChannelPipelineBlock): - key = (block.dev_id, block.ch_id) - self._shown.discard(key) - self._blocks.pop(key, None) - self._phys_inner.removeWidget(block) - block.deleteLater() - self._refresh_avail_cb() - - def _refresh_avail_cb(self): - self._avail_cb.clear() - for dev in self.registry.all_instances(): - for ch in dev.info.channels: - if not ch.enabled: - continue - if (dev.info.device_id, ch.channel_id) not in self._shown: - label = f"{dev.info.device_id} / {ch.channel_id} ({ch.name})" - if ch.unit: - label += f" [{ch.unit}]" - self._avail_cb.addItem(label, - userData=(dev.info.device_id, ch.channel_id)) - - # ── Virtual channel management ──────────────────────────────────────────── - - def _add_virtual(self): - kind = _ALL_KINDS[self._new_kind.currentIndex()] - 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_V{n}", name=f"Virtual Channel {n}", - kind=kind, color=color) - blk = self._make_derived_block(dc) - self._virt_inner.insertWidget(self._virt_inner.count() - 1, blk) - - def _make_derived_block(self, dc: DerivedChannel) -> DerivedBlock: - blk = DerivedBlock(dc, self.registry, self.processor) - blk.removed.connect(self._rm_virtual) - blk.applied.connect(lambda _: self.derived_changed.emit()) - self._derived_blocks.append(blk) - return blk - - def _rm_virtual(self, blk: DerivedBlock): - self.processor.remove_derived(blk.dc.channel_id) - self._derived_blocks.remove(blk) - self._virt_inner.removeWidget(blk); blk.deleteLater() - self.derived_changed.emit() - - def refresh_derived(self): - """Rebuild DerivedBlock widgets from processor state (called after profile load).""" - for blk in self._derived_blocks: - self._virt_inner.removeWidget(blk); blk.deleteLater() - self._derived_blocks.clear() - self._color_idx = 0 - - for dc in self.processor.get_derived(): - blk = self._make_derived_block(dc) - self._virt_inner.insertWidget(self._virt_inner.count() - 1, blk) - - self._color_idx = len(self._derived_blocks) - - -# ══════════════════════════════════════════════════════════════════════════════ -# Derived editor for one DerivedChannel -# ══════════════════════════════════════════════════════════════════════════════ - -_BUILTIN = ["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_HELP = { - "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", - "expression": "x = list of source values, t = elapsed time (s)\nExample: x[0] * 3.14159 / 180", - "function": "def compute(x, t):\n # x = source values list\n return x[0] * 2.0", - "custom_script":"Full Python. Must define: def compute(x, t): ...", -} - - -class DerivedBlock(QFrame): - removed = pyqtSignal(object) - applied = pyqtSignal(object) # DerivedChannel - - def __init__(self, dc: DerivedChannel, registry: DeviceRegistry, - processor: SignalProcessor): - super().__init__() - self.dc = dc; self.registry = registry; self.processor = processor - self.setObjectName("plotBlock") - self._src_combos: List[QComboBox] = [] - self._build() - - def _build(self): - 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) - self._name = QLineEdit(self.dc.name); self._name.setObjectName("plotBlockTitle") - self._name.textChanged.connect(lambda t: setattr(self.dc,"name",t)) - hl.addWidget(self._name, 1) - self._en = QCheckBox(); self._en.setChecked(self.dc.enabled) - self._en.setToolTip("Enable") - self._en.toggled.connect(lambda v: setattr(self.dc,"enabled",v)) - hl.addWidget(self._en) - rm = QToolButton(); rm.setText("✕"); rm.setObjectName("plotRemoveBtn") - rm.setFixedSize(22,22); rm.clicked.connect(lambda: self.removed.emit(self)) - hl.addWidget(rm) - outer.addWidget(hdr) - - # Body - body = QWidget(); body.setObjectName("plotBlockSettings") - bl = QVBoxLayout(body); bl.setContentsMargins(10,8,10,10); bl.setSpacing(6) - - # ID / unit row - meta = QHBoxLayout() - meta.addWidget(QLabel("ID:")) - self._id = QLineEdit(self.dc.channel_id) - self._id.setObjectName("traceLabel"); self._id.setFixedWidth(100) - self._id.textChanged.connect(lambda t: setattr(self.dc,"channel_id",t)) - meta.addWidget(self._id) - meta.addWidget(QLabel("Unit:")) - self._unit = QLineEdit(self.dc.unit) - self._unit.setObjectName("traceLabel"); self._unit.setFixedWidth(60) - self._unit.textChanged.connect(lambda t: setattr(self.dc,"unit",t)) - meta.addWidget(self._unit); meta.addStretch() - bl.addLayout(meta) - - # Kind combo + help - k_row = QHBoxLayout(); k_row.addWidget(QLabel("Operation:")) - self._kind_cb = QComboBox(); self._kind_cb.setObjectName("channelPickerCb") - self._kind_cb.addItems([_KIND_DISPLAY[k] for k in _ALL_KINDS]) - cur_idx = _ALL_KINDS.index(self.dc.kind) if self.dc.kind in _ALL_KINDS else 0 - self._kind_cb.setCurrentIndex(cur_idx) - self._kind_cb.currentIndexChanged.connect(self._on_kind) - k_row.addWidget(self._kind_cb); k_row.addStretch() - bl.addLayout(k_row) - self._help = QLabel(_KIND_HELP.get(self.dc.kind,"")) - self._help.setObjectName("traceSource"); self._help.setWordWrap(True) - bl.addWidget(self._help) - - # RMS window - self._win_row = QHBoxLayout() - self._win_row.addWidget(QLabel("Window samples:")) - self._win_sp = QSpinBox(); self._win_sp.setRange(2,10000) - self._win_sp.setValue(self.dc.params.get("window",20)) - self._win_sp.setObjectName("traceWidthSpin"); self._win_sp.setFixedWidth(80) - self._win_sp.valueChanged.connect(lambda v: self.dc.params.update({"window":v})) - self._win_row.addWidget(self._win_sp); self._win_row.addStretch() - 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") - src_init = [self.dc.sources[0]] if (_is_deriv and self.dc.sources) else self.dc.sources - for s in src_init: - 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) - - # W.r.t. — only for derivative/second_derivative - self._wrt_grp = QGroupBox("With Respect To"); self._wrt_grp.setObjectName("cfgGlobalBar") - wrt_lay = QVBoxLayout(self._wrt_grp); wrt_lay.setContentsMargins(6,6,6,6); wrt_lay.setSpacing(4) - wrt_top = QHBoxLayout(); wrt_top.addWidget(QLabel("Variable:")) - self._wrt_cb = QComboBox(); self._wrt_cb.setObjectName("channelPickerCb") - self._wrt_cb.addItems(["Time", "Channel"]) - _cur_wrt = self.dc.params.get("deriv_wrt", "time") - self._wrt_cb.setCurrentIndex(1 if _cur_wrt == "channel" else 0) - self._wrt_cb.currentIndexChanged.connect(self._on_wrt_changed) - wrt_top.addWidget(self._wrt_cb); wrt_top.addStretch() - wrt_lay.addLayout(wrt_top) - self._wrt_x_row = QWidget() - wx_lay = QHBoxLayout(self._wrt_x_row); wx_lay.setContentsMargins(0,0,0,0); wx_lay.setSpacing(6) - wx_lay.addWidget(QLabel("Channel (x):")) - self._wrt_x_cb = _channel_combo(self.registry, self.processor, - include_derived=True, show_unit=True) - self._wrt_x_cb.setObjectName("channelPickerCb") - if _cur_wrt == "channel" and len(self.dc.sources) > 1: - x_src = self.dc.sources[1] - for i in range(self._wrt_x_cb.count()): - if self._wrt_x_cb.itemData(i) == x_src: - self._wrt_x_cb.setCurrentIndex(i); break - wx_lay.addWidget(self._wrt_x_cb, 1) - wrt_lay.addWidget(self._wrt_x_row) - bl.addWidget(self._wrt_grp) - - # Code editor - self._code_grp = QGroupBox("Code") - code_lay = QVBoxLayout(self._code_grp); code_lay.setContentsMargins(6,4,6,4) - self._code = QTextEdit(); self._code.setObjectName("codeEditor") - self._code.setMinimumHeight(90); self._code.setMaximumHeight(180) - txt = self.dc.expression if self.dc.kind=="expression" else self.dc.script - self._code.setPlainText(txt) - code_lay.addWidget(self._code) - bl.addWidget(self._code_grp) - - # Status + apply - ap_row = QHBoxLayout() - self._st = QLabel(""); self._st.setObjectName("traceSource") - ap_row.addWidget(self._st, 1) - ap = QPushButton("✓ Apply"); ap.setObjectName("applyButton") - ap.clicked.connect(self._apply) - ap_row.addWidget(ap) - bl.addLayout(ap_row) - - outer.addWidget(body) - self._update_visibility() - - def _add_src(self, src=None): - row = QHBoxLayout() - # Include derived channels as valid sources (enables chaining) - cb = _channel_combo(self.registry, self.processor, - include_derived=True, show_unit=True) - cb.setObjectName("channelPickerCb") - if src: - for i in range(cb.count()): - if cb.itemData(i) == src: - cb.setCurrentIndex(i); break - rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn") - rm.setFixedSize(22,22); rm.clicked.connect(lambda: self._rm_src(row, cb)) - row.addWidget(cb,1); row.addWidget(rm) - self._src_vlay.addLayout(row) - self._src_combos.append(cb) - - def _rm_src(self, row, cb): - if cb in self._src_combos: - self._src_combos.remove(cb) - while row.count(): - it = row.takeAt(0) - if it.widget(): it.widget().deleteLater() - - def _on_kind(self, idx: int): - k = _ALL_KINDS[idx] - self.dc.kind = k - self._help.setText(_KIND_HELP.get(k,"")) - self._update_visibility() - - def _on_wrt_changed(self, idx: int): - self._wrt_x_row.setVisible(idx == 1) - - def _update_visibility(self): - is_deriv = self.dc.kind in ("derivative", "second_derivative") - self._code_grp.setVisible(self.dc.kind in _SCRIPT) - self._win_widget.setVisible(self.dc.kind == "rms") - 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): - 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" - self.dc.params["deriv_wrt"] = wrt - if wrt == "channel": - x_src = self._wrt_x_cb.currentData() - if x_src: - sources.append(x_src) - self.dc.sources = sources - if self.dc.kind == "expression": - self.dc.expression = self._code.toPlainText().strip() - elif self.dc.kind in ("function","custom_script"): - self.dc.script = self._code.toPlainText() - err = self.processor.add_derived(self.dc) - if err: - self._st.setText(f"⚠ {err[:100]}") - self._st.setStyleSheet("color:#ef4444;") - else: - self._st.setText("✓ Applied") - self._st.setStyleSheet("color:#22c55e;") - self.applied.emit(self.dc) - - -# ══════════════════════════════════════════════════════════════════════════════ -# Signals List Tab — flat list of all signals with enable/disable -# ══════════════════════════════════════════════════════════════════════════════ - -class SignalsListTab(QWidget): - visibility_changed = pyqtSignal() - channel_toggled = pyqtSignal(str, str, bool) # dev_id, ch_id, enabled - - def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): - super().__init__() - self.registry = registry - self.processor = processor - self._build() - - def _build(self): - scroll = QScrollArea(); scroll.setWidgetResizable(True) - scroll.setObjectName("deviceScroll") - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self._cont = QWidget() - self._lay = QVBoxLayout(self._cont) - self._lay.setContentsMargins(8,8,8,8); self._lay.setSpacing(2) - scroll.setWidget(self._cont) - root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0) - root.addWidget(scroll) - self.refresh() - - def refresh(self): - while self._lay.count(): - it = self._lay.takeAt(0) - if it.widget(): it.widget().deleteLater() - - for dev in self.registry.all_instances(): - for ch in dev.info.channels: - dev_id_cap = dev.info.device_id - ch_id_cap = ch.channel_id - self._lay.addWidget(self._make_row( - color = ch.color, - name = f"{ch.channel_id} — {ch.name}", - unit = ch.unit, - source = dev_id_cap, - enabled = ch.enabled, - on_toggle = lambda v, c=ch, d=dev_id_cap, ci=ch_id_cap: ( - setattr(c, "enabled", v), - self.visibility_changed.emit(), - self.channel_toggled.emit(d, ci, v), - ), - )) - - derived = self.processor.get_derived() - if self.registry.all_instances() and derived: - div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) - div.setObjectName("devWindowDivider") - self._lay.addWidget(div) - - for dc in derived: - self._lay.addWidget(self._make_row( - color = dc.color, - name = f"{dc.channel_id} — {dc.name}", - unit = dc.unit, - source = "virtual", - enabled = dc.enabled, - on_toggle = lambda v, d=dc: (setattr(d, "enabled", v), self.visibility_changed.emit()), - )) - - self._lay.addStretch() - - def _make_row(self, color: str, name: str, unit: str, source: str, - enabled: bool, on_toggle) -> QFrame: - row = QFrame(); row.setObjectName("traceRow") - lay = QHBoxLayout(row); lay.setContentsMargins(8,4,8,4); lay.setSpacing(8) - - dot = QLabel("●"); dot.setStyleSheet(f"color:{color};"); dot.setFixedWidth(14) - lay.addWidget(dot) - - chk = QCheckBox(name); chk.setChecked(enabled) - chk.toggled.connect(on_toggle) - lay.addWidget(chk, 1) - - if unit: - ul = QLabel(unit); ul.setObjectName("traceSource") - ul.setFixedWidth(52) - ul.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - lay.addWidget(ul) - - sl = QLabel(source); sl.setObjectName("traceSource") - sl.setFixedWidth(72) - sl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - lay.addWidget(sl) - - return row - - -# ══════════════════════════════════════════════════════════════════════════════ -# ChannelsWindow — main -# ══════════════════════════════════════════════════════════════════════════════ - -class ChannelsWindow(QWidget): - pipeline_changed = pyqtSignal() - derived_changed = pyqtSignal() - closed = pyqtSignal() - - def __init__(self, registry: DeviceRegistry, - processor: SignalProcessor, parent=None): - super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) - self.registry = registry - self.processor = processor - - self.setWindowTitle("Channels") - self.setMinimumSize(640, 520) - self.resize(740, 640) - self._build() - - def _build(self): - root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0); root.setSpacing(0) - - hdr = QWidget(); hdr.setObjectName("devWindowTitleBar"); hdr.setFixedHeight(44) - hl = QHBoxLayout(hdr); hl.setContentsMargins(14,0,14,0) - hl.addWidget(QLabel("CHANNELS").also(lambda w: w.setObjectName("devWindowTitle")), 1) - root.addWidget(hdr) - div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) - div.setObjectName("devWindowDivider"); root.addWidget(div) - - self._pip_tab = PipelineTab(self.registry, self.processor) - self._pip_tab.changed.connect(self.pipeline_changed) - self._pip_tab.derived_changed.connect(self.derived_changed) - root.addWidget(self._pip_tab, 1) - - self.refresh_derived() - - def refresh_derived(self): - self._pip_tab.refresh_derived() - - def on_channel_enabled_changed(self, dev_id: str, ch_id: str, enabled: bool): - self._pip_tab.on_channel_enabled_changed(dev_id, ch_id, enabled) - - def on_device_removed(self, dev_id: str): - self._pip_tab.on_device_removed(dev_id) - - def on_device_reconfigured(self, dev_id: str): - self._pip_tab.on_device_reconfigured(dev_id) - - def on_device_added(self): - self._pip_tab.on_device_added() - - def on_channel_name_changed(self, dev_id: str, ch_id: str, ch_name: str): - self._pip_tab.on_channel_name_changed(dev_id, ch_id, ch_name) - - def on_channel_unit_changed(self, dev_id: str, ch_id: str, unit: str): - self._pip_tab.on_channel_unit_changed(dev_id, ch_id, unit) - - def closeEvent(self, e: QCloseEvent): - self.closed.emit(); e.accept() -- cgit v1.2.3