summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--devices/base_device.py1
-rw-r--r--ui/main_window.py166
-rw-r--r--ui/windows/config_window.py504
3 files changed, 571 insertions, 100 deletions
diff --git a/devices/base_device.py b/devices/base_device.py
index 32fe787..aa9f7f2 100644
--- a/devices/base_device.py
+++ b/devices/base_device.py
@@ -28,6 +28,7 @@ class ChannelConfig:
min_value: float = 0.0
max_value: float = 100.0
enabled: bool = True
+ is_output: bool = False
color: str = "#00d4ff"
extra: Dict[str, Any] = field(default_factory=dict)
diff --git a/ui/main_window.py b/ui/main_window.py
index 76c203d..22b01a0 100644
--- a/ui/main_window.py
+++ b/ui/main_window.py
@@ -105,9 +105,8 @@ from core.profile import Profile, ProfileManager
from ui.control_panel import ControlPanel
from ui.strip_chart import StripChartWidget
from ui.profile_manager_ui import ProfileButton
-from ui.windows.devices_window import DevicesWindow
-from ui.windows.channels_window import ChannelsWindow
-from ui.windows.plot_window import PlotWindow, build_default_layout
+from ui.windows.config_window import ConfigWindow
+from ui.windows.plot_window import build_default_layout
from ui.windows.settings_window import SettingsWindow
from plugins.plugin_manager import PluginManager
@@ -131,9 +130,7 @@ class MainWindow(QMainWindow):
self._settings = load_settings(SettingsWindow._defaults)
self._elapsed = 0
- self._win_devices = None
- self._win_channels = None
- self._win_plot = None
+ self._win_config = None
self._win_settings = None
_plugins_dir = os.path.join(os.path.dirname(os.path.dirname(
@@ -194,19 +191,21 @@ class MainWindow(QMainWindow):
self._view_menu.addAction(self._act_ctrl_panel)
self._view_menu.addSeparator()
- for label, name, opener in [
- ("Devices", "devices", self._open_devices),
- ("Channels", "channels", self._open_channels),
- ("Plot Builder", "plot", self._open_plot),
+ for label, tab_idx in [
+ ("Devices", 0),
+ ("Channels", 1),
+ ("Plot Builder", 2),
]:
act = QAction(label, self); act.setCheckable(True)
act.triggered.connect(
- lambda checked, nm=name, op=opener:
- op() if checked else
- (getattr(self, f"_win_{nm}") and getattr(self, f"_win_{nm}").hide())
+ lambda checked, ti=tab_idx:
+ self._open_config(ti) if checked else
+ (self._win_config and self._win_config.hide())
)
self._view_menu.addAction(act)
- setattr(self, f"_act_{name}", act)
+ self._act_devices = self._view_menu.actions()[-3]
+ self._act_channels = self._view_menu.actions()[-2]
+ self._act_plot = self._view_menu.actions()[-1]
self._view_plugin_sep = self._view_menu.addSeparator()
self._view_plugin_sep.setVisible(False)
@@ -368,8 +367,8 @@ class MainWindow(QMainWindow):
self._plugin_toolbar_actions[plugin.plugin_id] = tb_actions
self._chart.refresh()
- if self._win_plot:
- self._win_plot.refresh_channels()
+ if self._win_config:
+ self._win_config.refresh_plot()
self._status.setText(f"Plugin enabled: {plugin.name}")
def _uninstall_plugin(self, plugin_id: str):
@@ -404,8 +403,8 @@ class MainWindow(QMainWindow):
self.engine.remove_device(dev_id)
self._chart.refresh()
- if self._win_plot:
- self._win_plot.refresh_channels()
+ if self._win_config:
+ self._win_config.refresh_plot()
self._status.setText(f"Plugin disabled: {plugin.name}")
def plugin_enable(self, plugin_id: str):
@@ -422,55 +421,29 @@ class MainWindow(QMainWindow):
# ── Window management ─────────────────────────────────────────────────
- def _toggle_win(self, name: str, checked: bool, btn: QPushButton):
- creators = {
- "devices": self._open_devices,
- "channels": self._open_channels,
- "plot": self._open_plot,
- "settings": self._open_settings,
- }
- wins = {
- "devices": "_win_devices",
- "channels": "_win_channels",
- "plot": "_win_plot",
- "settings": "_win_settings",
- }
- if checked:
- creators[name]()
- else:
- win = getattr(self, wins[name], None)
- if win: win.hide()
-
- def _open_devices(self):
- if self._win_devices is None:
- self._win_devices = DevicesWindow(self.registry, self.engine, self)
- self._win_devices.device_added.connect(self._on_device_added)
- self._win_devices.device_removed.connect(self._on_device_removed)
- self._win_devices.device_reconfigured.connect(self._on_device_reconfigured)
- self._win_devices.channel_visibility_changed.connect(self._on_channel_visibility_changed)
- self._win_devices.channel_name_changed.connect(self._on_channel_name_changed)
- self._win_devices.channel_unit_changed.connect(self._on_channel_unit_changed)
- self._win_devices.closed.connect(lambda: self._act_devices.setChecked(False))
- self._show_win(self._win_devices, "right")
-
- def _open_channels(self):
- if self._win_channels is None:
- self._win_channels = ChannelsWindow(self.registry, self.processor, self)
- self._win_channels.derived_changed.connect(self._on_derived_changed)
- self._win_channels.closed.connect(lambda: self._act_channels.setChecked(False))
- self._show_win(self._win_channels, "right")
-
- def _open_plot(self):
- if self._win_plot is None:
- self._win_plot = PlotWindow(self.registry, self.processor,
- self._chart._cfg, self)
- self._win_plot.layout_applied.connect(self._chart.apply_layout)
- self._win_plot.closed.connect(lambda: self._act_plot.setChecked(False))
- self._win_plot.refresh_channels() # sync any derived channels
- else:
- self._win_plot.cfg = self._chart._cfg
- self._win_plot.refresh_channels()
- self._show_win(self._win_plot, "below")
+ def _open_config(self, tab: int = 0):
+ if self._win_config is None:
+ self._win_config = ConfigWindow(
+ self.registry, self.engine, self.processor,
+ self._chart._cfg, self,
+ )
+ self._win_config.device_added.connect(self._on_device_added)
+ self._win_config.device_removed.connect(self._on_device_removed)
+ self._win_config.device_reconfigured.connect(self._on_device_reconfigured)
+ self._win_config.channel_visibility_changed.connect(self._on_channel_visibility_changed)
+ self._win_config.channel_name_changed.connect(self._on_channel_name_changed)
+ self._win_config.channel_unit_changed.connect(self._on_channel_unit_changed)
+ self._win_config.pipeline_changed.connect(lambda: None)
+ self._win_config.derived_changed.connect(self._on_derived_changed)
+ self._win_config.layout_applied.connect(self._chart.apply_layout)
+ self._win_config.closed.connect(self._on_config_closed)
+ self._win_config.tabs.setCurrentIndex(tab)
+ self._show_win(self._win_config, "right")
+
+ def _on_config_closed(self):
+ self._act_devices.setChecked(False)
+ self._act_channels.setChecked(False)
+ self._act_plot.setChecked(False)
def _open_settings(self):
if self._win_settings is None:
@@ -529,13 +502,10 @@ class MainWindow(QMainWindow):
# Clear controls
self._ctrl.clear_widgets()
- # Refresh open windows
- if self._win_devices:
- self._win_devices.refresh()
- if self._win_channels:
- self._win_channels.refresh_derived()
- if self._win_plot:
- self._win_plot.refresh_channels()
+ if self._win_config:
+ self._win_config.refresh_devices()
+ self._win_config.refresh_derived()
+ self._win_config.refresh_plot()
self._chart.apply_layout(build_default_layout(self.registry, self.processor))
self._clear_history()
@@ -578,55 +548,52 @@ class MainWindow(QMainWindow):
self._chart.apply_layout(plot_cfg)
else:
self._chart.refresh()
- # Refresh open windows
- if self._win_devices:
- self._win_devices.refresh()
- if self._win_channels:
- self._win_channels.refresh_derived()
- if self._win_plot:
- self._win_plot.refresh_channels()
+ if self._win_config:
+ self._win_config.refresh_devices()
+ self._win_config.refresh_derived()
+ self._win_config.refresh_plot(self._chart._cfg)
self._status.setText(f"Profile loaded: {profile.name}")
# ── Device / signal events ────────────────────────────────────────────
def _on_device_added(self):
self._chart.refresh()
- if self._win_plot: self._win_plot.refresh_channels()
- if self._win_channels: self._win_channels.on_device_added()
+ if self._win_config:
+ self._win_config.on_device_added()
+ self._win_config.refresh_plot()
self._status.setText("Device added.")
def _on_device_removed(self, dev_id: str):
self._chart.refresh()
- if self._win_channels: self._win_channels.on_device_removed(dev_id)
+ if self._win_config:
+ self._win_config.on_device_removed(dev_id)
self._status.setText(f"Device '{dev_id}' removed.")
def _on_device_reconfigured(self, dev_id: str):
self._chart.refresh()
- if self._win_plot: self._win_plot.refresh_channels()
- if self._win_channels: self._win_channels.on_device_reconfigured(dev_id)
+ if self._win_config:
+ self._win_config.on_device_reconfigured(dev_id)
+ self._win_config.refresh_plot()
self._status.setText(f"Device '{dev_id}' reconfigured.")
def _on_channel_visibility_changed(self, dev_id: str, ch_id: str, enabled: bool):
self._chart.on_channel_enabled_changed(dev_id, ch_id, enabled)
- if self._win_channels:
- self._win_channels.on_channel_enabled_changed(dev_id, ch_id, enabled)
- if self._win_plot:
- self._win_plot.refresh_channels()
+ if self._win_config:
+ self._win_config.on_channel_enabled_changed(dev_id, ch_id, enabled)
+ self._win_config.refresh_plot()
def _on_channel_name_changed(self, dev_id: str, ch_id: str, name: str):
- if self._win_channels:
- self._win_channels.on_channel_name_changed(dev_id, ch_id, name)
+ if self._win_config:
+ self._win_config.on_channel_name_changed(dev_id, ch_id, name)
def _on_channel_unit_changed(self, dev_id: str, ch_id: str, unit: str):
- if self._win_channels:
- self._win_channels.on_channel_unit_changed(dev_id, ch_id, unit)
+ if self._win_config:
+ self._win_config.on_channel_unit_changed(dev_id, ch_id, unit)
def _on_derived_changed(self):
self._chart.refresh()
- if self._win_plot:
- # Ensure plot window works on current chart cfg, then sync derived panes
- self._win_plot.cfg = self._chart._cfg
- self._win_plot.refresh_channels()
+ if self._win_config:
+ self._win_config.refresh_plot(self._chart._cfg)
# ── Run / Log ─────────────────────────────────────────────────────────
@@ -687,8 +654,7 @@ class MainWindow(QMainWindow):
self._time_lbl.setText(f"{h:02d}:{m:02d}:{s:02d}")
def closeEvent(self, event):
- for w in (self._win_devices, self._win_channels,
- self._win_plot, self._win_settings):
+ for w in (self._win_config, self._win_settings):
if w: w.close()
for plugin in list(self._plugin_mgr.get_loaded()):
try:
diff --git a/ui/windows/config_window.py b/ui/windows/config_window.py
new file mode 100644
index 0000000..05cc1fb
--- /dev/null
+++ b/ui/windows/config_window.py
@@ -0,0 +1,504 @@
+"""
+ui/windows/config_window.py
+
+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
+"""
+
+from __future__ import annotations
+from copy import deepcopy
+from typing import List, Optional, Set, Tuple
+
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QToolButton,
+ QScrollArea, QFrame, QTabWidget, QTableWidget, QTableWidgetItem,
+ QHeaderView, QCheckBox, QMessageBox, QAbstractItemView, QSplitter,
+ QDoubleSpinBox, QDialog, QDialogButtonBox,
+)
+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 ui.windows.devices_window import DeviceRow, ChannelsTab
+from ui.windows.channels_window import PipelineTab, ChannelPipelineBlock
+from ui.windows.plot_window import (
+ PlotWindow, LayoutConfig, LayoutCanvas, PaneBlock,
+ build_default_layout,
+)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# 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."""
+
+ _C_DEVICE = 0
+ _C_CH_ID = 1
+ _C_DIR = 2
+ _C_ENABLED = 3
+ _C_NAME = 4
+ _C_UNIT = 5
+ _C_MIN = 6
+ _C_MAX = 7
+ _C_PIPE = 8 # ⚙ pipeline button
+
+ def __init__(self, registry: DeviceRegistry, processor: SignalProcessor):
+ self._processor = processor
+ super().__init__(registry)
+
+ def _build(self):
+ lay = QVBoxLayout(self)
+ lay.setContentsMargins(0, 0, 0, 0)
+
+ self._table = QTableWidget()
+ self._table.setObjectName("channelTable")
+ self._table.setColumnCount(9)
+ self._table.setHorizontalHeaderLabels(
+ ["Device", "Signal ID", "Dir", "On", "Name", "Unit", "Min", "Max", ""]
+ )
+ hdr = self._table.horizontalHeader()
+ hdr.setSectionResizeMode(self._C_NAME, QHeaderView.ResizeMode.Stretch)
+ hdr.setSectionResizeMode(self._C_DEVICE, QHeaderView.ResizeMode.ResizeToContents)
+ hdr.setSectionResizeMode(self._C_CH_ID, QHeaderView.ResizeMode.ResizeToContents)
+ hdr.setSectionResizeMode(self._C_DIR, QHeaderView.ResizeMode.ResizeToContents)
+ hdr.setSectionResizeMode(self._C_ENABLED, QHeaderView.ResizeMode.ResizeToContents)
+ hdr.setSectionResizeMode(self._C_PIPE, QHeaderView.ResizeMode.ResizeToContents)
+ self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
+ self._table.setAlternatingRowColors(True)
+ self._table.verticalHeader().setVisible(False)
+ self._table.cellChanged.connect(self._on_cell_changed)
+ lay.addWidget(self._table)
+
+ def refresh(self):
+ self._table.blockSignals(True)
+ self._table.setRowCount(0)
+ row = 0
+ for dev in self.registry.all_instances():
+ for ch in dev.info.channels:
+ self._table.insertRow(row)
+
+ dev_item = QTableWidgetItem(f"{dev.info.icon} {dev.info.device_id}")
+ dev_item.setFlags(dev_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
+ dev_item.setForeground(QColor("#64748b"))
+ self._table.setItem(row, self._C_DEVICE, dev_item)
+
+ ch_item = QTableWidgetItem(ch.channel_id)
+ ch_item.setFlags(ch_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
+ 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")
+ 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"))
+ self._table.setItem(row, self._C_DIR, dir_item)
+
+ # Enable toggle — disabled for output channels
+ chk_w = QWidget()
+ chk_l = QHBoxLayout(chk_w)
+ chk_l.setContentsMargins(0, 0, 0, 0)
+ chk_l.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ chk = QCheckBox()
+ chk.setChecked(True if is_out else ch.enabled)
+ if is_out:
+ chk.setEnabled(False)
+ else:
+ dev_id_cap = dev.info.device_id
+ ch_id_cap = ch.channel_id
+ chk.toggled.connect(
+ lambda checked, d=dev_id_cap, c=ch_id_cap:
+ self._on_enabled(d, c, checked)
+ )
+ chk_l.addWidget(chk)
+ self._table.setCellWidget(row, self._C_ENABLED, chk_w)
+
+ name_item = QTableWidgetItem(ch.name)
+ name_item.setData(Qt.ItemDataRole.UserRole, (dev.info.device_id, ch.channel_id))
+ self._table.setItem(row, self._C_NAME, name_item)
+
+ self._table.setItem(row, self._C_UNIT, QTableWidgetItem(ch.unit))
+ 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
+ )
+ 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)
+ )
+ pipe_l.addWidget(pipe_btn)
+ self._table.setCellWidget(row, self._C_PIPE, pipe_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()
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Plot tab — PlotWindow embedded without floating chrome
+# ══════════════════════════════════════════════════════════════════════════════
+
+class _PlotTab(PlotWindow):
+ def __init__(self, registry: DeviceRegistry, processor: SignalProcessor,
+ current: Optional[LayoutConfig] = None, parent=None):
+ QWidget.__init__(self, parent)
+ self.registry = registry
+ self.processor = processor
+ self.cfg = deepcopy(current) if current else build_default_layout(registry, processor)
+ self._blocks: List[PaneBlock] = []
+ self._build()
+ self._populate()
+
+ def _build(self):
+ root = QVBoxLayout(self); root.setContentsMargins(0, 0, 0, 0); root.setSpacing(0)
+
+ glob = QWidget(); glob.setObjectName("cfgGlobalBar")
+ gl = QHBoxLayout(glob); gl.setContentsMargins(12, 7, 12, 7); gl.setSpacing(12)
+ gl.addWidget(QLabel("Window:"))
+ self._win = QDoubleSpinBox(); self._win.setRange(1, 3600)
+ self._win.setSuffix(" s"); self._win.setValue(self.cfg.time_window_s)
+ self._win.setObjectName("cfgGlobalSpin")
+ self._win.valueChanged.connect(lambda v: setattr(self.cfg, "time_window_s", v))
+ gl.addWidget(self._win)
+ self._linkx = QCheckBox("Link X"); self._linkx.setChecked(self.cfg.link_x)
+ self._linkx.setObjectName("cfgLiveChk")
+ self._linkx.toggled.connect(lambda v: setattr(self.cfg, "link_x", v))
+ gl.addWidget(self._linkx)
+ self._legend = QCheckBox("Legend"); self._legend.setChecked(self.cfg.show_legend)
+ self._legend.setObjectName("cfgLiveChk")
+ self._legend.toggled.connect(lambda v: setattr(self.cfg, "show_legend", v))
+ gl.addWidget(self._legend)
+ self._live = QCheckBox("Live preview"); self._live.setObjectName("cfgLiveChk")
+ gl.addWidget(self._live)
+ gl.addStretch()
+ add_btn = QPushButton("+ Add Pane"); add_btn.setObjectName("addDeviceButton")
+ add_btn.clicked.connect(self._add_pane); gl.addWidget(add_btn)
+ root.addWidget(glob)
+
+ canvas_bar = QWidget(); canvas_bar.setObjectName("layoutCanvasBar")
+ cb_lay = QVBoxLayout(canvas_bar)
+ cb_lay.setContentsMargins(10, 6, 10, 8); cb_lay.setSpacing(4)
+ clbl = QLabel(
+ "LAYOUT — edge: split (blue) | "
+ "edge gap: insert between (amber) | center: move (green)"
+ )
+ clbl.setObjectName("layoutCanvasLabel")
+ cb_lay.addWidget(clbl)
+ self._canvas = LayoutCanvas(self.cfg.panes, self.cfg.tree, self)
+ self._canvas._cfg_ref = self.cfg
+ self._canvas.arrangement_changed.connect(self._maybe_live)
+ cb_lay.addWidget(self._canvas)
+ root.addWidget(canvas_bar)
+
+ div = QFrame(); div.setFrameShape(QFrame.Shape.HLine)
+ div.setObjectName("devWindowDivider"); root.addWidget(div)
+
+ scroll = QScrollArea(); scroll.setWidgetResizable(True)
+ scroll.setObjectName("deviceScroll")
+ self._cont = QWidget()
+ self._blay = QVBoxLayout(self._cont)
+ self._blay.setContentsMargins(10, 10, 10, 10); self._blay.setSpacing(12)
+ self._blay.addStretch()
+ scroll.setWidget(self._cont)
+ root.addWidget(scroll, 1)
+
+ btm = QWidget(); btm.setObjectName("cfgBottomBar")
+ bl = QHBoxLayout(btm); bl.setContentsMargins(12, 8, 12, 8)
+ for lbl, fn in [("⟳ Rebuild from Channels", self._reset),
+ ("↓ Export JSON", self._export),
+ ("↑ Import JSON", self._import_json)]:
+ b = QPushButton(lbl); b.setObjectName("configButton")
+ b.clicked.connect(fn); bl.addWidget(b)
+ bl.addStretch()
+ ap = QPushButton("✓ Apply"); ap.setObjectName("applyButton")
+ ap.clicked.connect(self._apply); bl.addWidget(ap)
+ root.addWidget(btm)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# ConfigWindow
+# ══════════════════════════════════════════════════════════════════════════════
+
+class ConfigWindow(QWidget):
+ device_added = pyqtSignal()
+ device_removed = pyqtSignal(str)
+ device_reconfigured = pyqtSignal(str)
+ channel_visibility_changed = pyqtSignal(str, str, bool)
+ channel_name_changed = pyqtSignal(str, str, str)
+ channel_unit_changed = pyqtSignal(str, str, str)
+ pipeline_changed = pyqtSignal()
+ derived_changed = pyqtSignal()
+ layout_applied = pyqtSignal(object)
+ closed = pyqtSignal()
+
+ def __init__(self, registry: DeviceRegistry, engine: AcquisitionEngine,
+ processor: SignalProcessor,
+ current_layout: Optional[LayoutConfig] = None, parent=None):
+ super().__init__(parent, Qt.WindowType.Window)
+ self.registry = registry
+ self.engine = engine
+ self.processor = processor
+ self._dev_rows: dict = {}
+
+ self.setWindowTitle("Configuration")
+ self.setMinimumSize(900, 580)
+ self.resize(1200, 760)
+ self._build(current_layout)
+
+ # ── Build ────────────────────────────────────────────────────────────────
+
+ def _build(self, current_layout: Optional[LayoutConfig]):
+ root = QVBoxLayout(self); root.setContentsMargins(0, 0, 0, 0); root.setSpacing(0)
+ self.tabs = QTabWidget()
+ self.tabs.setObjectName("signalBuilderTabs")
+ root.addWidget(self.tabs, 1)
+ self._build_devices_tab()
+ self._build_channels_tab()
+ self._build_plot_tab(current_layout)
+ self.tabs.currentChanged.connect(self._on_tab_changed)
+
+ # ── Tab 0: Devices ───────────────────────────────────────────────────────
+
+ def _build_devices_tab(self):
+ w = QWidget()
+ lay = QVBoxLayout(w); lay.setContentsMargins(0, 0, 0, 0); lay.setSpacing(0)
+
+ bar = QWidget(); bar.setObjectName("cfgGlobalBar")
+ bl = QHBoxLayout(bar); bl.setContentsMargins(10, 7, 10, 7); bl.setSpacing(6)
+ lbl = QLabel("Physical Devices"); lbl.setObjectName("devWindowTitle")
+ bl.addWidget(lbl, 1)
+ add_btn = QPushButton("+ Add Device"); add_btn.setObjectName("addDeviceButton")
+ add_btn.clicked.connect(self._on_add_device)
+ bl.addWidget(add_btn)
+ lay.addWidget(bar)
+
+ div = QFrame(); div.setFrameShape(QFrame.Shape.HLine)
+ div.setObjectName("devWindowDivider"); lay.addWidget(div)
+
+ scroll = QScrollArea(); scroll.setWidgetResizable(True)
+ scroll.setObjectName("deviceScroll")
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ self._dev_container = QWidget()
+ self._dev_inner = QVBoxLayout(self._dev_container)
+ self._dev_inner.setContentsMargins(10, 10, 10, 10); self._dev_inner.setSpacing(6)
+ self._dev_inner.addStretch()
+ scroll.setWidget(self._dev_container)
+ lay.addWidget(scroll, 1)
+
+ self.tabs.addTab(w, " Devices ")
+
+ self._dev_poll = QTimer(self)
+ self._dev_poll.setInterval(2000)
+ self._dev_poll.timeout.connect(self._refresh_device_status)
+ self._dev_poll.start()
+
+ self.refresh_devices()
+
+ def refresh_devices(self):
+ for row in self._dev_rows.values():
+ self._dev_inner.removeWidget(row); row.deleteLater()
+ self._dev_rows.clear()
+ for dev in self.registry.all_instances():
+ row = DeviceRow(dev)
+ row.configure_clicked.connect(self._on_configure_device)
+ row.remove_clicked.connect(self._on_remove_device)
+ self._dev_inner.insertWidget(self._dev_inner.count() - 1, row)
+ self._dev_rows[dev.info.device_id] = row
+
+ def _refresh_device_status(self):
+ for row in self._dev_rows.values():
+ row.refresh()
+
+ def _on_add_device(self):
+ from ui.add_device_dialog import AddDeviceDialog
+ dlg = AddDeviceDialog(self.registry, self)
+ if dlg.exec():
+ dev = dlg.created_device
+ if dev:
+ dev.connect()
+ self.registry.add_instance(dev)
+ self.engine.add_device(dev)
+ self.refresh_devices()
+ self._signals_tab.refresh()
+ self.device_added.emit()
+
+ 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.device_reconfigured.emit(device_id)
+
+ def _on_remove_device(self, device_id: str):
+ reply = QMessageBox.question(
+ self, "Remove Device",
+ f"Remove '{device_id}'? This cannot be undone.",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ )
+ if reply == QMessageBox.StandardButton.Yes:
+ self.engine.remove_device(device_id)
+ self.registry.remove_instance(device_id)
+ self.refresh_devices()
+ self._signals_tab.refresh()
+ self._pipeline_tab.on_device_removed(device_id)
+ self.device_removed.emit(device_id)
+
+ # ── Tab 1: Channels ──────────────────────────────────────────────────────
+ # Left — signal table (metadata + enable + ⚙ pipeline button per row)
+ # Right — derived / virtual channels (PipelineTab with physical side hidden)
+
+ def _build_channels_tab(self):
+ w = QWidget()
+ lay = QVBoxLayout(w); lay.setContentsMargins(0, 0, 0, 0); lay.setSpacing(0)
+
+ splitter = QSplitter(Qt.Orientation.Horizontal)
+ splitter.setObjectName("channelSplitter")
+ lay.addWidget(splitter, 1)
+
+ # Left: signals table
+ sig_w = 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_lbl = QLabel("Signals"); sb_lbl.setObjectName("devWindowTitle")
+ sb_lay.addWidget(sb_lbl, 1)
+ sig_l.addWidget(sig_bar)
+
+ sig_div = QFrame(); sig_div.setFrameShape(QFrame.Shape.HLine)
+ sig_div.setObjectName("devWindowDivider"); sig_l.addWidget(sig_div)
+
+ self._signals_tab = _SignalsTab(self.registry, self.processor)
+ self._signals_tab.channel_visibility_changed.connect(self._on_sig_visibility)
+ self._signals_tab.channel_name_changed.connect(self._on_sig_name)
+ self._signals_tab.channel_unit_changed.connect(self._on_sig_unit)
+ sig_l.addWidget(self._signals_tab, 1)
+ splitter.addWidget(sig_w)
+
+ # Right: derived channels only
+ self._pipeline_tab = PipelineTab(self.registry, self.processor)
+ self._pipeline_tab.changed.connect(self.pipeline_changed)
+ self._pipeline_tab.derived_changed.connect(self.derived_changed)
+ inner_splitters = self._pipeline_tab.findChildren(QSplitter)
+ if inner_splitters:
+ inner_splitters[0].widget(0).setVisible(False)
+ splitter.addWidget(self._pipeline_tab)
+
+ splitter.setStretchFactor(0, 2)
+ splitter.setStretchFactor(1, 1)
+
+ self.tabs.addTab(w, " Channels ")
+
+ def _on_sig_visibility(self, dev_id: str, ch_id: str, enabled: bool):
+ self._pipeline_tab.on_channel_enabled_changed(dev_id, ch_id, enabled)
+ self.channel_visibility_changed.emit(dev_id, ch_id, enabled)
+
+ def _on_sig_name(self, dev_id: str, ch_id: str, name: str):
+ self._pipeline_tab.on_channel_name_changed(dev_id, ch_id, name)
+ self.channel_name_changed.emit(dev_id, ch_id, name)
+
+ def _on_sig_unit(self, dev_id: str, ch_id: str, unit: str):
+ self._pipeline_tab.on_channel_unit_changed(dev_id, ch_id, unit)
+ self.channel_unit_changed.emit(dev_id, ch_id, unit)
+
+ def on_device_added(self):
+ self._signals_tab.refresh()
+ self._pipeline_tab.on_device_added()
+
+ def on_device_removed(self, dev_id: str):
+ self._pipeline_tab.on_device_removed(dev_id)
+
+ def on_device_reconfigured(self, dev_id: str):
+ self._signals_tab.refresh()
+ self._pipeline_tab.on_device_reconfigured(dev_id)
+
+ def on_channel_enabled_changed(self, dev_id: str, ch_id: str, enabled: bool):
+ self._pipeline_tab.on_channel_enabled_changed(dev_id, ch_id, enabled)
+
+ def on_channel_name_changed(self, dev_id: str, ch_id: str, name: str):
+ self._pipeline_tab.on_channel_name_changed(dev_id, ch_id, name)
+
+ def on_channel_unit_changed(self, dev_id: str, ch_id: str, unit: str):
+ self._pipeline_tab.on_channel_unit_changed(dev_id, ch_id, unit)
+
+ def refresh_derived(self):
+ self._pipeline_tab.refresh_derived()
+
+ # ── Tab 2: Plot ──────────────────────────────────────────────────────────
+
+ def _build_plot_tab(self, current_layout: Optional[LayoutConfig]):
+ self._plot_tab = _PlotTab(self.registry, self.processor, current_layout, self)
+ self._plot_tab.layout_applied.connect(self.layout_applied)
+ self.tabs.addTab(self._plot_tab, " Plot ")
+
+ def refresh_plot(self, cfg: Optional[LayoutConfig] = None):
+ if cfg is not None:
+ self._plot_tab.cfg = cfg
+ self._plot_tab.refresh_channels()
+
+ # ── Tab switch ───────────────────────────────────────────────────────────
+
+ def _on_tab_changed(self, idx: int):
+ if idx == 1:
+ self._signals_tab.refresh()
+ elif idx == 2:
+ self._plot_tab.refresh_channels()
+
+ # ── Close ────────────────────────────────────────────────────────────────
+
+ def closeEvent(self, e: QCloseEvent):
+ self._dev_poll.stop()
+ self.closed.emit()
+ e.accept()