summaryrefslogtreecommitdiff
path: root/daq_system/ui
diff options
context:
space:
mode:
Diffstat (limited to 'daq_system/ui')
-rw-r--r--daq_system/ui/__pycache__/alarm_panel.cpython-312.pycbin0 -> 5462 bytes
-rw-r--r--daq_system/ui/__pycache__/config_dialog.cpython-312.pycbin0 -> 9345 bytes
-rw-r--r--daq_system/ui/__pycache__/device_panel.cpython-312.pycbin0 -> 7943 bytes
-rw-r--r--daq_system/ui/__pycache__/main_window.cpython-312.pycbin0 -> 13127 bytes
-rw-r--r--daq_system/ui/__pycache__/readout_panel.cpython-312.pycbin0 -> 8378 bytes
-rw-r--r--daq_system/ui/__pycache__/strip_chart.cpython-312.pycbin0 -> 8328 bytes
-rw-r--r--daq_system/ui/add_device_dialog.py7
-rw-r--r--daq_system/ui/alarm_panel.py85
-rw-r--r--daq_system/ui/config_dialog.py174
-rw-r--r--daq_system/ui/device_panel.py127
-rw-r--r--daq_system/ui/main_window.py228
-rw-r--r--daq_system/ui/readout_panel.py128
-rw-r--r--daq_system/ui/strip_chart.py140
13 files changed, 889 insertions, 0 deletions
diff --git a/daq_system/ui/__pycache__/alarm_panel.cpython-312.pyc b/daq_system/ui/__pycache__/alarm_panel.cpython-312.pyc
new file mode 100644
index 0000000..7f2776c
--- /dev/null
+++ b/daq_system/ui/__pycache__/alarm_panel.cpython-312.pyc
Binary files differ
diff --git a/daq_system/ui/__pycache__/config_dialog.cpython-312.pyc b/daq_system/ui/__pycache__/config_dialog.cpython-312.pyc
new file mode 100644
index 0000000..d62887f
--- /dev/null
+++ b/daq_system/ui/__pycache__/config_dialog.cpython-312.pyc
Binary files differ
diff --git a/daq_system/ui/__pycache__/device_panel.cpython-312.pyc b/daq_system/ui/__pycache__/device_panel.cpython-312.pyc
new file mode 100644
index 0000000..0616690
--- /dev/null
+++ b/daq_system/ui/__pycache__/device_panel.cpython-312.pyc
Binary files differ
diff --git a/daq_system/ui/__pycache__/main_window.cpython-312.pyc b/daq_system/ui/__pycache__/main_window.cpython-312.pyc
new file mode 100644
index 0000000..d6af385
--- /dev/null
+++ b/daq_system/ui/__pycache__/main_window.cpython-312.pyc
Binary files differ
diff --git a/daq_system/ui/__pycache__/readout_panel.cpython-312.pyc b/daq_system/ui/__pycache__/readout_panel.cpython-312.pyc
new file mode 100644
index 0000000..47d94da
--- /dev/null
+++ b/daq_system/ui/__pycache__/readout_panel.cpython-312.pyc
Binary files differ
diff --git a/daq_system/ui/__pycache__/strip_chart.cpython-312.pyc b/daq_system/ui/__pycache__/strip_chart.cpython-312.pyc
new file mode 100644
index 0000000..95dc075
--- /dev/null
+++ b/daq_system/ui/__pycache__/strip_chart.cpython-312.pyc
Binary files differ
diff --git a/daq_system/ui/add_device_dialog.py b/daq_system/ui/add_device_dialog.py
new file mode 100644
index 0000000..8851c99
--- /dev/null
+++ b/daq_system/ui/add_device_dialog.py
@@ -0,0 +1,7 @@
+"""
+ui/add_device_dialog.py
+
+Re-exported from config_dialog for import convenience.
+"""
+
+from ui.config_dialog import AddDeviceDialog # noqa: F401
diff --git a/daq_system/ui/alarm_panel.py b/daq_system/ui/alarm_panel.py
new file mode 100644
index 0000000..590d785
--- /dev/null
+++ b/daq_system/ui/alarm_panel.py
@@ -0,0 +1,85 @@
+"""
+ui/alarm_panel.py — Alarm event log panel.
+"""
+
+from datetime import datetime
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel,
+ QScrollArea, QFrame, QPushButton
+)
+from PyQt6.QtCore import Qt
+
+
+class AlarmEntry(QFrame):
+ def __init__(self, device_id, channel_id, value, kind, timestamp):
+ super().__init__()
+ self.setObjectName("alarmEntry")
+ color = "#f85149" if kind == "high" else "#d29922"
+ self.setStyleSheet(f"border-left: 3px solid {color};")
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(8, 4, 8, 4)
+ icon = "▲" if kind == "high" else "▼"
+ msg = QLabel(f"{icon} {device_id} / {channel_id} = {value:.3f} [{kind.upper()}]")
+ msg.setObjectName("alarmMsg")
+ ts_lbl = QLabel(timestamp)
+ ts_lbl.setObjectName("alarmTs")
+ layout.addWidget(msg)
+ layout.addStretch()
+ layout.addWidget(ts_lbl)
+
+
+class AlarmPanel(QWidget):
+ def __init__(self):
+ super().__init__()
+ self._build_ui()
+
+ def _build_ui(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ hdr_row = QHBoxLayout()
+ hdr = QLabel(" ALARMS")
+ hdr.setObjectName("panelHeader")
+ hdr_row.addWidget(hdr)
+ hdr_row.addStretch()
+ clr = QPushButton("Clear")
+ clr.setObjectName("clearAlarmsBtn")
+ clr.clicked.connect(self.clear_alarms)
+ hdr_row.addWidget(clr)
+ hdr_widget = QWidget()
+ hdr_widget.setLayout(hdr_row)
+ hdr_widget.setObjectName("alarmHeaderWidget")
+ layout.addWidget(hdr_widget)
+
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ self._container = QWidget()
+ self._inner = QVBoxLayout(self._container)
+ self._inner.setContentsMargins(4, 4, 4, 4)
+ self._inner.setSpacing(3)
+ self._inner.addStretch()
+ scroll.setWidget(self._container)
+ layout.addWidget(scroll)
+
+ self._scroll = scroll
+ self._count = 0
+
+ def add_alarm(self, device_id, channel_id, value, kind):
+ ts = datetime.now().strftime("%H:%M:%S")
+ entry = AlarmEntry(device_id, channel_id, value, kind, ts)
+ self._inner.insertWidget(0, entry)
+ self._count += 1
+ # Keep last 200
+ if self._count > 200:
+ item = self._inner.takeAt(self._inner.count() - 2)
+ if item and item.widget():
+ item.widget().deleteLater()
+ self._count -= 1
+
+ def clear_alarms(self):
+ while self._inner.count() > 1:
+ item = self._inner.takeAt(0)
+ if item and item.widget():
+ item.widget().deleteLater()
+ self._count = 0
diff --git a/daq_system/ui/config_dialog.py b/daq_system/ui/config_dialog.py
new file mode 100644
index 0000000..a7b0cdf
--- /dev/null
+++ b/daq_system/ui/config_dialog.py
@@ -0,0 +1,174 @@
+"""
+ui/config_dialog.py — Per-device configuration dialog.
+"""
+
+from PyQt6.QtWidgets import (
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel,
+ QPushButton, QTabWidget, QWidget, QFormLayout,
+ QLineEdit, QDoubleSpinBox, QCheckBox, QScrollArea
+)
+from PyQt6.QtCore import Qt
+
+
+class DeviceConfigDialog(QDialog):
+ def __init__(self, device, parent=None):
+ super().__init__(parent)
+ self.device = device
+ self.setWindowTitle(f"Configure: {device.info.name} [{device.info.device_id}]")
+ self.setMinimumSize(500, 400)
+ self._build()
+
+ def _build(self):
+ layout = QVBoxLayout(self)
+
+ tabs = QTabWidget()
+
+ # Tab 1: Device-specific config widget
+ dev_tab = QScrollArea()
+ dev_tab.setWidgetResizable(True)
+ dev_tab.setWidget(self.device.get_config_widget())
+ tabs.addTab(dev_tab, "Device Settings")
+
+ # Tab 2: Channel config
+ ch_tab = self._build_channel_tab()
+ tabs.addTab(ch_tab, "Channels")
+
+ layout.addWidget(tabs)
+
+ # Buttons
+ btn_row = QHBoxLayout()
+ btn_row.addStretch()
+ ok = QPushButton("Close")
+ ok.setDefault(True)
+ ok.clicked.connect(self.accept)
+ btn_row.addWidget(ok)
+ layout.addLayout(btn_row)
+
+ def _build_channel_tab(self):
+ w = QWidget()
+ layout = QVBoxLayout(w)
+ for ch in self.device.info.channels:
+ grp_layout = QFormLayout()
+ name_edit = QLineEdit(ch.name)
+ unit_edit = QLineEdit(ch.unit)
+ en_check = QCheckBox()
+ en_check.setChecked(ch.enabled)
+
+ lo_spin = QDoubleSpinBox()
+ lo_spin.setRange(-1e9, 1e9)
+ lo_spin.setValue(ch.alarm_low or 0.0)
+
+ hi_spin = QDoubleSpinBox()
+ hi_spin.setRange(-1e9, 1e9)
+ hi_spin.setValue(ch.alarm_high or 100.0)
+
+ grp_layout.addRow(f"[{ch.channel_id}] Name:", name_edit)
+ grp_layout.addRow("Unit:", unit_edit)
+ grp_layout.addRow("Enabled:", en_check)
+ grp_layout.addRow("Alarm Low:", lo_spin)
+ grp_layout.addRow("Alarm High:", hi_spin)
+
+ def make_apply(c, ne, ue, ec, ls, hs):
+ def apply():
+ c.name = ne.text()
+ c.unit = ue.text()
+ c.enabled = ec.isChecked()
+ c.alarm_low = ls.value()
+ c.alarm_high = hs.value()
+ return apply
+
+ apply_btn = QPushButton("Apply")
+ apply_btn.clicked.connect(make_apply(ch, name_edit, unit_edit, en_check, lo_spin, hi_spin))
+ grp_layout.addRow(apply_btn)
+ layout.addLayout(grp_layout)
+
+ layout.addStretch()
+ return w
+
+
+# ------------------------------------------------------------------ #
+# Add Device Dialog #
+# ------------------------------------------------------------------ #
+
+"""
+ui/add_device_dialog.py — Dialog for adding a new device to the system.
+"""
+
+from PyQt6.QtWidgets import (
+ QDialog, QVBoxLayout, QFormLayout, QComboBox,
+ QLineEdit, QCheckBox, QSpinBox, QPushButton, QHBoxLayout, QLabel
+)
+
+from devices.device_registry import DeviceRegistry
+from devices.analog_input import AnalogInputDevice
+from devices.digital_io import DigitalIODevice
+from devices.temperature import TemperatureDevice
+from devices.serial_device import SerialDevice
+
+
+DEVICE_CONSTRUCTORS = {
+ "Analog Input": AnalogInputDevice,
+ "Digital I/O": DigitalIODevice,
+ "Temperature": TemperatureDevice,
+ "Serial / UART": SerialDevice,
+}
+
+
+class AddDeviceDialog(QDialog):
+ def __init__(self, registry: DeviceRegistry, parent=None):
+ super().__init__(parent)
+ self.registry = registry
+ self.created_device = None
+ self.setWindowTitle("Add Device")
+ self.setMinimumWidth(340)
+ self._build()
+
+ def _build(self):
+ layout = QVBoxLayout(self)
+ form = QFormLayout()
+
+ self.type_cb = QComboBox()
+ self.type_cb.addItems(list(DEVICE_CONSTRUCTORS.keys()))
+ form.addRow("Device Type:", self.type_cb)
+
+ self.id_edit = QLineEdit()
+ self.id_edit.setPlaceholderText("e.g. ai_1")
+ form.addRow("Device ID:", self.id_edit)
+
+ self.ch_spin = QSpinBox()
+ self.ch_spin.setRange(1, 16)
+ self.ch_spin.setValue(4)
+ form.addRow("# Channels:", self.ch_spin)
+
+ self.sim_check = QCheckBox("Simulation Mode")
+ self.sim_check.setChecked(True)
+ form.addRow(self.sim_check)
+
+ layout.addLayout(form)
+
+ btns = QHBoxLayout()
+ btns.addStretch()
+ cancel = QPushButton("Cancel")
+ cancel.clicked.connect(self.reject)
+ add = QPushButton("Add")
+ add.setDefault(True)
+ add.clicked.connect(self._on_add)
+ btns.addWidget(cancel)
+ btns.addWidget(add)
+ layout.addLayout(btns)
+
+ def _on_add(self):
+ dev_type = self.type_cb.currentText()
+ dev_id = self.id_edit.text().strip() or f"dev_{len(self.registry)}"
+ cls = DEVICE_CONSTRUCTORS[dev_type]
+ try:
+ dev = cls(
+ device_id=dev_id,
+ num_channels=self.ch_spin.value(),
+ simulate=self.sim_check.isChecked()
+ )
+ self.created_device = dev
+ self.accept()
+ except Exception as e:
+ from PyQt6.QtWidgets import QMessageBox
+ QMessageBox.critical(self, "Error", str(e))
diff --git a/daq_system/ui/device_panel.py b/daq_system/ui/device_panel.py
new file mode 100644
index 0000000..9b6c7e4
--- /dev/null
+++ b/daq_system/ui/device_panel.py
@@ -0,0 +1,127 @@
+"""
+ui/device_panel.py
+
+Left sidebar showing all registered devices with status indicators.
+"""
+
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QLabel, QPushButton,
+ QHBoxLayout, QScrollArea, QFrame
+)
+from PyQt6.QtCore import Qt, pyqtSignal
+
+from devices.base_device import DeviceStatus
+from devices.device_registry import DeviceRegistry
+from core.acquisition import AcquisitionEngine
+
+
+class DeviceCard(QFrame):
+ config_clicked = pyqtSignal(str)
+ toggle_clicked = pyqtSignal(str, bool)
+
+ STATUS_COLORS = {
+ DeviceStatus.CONNECTED: "#3fb950",
+ DeviceStatus.SIMULATED: "#58a6ff",
+ DeviceStatus.DISCONNECTED: "#8b949e",
+ DeviceStatus.CONNECTING: "#d29922",
+ DeviceStatus.ERROR: "#f85149",
+ }
+
+ def __init__(self, device, parent=None):
+ super().__init__(parent)
+ self.device = device
+ self.setObjectName("deviceCard")
+ self._build()
+
+ def _build(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(8, 8, 8, 8)
+ layout.setSpacing(4)
+
+ # Header row
+ hdr = QHBoxLayout()
+ icon = QLabel(self.device.info.icon)
+ icon.setObjectName("deviceIcon")
+ hdr.addWidget(icon)
+
+ name = QLabel(self.device.info.name)
+ name.setObjectName("deviceName")
+ hdr.addWidget(name)
+ hdr.addStretch()
+
+ color = self.STATUS_COLORS.get(self.device.status, "#8b949e")
+ self.status_dot = QLabel("●")
+ self.status_dot.setStyleSheet(f"color: {color}; font-size: 10px;")
+ hdr.addWidget(self.status_dot)
+
+ layout.addLayout(hdr)
+
+ # ID + type
+ sub = QLabel(f"{self.device.info.device_id} · {self.device.info.device_type}")
+ sub.setObjectName("deviceSub")
+ layout.addWidget(sub)
+
+ # Channel count
+ ch_count = sum(1 for ch in self.device.info.channels if ch.enabled)
+ ch_lbl = QLabel(f"{ch_count} channel{'s' if ch_count != 1 else ''}")
+ ch_lbl.setObjectName("deviceChannelCount")
+ layout.addWidget(ch_lbl)
+
+ # Config button
+ cfg_btn = QPushButton("Configure")
+ cfg_btn.setObjectName("configButton")
+ cfg_btn.clicked.connect(lambda: self.config_clicked.emit(self.device.info.device_id))
+ layout.addWidget(cfg_btn)
+
+ def refresh_status(self):
+ color = self.STATUS_COLORS.get(self.device.status, "#8b949e")
+ self.status_dot.setStyleSheet(f"color: {color}; font-size: 10px;")
+
+
+class DevicePanel(QWidget):
+ config_requested = pyqtSignal(str)
+
+ def __init__(self, registry: DeviceRegistry, engine: AcquisitionEngine):
+ super().__init__()
+ self.registry = registry
+ self.engine = engine
+ self._cards = {}
+ self._build_ui()
+ self.refresh()
+
+ def _build_ui(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ header = QLabel(" DEVICES")
+ header.setObjectName("panelHeader")
+ layout.addWidget(header)
+
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ scroll.setObjectName("deviceScroll")
+
+ self._container = QWidget()
+ self._inner = QVBoxLayout(self._container)
+ self._inner.setContentsMargins(8, 8, 8, 8)
+ self._inner.setSpacing(8)
+ self._inner.addStretch()
+
+ scroll.setWidget(self._container)
+ layout.addWidget(scroll)
+
+ def refresh(self):
+ # Remove old cards
+ for card in self._cards.values():
+ self._inner.removeWidget(card)
+ card.deleteLater()
+ self._cards.clear()
+
+ for dev in self.registry.all_instances():
+ card = DeviceCard(dev)
+ card.config_clicked.connect(self.config_requested.emit)
+ # Insert before the stretch
+ self._inner.insertWidget(self._inner.count() - 1, card)
+ self._cards[dev.info.device_id] = card
diff --git a/daq_system/ui/main_window.py b/daq_system/ui/main_window.py
new file mode 100644
index 0000000..8b602d1
--- /dev/null
+++ b/daq_system/ui/main_window.py
@@ -0,0 +1,228 @@
+"""
+ui/main_window.py
+
+Main application window. Hosts the left sidebar (device list),
+center strip chart, right panel (numeric readouts + alarms).
+"""
+
+from PyQt6.QtWidgets import (
+ QMainWindow, QWidget, QHBoxLayout, QVBoxLayout,
+ QSplitter, QStatusBar, QLabel, QPushButton,
+ QToolBar, QMessageBox, QFileDialog
+)
+from PyQt6.QtCore import Qt, QTimer, pyqtSlot
+from PyQt6.QtGui import QAction, QIcon, QFont
+
+from devices.analog_input import AnalogInputDevice
+from devices.digital_io import DigitalIODevice
+from devices.temperature import TemperatureDevice
+from devices.serial_device import SerialDevice
+from devices.device_registry import DeviceRegistry
+from core.acquisition import AcquisitionEngine
+
+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
+
+
+class MainWindow(QMainWindow):
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("LabDAQ — Data Acquisition System")
+ self.setMinimumSize(1280, 780)
+
+ self.registry = DeviceRegistry()
+ self.engine = AcquisitionEngine(poll_interval_ms=100)
+ self._running = False
+
+ self._init_default_devices()
+ self._build_ui()
+ self._connect_signals()
+
+ # ------------------------------------------------------------------ #
+ # Default demo configuration #
+ # ------------------------------------------------------------------ #
+
+ def _init_default_devices(self):
+ ai = AnalogInputDevice(device_id="ai_0", num_channels=4, simulate=True)
+ ai.connect()
+ temp = TemperatureDevice(device_id="temp_0", num_channels=3, simulate=True)
+ temp.connect()
+ dio = DigitalIODevice(device_id="dio_0", num_inputs=4, num_outputs=4, simulate=True)
+ dio.connect()
+
+ for dev in [ai, temp, dio]:
+ self.registry.add_instance(dev)
+ self.engine.add_device(dev)
+
+ # ------------------------------------------------------------------ #
+ # UI construction #
+ # ------------------------------------------------------------------ #
+
+ def _build_ui(self):
+ # ---- Toolbar ----
+ toolbar = QToolBar("Main")
+ toolbar.setMovable(False)
+ toolbar.setObjectName("mainToolbar")
+ self.addToolBar(toolbar)
+
+ self.run_btn = QPushButton("▶ RUN")
+ self.run_btn.setObjectName("runButton")
+ self.run_btn.setCheckable(True)
+ self.run_btn.clicked.connect(self._toggle_run)
+
+ 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)
+
+ add_dev_btn = QPushButton("+ Device")
+ add_dev_btn.setObjectName("addDeviceButton")
+ add_dev_btn.clicked.connect(self._add_device)
+
+ toolbar.addWidget(self.run_btn)
+ toolbar.addSeparator()
+ toolbar.addWidget(self.log_btn)
+ toolbar.addSeparator()
+ toolbar.addWidget(add_dev_btn)
+
+ # Spacer
+ spacer = QWidget()
+ spacer.setSizePolicy(
+ spacer.sizePolicy().horizontalPolicy(),
+ spacer.sizePolicy().verticalPolicy()
+ )
+ from PyQt6.QtWidgets import QSizePolicy
+ spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+ toolbar.addWidget(spacer)
+
+ self.time_label = QLabel("00:00:00")
+ self.time_label.setObjectName("timeLabel")
+ toolbar.addWidget(self.time_label)
+
+ # ---- Central layout ----
+ central = QWidget()
+ self.setCentralWidget(central)
+ root = QHBoxLayout(central)
+ root.setContentsMargins(0, 0, 0, 0)
+ root.setSpacing(0)
+
+ splitter = QSplitter(Qt.Orientation.Horizontal)
+ splitter.setHandleWidth(4)
+
+ # Left: device panel
+ self.device_panel = DevicePanel(self.registry, self.engine)
+ self.device_panel.setMinimumWidth(200)
+ splitter.addWidget(self.device_panel)
+
+ # Center: strip chart
+ self.strip_chart = StripChartWidget(self.engine, self.registry)
+ splitter.addWidget(self.strip_chart)
+
+ # Right: readouts + alarms
+ right_panel = QWidget()
+ right_layout = QVBoxLayout(right_panel)
+ right_layout.setContentsMargins(0, 0, 0, 0)
+ right_layout.setSpacing(0)
+
+ self.readout_panel = ReadoutPanel(self.registry)
+ self.alarm_panel = AlarmPanel()
+
+ right_splitter = QSplitter(Qt.Orientation.Vertical)
+ right_splitter.addWidget(self.readout_panel)
+ right_splitter.addWidget(self.alarm_panel)
+ right_layout.addWidget(right_splitter)
+
+ right_panel.setMinimumWidth(220)
+ splitter.addWidget(right_panel)
+
+ splitter.setSizes([220, 820, 240])
+ root.addWidget(splitter)
+
+ # ---- Status bar ----
+ self.status_bar = QStatusBar()
+ self.setStatusBar(self.status_bar)
+ self._status_lbl = QLabel("Ready")
+ self.status_bar.addWidget(self._status_lbl)
+
+ # Clock timer
+ self._elapsed = 0
+ self._clock = QTimer()
+ self._clock.setInterval(1000)
+ self._clock.timeout.connect(self._tick_clock)
+
+ 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.strip_chart.on_new_data)
+ self.device_panel.config_requested.connect(self._open_config)
+
+ # ------------------------------------------------------------------ #
+ # Actions #
+ # ------------------------------------------------------------------ #
+
+ def _toggle_run(self, checked: bool):
+ if checked:
+ self.engine.start()
+ self.run_btn.setText("⏹ STOP")
+ self.log_btn.setEnabled(True)
+ self._clock.start()
+ self._status_lbl.setText("Acquiring data…")
+ self._running = True
+ else:
+ self.engine.stop()
+ self.run_btn.setText("▶ RUN")
+ self.log_btn.setEnabled(False)
+ if self.log_btn.isChecked():
+ self.log_btn.setChecked(False)
+ self._toggle_log(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()
+ self.log_btn.setText(f"⏹ LOGGING")
+ self._status_lbl.setText(f"Logging → {path}")
+ else:
+ self.engine.stop_logging()
+ self.log_btn.setText("⬤ LOG")
+ self._status_lbl.setText("Log saved.")
+
+ def _add_device(self):
+ from ui.add_device_dialog import AddDeviceDialog
+ dlg = AddDeviceDialog(self.registry, self)
+ if dlg.exec():
+ dev = dlg.created_device
+ if dev:
+ self.registry.add_instance(dev)
+ self.engine.add_device(dev)
+ dev.connect()
+ self.device_panel.refresh()
+ self.readout_panel.refresh()
+ self.strip_chart.refresh()
+
+ def _open_config(self, device_id: str):
+ dev = self.registry.get_instance(device_id)
+ if dev:
+ dlg = DeviceConfigDialog(dev, self)
+ dlg.exec()
+
+ @pyqtSlot(str, str, float, str)
+ def _on_alarm(self, device_id: str, channel_id: str, value: float, kind: str):
+ self.alarm_panel.add_alarm(device_id, channel_id, value, kind)
+
+ def _tick_clock(self):
+ self._elapsed += 1
+ h = self._elapsed // 3600
+ m = (self._elapsed % 3600) // 60
+ s = self._elapsed % 60
+ self.time_label.setText(f"{h:02d}:{m:02d}:{s:02d}")
+
+ def closeEvent(self, event):
+ self.engine.stop()
+ event.accept()
diff --git a/daq_system/ui/readout_panel.py b/daq_system/ui/readout_panel.py
new file mode 100644
index 0000000..6086c30
--- /dev/null
+++ b/daq_system/ui/readout_panel.py
@@ -0,0 +1,128 @@
+"""
+ui/readout_panel.py
+
+Right-side numeric readout panel. Shows current value for every channel
+with a colored bar indicator and alarm highlighting.
+"""
+
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel,
+ QScrollArea, QFrame, QProgressBar
+)
+from PyQt6.QtCore import Qt, pyqtSlot
+from PyQt6.QtGui import QColor
+
+from devices.device_registry import DeviceRegistry
+
+
+class ChannelReadout(QFrame):
+ def __init__(self, device_name: str, channel_config, parent=None):
+ super().__init__(parent)
+ self.ch = channel_config
+ self.device_name = device_name
+ self.setObjectName("channelReadout")
+ self._alarm_active = False
+ self._build()
+
+ def _build(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(8, 6, 8, 6)
+ layout.setSpacing(2)
+
+ top = QHBoxLayout()
+ self.name_lbl = QLabel(f"{self.ch.name}")
+ self.name_lbl.setObjectName("readoutName")
+ top.addWidget(self.name_lbl)
+ top.addStretch()
+ self.value_lbl = QLabel("---")
+ self.value_lbl.setObjectName("readoutValue")
+ self.value_lbl.setStyleSheet(f"color: {self.ch.color};")
+ top.addWidget(self.value_lbl)
+ unit = QLabel(self.ch.unit)
+ unit.setObjectName("readoutUnit")
+ top.addWidget(unit)
+ layout.addLayout(top)
+
+ # Mini bar
+ self.bar = QProgressBar()
+ self.bar.setRange(0, 1000)
+ self.bar.setValue(0)
+ self.bar.setTextVisible(False)
+ self.bar.setMaximumHeight(4)
+ self.bar.setObjectName("readoutBar")
+ self.bar.setStyleSheet(
+ f"QProgressBar::chunk {{ background: {self.ch.color}; border-radius: 2px; }}"
+ )
+ layout.addWidget(self.bar)
+
+ def update_value(self, value: float):
+ self.value_lbl.setText(f"{value:.3f}")
+ rng = self.ch.max_value - self.ch.min_value
+ if rng > 0:
+ norm = int(((value - self.ch.min_value) / rng) * 1000)
+ self.bar.setValue(max(0, min(1000, norm)))
+
+ def set_alarm(self, active: bool):
+ if active != self._alarm_active:
+ self._alarm_active = active
+ self.setProperty("alarm", active)
+ self.style().unpolish(self)
+ self.style().polish(self)
+
+
+class ReadoutPanel(QWidget):
+ def __init__(self, registry: DeviceRegistry):
+ super().__init__()
+ self.registry = registry
+ self._readouts: dict = {} # (device_id, channel_id) -> ChannelReadout
+ self._build_ui()
+ self.refresh()
+
+ def _build_ui(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ hdr = QLabel(" CHANNELS")
+ hdr.setObjectName("panelHeader")
+ layout.addWidget(hdr)
+
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+
+ self._container = QWidget()
+ self._inner = QVBoxLayout(self._container)
+ self._inner.setContentsMargins(6, 6, 6, 6)
+ self._inner.setSpacing(4)
+ self._inner.addStretch()
+
+ scroll.setWidget(self._container)
+ layout.addWidget(scroll)
+
+ def refresh(self):
+ for w in self._readouts.values():
+ self._inner.removeWidget(w)
+ w.deleteLater()
+ self._readouts.clear()
+
+ for dev in self.registry.all_instances():
+ for ch in dev.info.channels:
+ if not ch.enabled:
+ continue
+ ro = ChannelReadout(dev.info.name, ch)
+ self._inner.insertWidget(self._inner.count() - 1, ro)
+ self._readouts[(dev.info.device_id, ch.channel_id)] = ro
+
+ @pyqtSlot(str, str, float, float)
+ def on_new_data(self, device_id: str, channel_id: str, timestamp: float, value: float):
+ ro = self._readouts.get((device_id, channel_id))
+ if ro:
+ ro.update_value(value)
+ ch = ro.ch
+ alarm = False
+ if ch.alarm_low is not None and value < ch.alarm_low:
+ alarm = True
+ if ch.alarm_high is not None and value > ch.alarm_high:
+ alarm = True
+ ro.set_alarm(alarm)
diff --git a/daq_system/ui/strip_chart.py b/daq_system/ui/strip_chart.py
new file mode 100644
index 0000000..46d73fa
--- /dev/null
+++ b/daq_system/ui/strip_chart.py
@@ -0,0 +1,140 @@
+"""
+ui/strip_chart.py
+
+Live scrolling strip chart using pyqtgraph.
+Each channel gets its own colored trace. Window duration is adjustable.
+"""
+
+from typing import Dict, List
+import numpy as np
+
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel,
+ QComboBox, QDoubleSpinBox, QCheckBox, QScrollArea,
+ QPushButton, QSizePolicy
+)
+from PyQt6.QtCore import Qt, pyqtSlot
+
+try:
+ import pyqtgraph as pg
+ pg.setConfigOptions(antialias=True, background="#0d1117", foreground="#c9d1d9")
+ HAS_PG = True
+except ImportError:
+ HAS_PG = False
+
+from devices.device_registry import DeviceRegistry
+from core.acquisition import AcquisitionEngine
+
+
+class StripChartWidget(QWidget):
+ def __init__(self, engine: AcquisitionEngine, registry: DeviceRegistry):
+ super().__init__()
+ self.engine = engine
+ self.registry = registry
+ self._plots: Dict[str, Dict] = {} # device_id -> {channel_id -> curve}
+ self._window_s = 30.0
+ self._paused = False
+ self._build_ui()
+ self.refresh()
+
+ def _build_ui(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(4, 4, 4, 4)
+ layout.setSpacing(4)
+
+ # Control bar
+ ctrl = QHBoxLayout()
+ ctrl.addWidget(QLabel("Window:"))
+
+ self.window_spin = QDoubleSpinBox()
+ self.window_spin.setRange(1, 600)
+ self.window_spin.setValue(self._window_s)
+ self.window_spin.setSuffix(" s")
+ self.window_spin.valueChanged.connect(lambda v: setattr(self, "_window_s", v))
+ ctrl.addWidget(self.window_spin)
+
+ ctrl.addStretch()
+
+ self.pause_btn = QPushButton("⏸ Pause")
+ self.pause_btn.setCheckable(True)
+ self.pause_btn.setObjectName("pauseButton")
+ self.pause_btn.toggled.connect(lambda c: setattr(self, "_paused", c))
+ self.pause_btn.toggled.connect(lambda c: self.pause_btn.setText("▶ Resume" if c else "⏸ Pause"))
+ ctrl.addWidget(self.pause_btn)
+
+ layout.addLayout(ctrl)
+
+ if HAS_PG:
+ self._build_pg_chart(layout)
+ else:
+ layout.addWidget(QLabel(
+ "pyqtgraph not installed.\nRun: pip install pyqtgraph\n\nData is still being acquired.",
+ alignment=Qt.AlignmentFlag.AlignCenter
+ ))
+
+ def _build_pg_chart(self, parent_layout):
+ self.plot_widget = pg.GraphicsLayoutWidget()
+ self.plot_widget.setSizePolicy(
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
+ )
+ parent_layout.addWidget(self.plot_widget)
+
+ def refresh(self):
+ if not HAS_PG:
+ return
+ self.plot_widget.clear()
+ self._plots.clear()
+
+ devices = self.registry.all_instances()
+ num = len(devices)
+ if num == 0:
+ return
+
+ for row_idx, dev in enumerate(devices):
+ plot = self.plot_widget.addPlot(row=row_idx, col=0)
+ plot.setLabel("left", f"{dev.info.icon} {dev.info.name}")
+ plot.showGrid(x=True, y=True, alpha=0.15)
+ plot.getAxis("bottom").setStyle(showValues=(row_idx == num - 1))
+
+ if row_idx < num - 1:
+ plot.getAxis("bottom").setHeight(0)
+ else:
+ plot.setLabel("bottom", "Time (s)")
+
+ self._plots[dev.info.device_id] = {}
+ for ch in dev.info.channels:
+ if not ch.enabled:
+ continue
+ color = ch.color
+ pen = pg.mkPen(color=color, width=1.5)
+ curve = plot.plot([], [], pen=pen, name=ch.name)
+ self._plots[dev.info.device_id][ch.channel_id] = {
+ "curve": curve,
+ "plot": plot,
+ }
+
+ @pyqtSlot(str, str, float, float)
+ def on_new_data(self, device_id: str, channel_id: str, timestamp: float, value: float):
+ if self._paused or not HAS_PG:
+ return
+ dev_plots = self._plots.get(device_id, {})
+ entry = dev_plots.get(channel_id)
+ if entry is None:
+ return
+
+ buf = self.engine.get_buffer(device_id, channel_id)
+ if buf is None or len(buf) < 2:
+ return
+
+ ts, vs = buf.all()
+ ts = np.array(ts, dtype=np.float64)
+ vs = np.array(vs, dtype=np.float64)
+
+ t_max = ts[-1]
+ t_min = t_max - self._window_s
+ mask = ts >= t_min
+ ts = ts[mask]
+ vs = vs[mask]
+
+ entry["curve"].setData(ts, vs)
+ entry["plot"].setXRange(t_min, t_max, padding=0)