diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-04-20 16:55:57 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-04-20 16:55:57 -0600 |
| commit | d5acb04b88373d33b038bb59945fb5ab8b4f543b (patch) | |
| tree | 21c37f2b54258ef20edc1b9b7226327d5f5b1a3a /ui/main_window.py | |
| parent | 425ba78ee1f760978b23a09fe8acbbc9b8b5dae4 (diff) | |
V8
Diffstat (limited to 'ui/main_window.py')
| -rw-r--r-- | ui/main_window.py | 383 |
1 files changed, 238 insertions, 145 deletions
diff --git a/ui/main_window.py b/ui/main_window.py index d329571..e69ab1a 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -1,214 +1,307 @@ """ -ui/main_window.py — Main application window. - -Layout: - ┌──────────────────────────────────────────────────────────┐ - │ TOOLBAR [▶ RUN] [⬤ LOG] [+ Device] 00:00:00 │ - ├────────────┬───────────────────────────┬─────────────────┤ - │ DEVICES │ STRIP CHART (pyqtgraph) │ CHANNELS │ - │ (left) │ │ (readouts) │ - │ │ ├─────────────────┤ - │ │ │ ALARMS │ - └────────────┴───────────────────────────┴─────────────────┘ - │ status bar │ - └──────────────────────────────────────────────────────────┘ +ui/main_window.py + +Toolbar sections (left→right): + [▶ RUN] [⬤ LOG] | [⚙ DEVICES] | [⚗ SIGNALS] | [📐 PLOT] | [⚙ SETTINGS] + spacer | timer + +Central: + Left: ControlPanel (output widgets only) + Center: StripChartWidget + +All secondary features live in floating windows. """ from PyQt6.QtWidgets import ( QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QSplitter, QStatusBar, QLabel, QPushButton, - QToolBar, QSizePolicy, QMessageBox, + QToolBar, QSizePolicy, QApplication, ) from PyQt6.QtCore import Qt, QTimer, pyqtSlot +import os from devices.analog_input import AnalogInputDevice from devices.digital_io import DigitalIODevice from devices.serial_device import SerialDevice from devices.device_registry import DeviceRegistry from core.acquisition import AcquisitionEngine +from core.signal_processor import SignalProcessor -from ui.device_panel import DevicePanel -from ui.strip_chart import StripChartWidget -from ui.readout_panel import ReadoutPanel -from ui.alarm_panel import AlarmPanel -from ui.config_dialog import DeviceConfigDialog -from ui.add_device_dialog import AddDeviceDialog +from ui.control_panel import ControlPanel +from ui.strip_chart import StripChartWidget +from ui.windows.devices_window import DevicesWindow +from ui.windows.signals_window import SignalsWindow +from ui.windows.plot_window import PlotWindow, build_default_layout +from ui.windows.settings_window import SettingsWindow + + +_DARK_QSS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style_dark.qss") +_LIGHT_QSS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style_light.qss") class MainWindow(QMainWindow): def __init__(self): super().__init__() - self.setWindowTitle("LabDAQ — Data Acquisition System") - self.setMinimumSize(1360, 820) + self.setWindowTitle("LabDAQ") + self.setMinimumSize(1000, 640) - self.registry = DeviceRegistry() - self.engine = AcquisitionEngine(poll_interval_ms=100) + self.registry = DeviceRegistry() + self.engine = AcquisitionEngine(poll_interval_ms=100) + self.processor = SignalProcessor() + self._settings = dict(SettingsWindow._defaults) self._elapsed = 0 - self._running = False + self._win_devices = None + self._win_signals = None + self._win_plot = None + self._win_settings = None self._init_demo_devices() self._build_ui() self._connect_signals() + # ── Demo ────────────────────────────────────────────────────────────── + def _init_demo_devices(self): - ai = AnalogInputDevice(device_id="ai_0", num_channels=4, simulate=True, backend="nidaqmx") - ai.connect() - ard = AnalogInputDevice(device_id="ard_0", num_channels=4, simulate=True, backend="arduino") - ard.connect() - dio = DigitalIODevice(device_id="dio_0", num_inputs=4, num_outputs=4, simulate=True, backend="nidaqmx") - dio.connect() - ser = SerialDevice(device_id="ser_0", num_channels=3, simulate=True) - ser.connect() - for dev in [ai, ard, dio, ser]: + for dev in [ + AnalogInputDevice(device_id="ai_0", num_channels=4, simulate=True, backend="nidaqmx"), + AnalogInputDevice(device_id="ard_0", num_channels=4, simulate=True, backend="arduino"), + DigitalIODevice (device_id="dio_0", num_inputs=4, num_outputs=4, simulate=True), + SerialDevice (device_id="ser_0", num_channels=3, simulate=True), + ]: + dev.connect() self.registry.add_instance(dev) self.engine.add_device(dev) + # ── UI ──────────────────────────────────────────────────────────────── + def _build_ui(self): - tb = QToolBar("Main") - tb.setObjectName("mainToolbar") - tb.setMovable(False) + # ── Toolbar ────────────────────────────────────────────────────── + tb = QToolBar(); tb.setObjectName("mainToolbar"); tb.setMovable(False) self.addToolBar(tb) + def _sep(): + s = QFrame(); s.setFrameShape(QFrame.Shape.VLine) + s.setObjectName("toolbarSep"); return s + + # RUN / LOG self._run_btn = QPushButton("▶ RUN") - self._run_btn.setObjectName("runButton") - self._run_btn.setCheckable(True) - self._run_btn.clicked.connect(self._toggle_run) - tb.addWidget(self._run_btn) - tb.addSeparator() + self._run_btn.setObjectName("runButton"); self._run_btn.setCheckable(True) + self._run_btn.clicked.connect(self._toggle_run); tb.addWidget(self._run_btn) self._log_btn = QPushButton("⬤ LOG") - self._log_btn.setObjectName("logButton") - self._log_btn.setCheckable(True) - self._log_btn.setEnabled(False) - self._log_btn.clicked.connect(self._toggle_log) + self._log_btn.setObjectName("logButton"); self._log_btn.setCheckable(True) + self._log_btn.setEnabled(False); self._log_btn.clicked.connect(self._toggle_log) tb.addWidget(self._log_btn) - tb.addSeparator() - add_btn = QPushButton("+ Device") - add_btn.setObjectName("addDeviceButton") - add_btn.clicked.connect(self._add_device) - tb.addWidget(add_btn) + tb.addWidget(_sep()) + + # DEVICES + dev_btn = QPushButton("⊞ Devices") + dev_btn.setObjectName("toolbarSectionBtn") + dev_btn.setCheckable(True) + dev_btn.clicked.connect(lambda c: self._toggle_win("devices", c, dev_btn)) + tb.addWidget(dev_btn); self._btn_devices = dev_btn + + tb.addWidget(_sep()) + + # SIGNALS + sig_btn = QPushButton("⚗ Signals") + sig_btn.setObjectName("toolbarSectionBtn") + sig_btn.setCheckable(True) + sig_btn.clicked.connect(lambda c: self._toggle_win("signals", c, sig_btn)) + tb.addWidget(sig_btn); self._btn_signals = sig_btn + + tb.addWidget(_sep()) - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + # PLOT + plot_btn = QPushButton("📐 Plot") + plot_btn.setObjectName("toolbarSectionBtn") + plot_btn.setCheckable(True) + plot_btn.clicked.connect(lambda c: self._toggle_win("plot", c, plot_btn)) + tb.addWidget(plot_btn); self._btn_plot = plot_btn + + tb.addWidget(_sep()) + + # SETTINGS + set_btn = QPushButton("⚙ Settings") + set_btn.setObjectName("toolbarSectionBtn") + set_btn.setCheckable(True) + set_btn.clicked.connect(lambda c: self._toggle_win("settings", c, set_btn)) + tb.addWidget(set_btn); self._btn_settings = set_btn + + spacer = QWidget(); spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) tb.addWidget(spacer) - self._time_lbl = QLabel("00:00:00") - self._time_lbl.setObjectName("timeLabel") + self._time_lbl = QLabel("00:00:00"); self._time_lbl.setObjectName("timeLabel") tb.addWidget(self._time_lbl) - central = QWidget() - self.setCentralWidget(central) - root = QHBoxLayout(central) - root.setContentsMargins(0, 0, 0, 0) - root.setSpacing(0) + # ── Central ─────────────────────────────────────────────────────── + central = QWidget(); self.setCentralWidget(central) + root = QHBoxLayout(central); root.setContentsMargins(0,0,0,0); root.setSpacing(0) - hsplit = QSplitter(Qt.Orientation.Horizontal) - hsplit.setHandleWidth(3) + hsplit = QSplitter(Qt.Orientation.Horizontal); hsplit.setHandleWidth(3) - self._dev_panel = DevicePanel(self.registry, self.engine) - self._dev_panel.setMinimumWidth(210) - self._dev_panel.setMaximumWidth(300) - hsplit.addWidget(self._dev_panel) + self._ctrl = ControlPanel(self.registry) + self._ctrl.setMinimumWidth(200); self._ctrl.setMaximumWidth(320) + hsplit.addWidget(self._ctrl) - self._chart = StripChartWidget(self.engine, self.registry) + self._chart = StripChartWidget(self.engine, self.registry, self.processor) hsplit.addWidget(self._chart) - right_widget = QWidget() - right_widget.setMinimumWidth(230) - right_widget.setMaximumWidth(320) - right_lay = QVBoxLayout(right_widget) - right_lay.setContentsMargins(0, 0, 0, 0) - right_lay.setSpacing(0) - vsplit = QSplitter(Qt.Orientation.Vertical) - vsplit.setHandleWidth(3) - self._readout_panel = ReadoutPanel(self.registry) - self._alarm_panel = AlarmPanel() - vsplit.addWidget(self._readout_panel) - vsplit.addWidget(self._alarm_panel) - vsplit.setSizes([500, 300]) - right_lay.addWidget(vsplit) - hsplit.addWidget(right_widget) - - hsplit.setSizes([230, 880, 260]) + hsplit.setSizes([240, 1000]) root.addWidget(hsplit) - sb = QStatusBar() - self.setStatusBar(sb) - self._status_lbl = QLabel("Ready — simulation mode active") - sb.addWidget(self._status_lbl) - self._log_lbl = QLabel("") - sb.addPermanentWidget(self._log_lbl) + # ── Status bar ───────────────────────────────────────────────── + sb = QStatusBar(); self.setStatusBar(sb) + self._status = QLabel("Ready"); sb.addWidget(self._status) + self._log_lbl = QLabel(""); sb.addPermanentWidget(self._log_lbl) - self._clock = QTimer(self) - self._clock.setInterval(1000) + self._clock = QTimer(self); self._clock.setInterval(1000) self._clock.timeout.connect(self._tick) def _connect_signals(self): - self.engine.alarm_triggered.connect(self._on_alarm) - self.engine.new_data.connect(self._readout_panel.on_new_data) - self.engine.new_data.connect(self._chart.on_new_data) - self.engine.log_started.connect(lambda p: self._log_lbl.setText(f"● LOG {p}")) - self.engine.log_stopped.connect(lambda p: self._log_lbl.setText(f"✓ Saved {p}")) - self._dev_panel.config_requested.connect(self._open_config) - - def _toggle_run(self, checked: bool): + self.engine.new_data.connect(self.processor.on_raw_data) + self.processor.processed_data.connect(self._chart.on_new_data) + self.engine.log_started.connect(lambda p: self._log_lbl.setText(f"● {p}")) + self.engine.log_stopped.connect(lambda p: self._log_lbl.setText(f"✓ {p}")) + + # ── Window management ───────────────────────────────────────────────── + + def _toggle_win(self, name: str, checked: bool, btn: QPushButton): + creators = { + "devices": self._open_devices, + "signals": self._open_signals, + "plot": self._open_plot, + "settings": self._open_settings, + } + wins = { + "devices": "_win_devices", + "signals": "_win_signals", + "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.closed.connect(lambda: self._btn_devices.setChecked(False)) + self._show_win(self._win_devices, "right") + + def _open_signals(self): + if self._win_signals is None: + self._win_signals = SignalsWindow(self.registry, self.processor, self) + self._win_signals.derived_changed.connect(self._on_derived_changed) + self._win_signals.visibility_changed.connect(self._chart.refresh) + self._win_signals.closed.connect(lambda: self._btn_signals.setChecked(False)) + self._show_win(self._win_signals, "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._btn_plot.setChecked(False)) + else: + self._win_plot.cfg = self._chart._cfg + self._win_plot._populate() + self._show_win(self._win_plot, "below") + + def _open_settings(self): + if self._win_settings is None: + self._win_settings = SettingsWindow(self.registry, self.engine, + self._settings, self) + self._win_settings.theme_changed.connect(self._apply_theme) + self._win_settings.settings_changed.connect(self._on_settings) + self._win_settings.closed.connect(lambda: self._btn_settings.setChecked(False)) + self._show_win(self._win_settings, "right") + + def _show_win(self, win: QWidget, position: str = "right"): + geo = self.geometry() + if not win.isVisible(): + if position == "right": + win.move(geo.right() + 8, geo.top() + 40) + elif position == "below": + win.move(geo.left(), geo.bottom() + 8) + win.show(); win.raise_(); win.activateWindow() + + # ── Device/signal events ────────────────────────────────────────────── + + def _on_device_added(self): + self._chart.refresh() + if self._win_plot: self._win_plot.refresh_channels() + self._status.setText("Device added.") + + def _on_device_removed(self, dev_id: str): + self._chart.refresh() + self._status.setText(f"Device '{dev_id}' removed.") + + def _on_device_reconfigured(self, dev_id: str): + """Called after Configure dialog closes — rebuild chart curves.""" + self._chart.refresh() + if self._win_plot: + self._win_plot.refresh_channels() + self._status.setText(f"Device '{dev_id}' reconfigured.") + + def _on_derived_changed(self): + self._chart.refresh() + if self._win_plot: self._win_plot.refresh_channels() + + # ── Run / Log ───────────────────────────────────────────────────────── + + def _toggle_run(self, c: bool): + if c: self.engine.start() - self._run_btn.setText("⏹ STOP") - self._log_btn.setEnabled(True) - self._clock.start() - self._status_lbl.setText("Acquiring…") - self._running = True + self._run_btn.setText("⏹ STOP"); self._log_btn.setEnabled(True) + self._clock.start(); self._status.setText("Acquiring…") else: self.engine.stop() self._run_btn.setText("▶ RUN") - if self._log_btn.isChecked(): - self._log_btn.setChecked(False) - self._log_btn.setEnabled(False) - self._clock.stop() - self._status_lbl.setText("Stopped") - self._running = False - - def _toggle_log(self, checked: bool): - if checked: - path = self.engine.start_logging() + if self._log_btn.isChecked(): self._log_btn.setChecked(False) + self._log_btn.setEnabled(False); self._clock.stop() + self._status.setText("Stopped") + + def _toggle_log(self, c: bool): + if c: + os.makedirs(self._settings.get("log_dir","logs"), exist_ok=True) + p = self.engine.start_logging( + os.path.join(self._settings.get("log_dir","logs"), "")) self._log_btn.setText("⏹ LOGGING") - self._status_lbl.setText(f"Logging → {path}") + self._status.setText(f"Logging → {p}") else: - self.engine.stop_logging() - self._log_btn.setText("⬤ LOG") - - def _add_device(self): - 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._dev_panel.refresh() - self._readout_panel.refresh() - self._chart.refresh() - self._status_lbl.setText(f"Added device: {dev.info.device_id}") - - def _open_config(self, device_id: str): - dev = self.registry.get_instance(device_id) - if dev: - DeviceConfigDialog(dev, self).exec() - - @pyqtSlot(str, str, float, str) - def _on_alarm(self, dev_id: str, ch_id: str, value: float, kind: str): - self._alarm_panel.add_alarm(dev_id, ch_id, value, kind) + self.engine.stop_logging(); self._log_btn.setText("⬤ LOG") + + # ── Settings ────────────────────────────────────────────────────────── + + def _apply_theme(self, theme: str): + qss_file = _DARK_QSS if theme == "dark" else _LIGHT_QSS + if os.path.exists(qss_file): + with open(qss_file) as f: + QApplication.instance().setStyleSheet(f.read()) + # Update pyqtgraph plot colours independently of QSS + self._chart.set_theme(theme) + + def _on_settings(self, cfg: dict): + self._settings.update(cfg) + self.engine._interval = cfg.get("poll_ms", 100) / 1000.0 def _tick(self): self._elapsed += 1 - h = self._elapsed // 3600 - m = (self._elapsed % 3600) // 60 - s = self._elapsed % 60 + h=self._elapsed//3600; m=(self._elapsed%3600)//60; s=self._elapsed%60 self._time_lbl.setText(f"{h:02d}:{m:02d}:{s:02d}") def closeEvent(self, event): - self.engine.stop() - event.accept() + for w in (self._win_devices,self._win_signals,self._win_plot,self._win_settings): + if w: w.close() + self.engine.stop(); event.accept() + + +from PyQt6.QtWidgets import QFrame |
