diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-04-13 14:36:55 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-04-13 14:36:55 -0600 |
| commit | 8d6acf3a8ea4b37f86b321dbf430be5be01b1267 (patch) | |
| tree | 923251b7ee88c7b5c89500883154bad32a2ecca5 /daq_system/ui | |
init
Diffstat (limited to 'daq_system/ui')
| -rw-r--r-- | daq_system/ui/__pycache__/alarm_panel.cpython-312.pyc | bin | 0 -> 5462 bytes | |||
| -rw-r--r-- | daq_system/ui/__pycache__/config_dialog.cpython-312.pyc | bin | 0 -> 9345 bytes | |||
| -rw-r--r-- | daq_system/ui/__pycache__/device_panel.cpython-312.pyc | bin | 0 -> 7943 bytes | |||
| -rw-r--r-- | daq_system/ui/__pycache__/main_window.cpython-312.pyc | bin | 0 -> 13127 bytes | |||
| -rw-r--r-- | daq_system/ui/__pycache__/readout_panel.cpython-312.pyc | bin | 0 -> 8378 bytes | |||
| -rw-r--r-- | daq_system/ui/__pycache__/strip_chart.cpython-312.pyc | bin | 0 -> 8328 bytes | |||
| -rw-r--r-- | daq_system/ui/add_device_dialog.py | 7 | ||||
| -rw-r--r-- | daq_system/ui/alarm_panel.py | 85 | ||||
| -rw-r--r-- | daq_system/ui/config_dialog.py | 174 | ||||
| -rw-r--r-- | daq_system/ui/device_panel.py | 127 | ||||
| -rw-r--r-- | daq_system/ui/main_window.py | 228 | ||||
| -rw-r--r-- | daq_system/ui/readout_panel.py | 128 | ||||
| -rw-r--r-- | daq_system/ui/strip_chart.py | 140 |
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 Binary files differnew file mode 100644 index 0000000..7f2776c --- /dev/null +++ b/daq_system/ui/__pycache__/alarm_panel.cpython-312.pyc diff --git a/daq_system/ui/__pycache__/config_dialog.cpython-312.pyc b/daq_system/ui/__pycache__/config_dialog.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..d62887f --- /dev/null +++ b/daq_system/ui/__pycache__/config_dialog.cpython-312.pyc diff --git a/daq_system/ui/__pycache__/device_panel.cpython-312.pyc b/daq_system/ui/__pycache__/device_panel.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..0616690 --- /dev/null +++ b/daq_system/ui/__pycache__/device_panel.cpython-312.pyc diff --git a/daq_system/ui/__pycache__/main_window.cpython-312.pyc b/daq_system/ui/__pycache__/main_window.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..d6af385 --- /dev/null +++ b/daq_system/ui/__pycache__/main_window.cpython-312.pyc diff --git a/daq_system/ui/__pycache__/readout_panel.cpython-312.pyc b/daq_system/ui/__pycache__/readout_panel.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..47d94da --- /dev/null +++ b/daq_system/ui/__pycache__/readout_panel.cpython-312.pyc diff --git a/daq_system/ui/__pycache__/strip_chart.cpython-312.pyc b/daq_system/ui/__pycache__/strip_chart.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..95dc075 --- /dev/null +++ b/daq_system/ui/__pycache__/strip_chart.cpython-312.pyc 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) |
