diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-04-13 15:22:56 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-04-13 15:22:56 -0600 |
| commit | 2ed9d37da7b27d25173535550fb92702225ac14e (patch) | |
| tree | d6ff360a656e577629d3cefdaa1ae02af7255864 /ui | |
| parent | 8d6acf3a8ea4b37f86b321dbf430be5be01b1267 (diff) | |
Removed QtPy5 snippet and fixed directories
Diffstat (limited to 'ui')
| -rw-r--r-- | ui/__init__.py | 1 | ||||
| -rw-r--r-- | ui/__pycache__/__init__.cpython-314.pyc | bin | 0 -> 153 bytes | |||
| -rw-r--r-- | ui/__pycache__/add_device_dialog.cpython-314.pyc | bin | 0 -> 5942 bytes | |||
| -rw-r--r-- | ui/__pycache__/alarm_panel.cpython-314.pyc | bin | 0 -> 6113 bytes | |||
| -rw-r--r-- | ui/__pycache__/config_dialog.cpython-314.pyc | bin | 0 -> 7331 bytes | |||
| -rw-r--r-- | ui/__pycache__/device_panel.cpython-314.pyc | bin | 0 -> 10002 bytes | |||
| -rw-r--r-- | ui/__pycache__/main_window.cpython-314.pyc | bin | 0 -> 15798 bytes | |||
| -rw-r--r-- | ui/__pycache__/readout_panel.cpython-314.pyc | bin | 0 -> 9028 bytes | |||
| -rw-r--r-- | ui/__pycache__/strip_chart.cpython-314.pyc | bin | 0 -> 11052 bytes | |||
| -rw-r--r-- | ui/add_device_dialog.py | 100 | ||||
| -rw-r--r-- | ui/alarm_panel.py | 85 | ||||
| -rw-r--r-- | ui/config_dialog.py | 111 | ||||
| -rw-r--r-- | ui/device_panel.py | 148 | ||||
| -rw-r--r-- | ui/main_window.py | 214 | ||||
| -rw-r--r-- | ui/readout_panel.py | 122 | ||||
| -rw-r--r-- | ui/strip_chart.py | 186 | ||||
| -rw-r--r-- | ui/style.qss | 378 |
17 files changed, 1345 insertions, 0 deletions
diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..dfde25c --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1 @@ +# ui/__init__.py diff --git a/ui/__pycache__/__init__.cpython-314.pyc b/ui/__pycache__/__init__.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..6e83590 --- /dev/null +++ b/ui/__pycache__/__init__.cpython-314.pyc diff --git a/ui/__pycache__/add_device_dialog.cpython-314.pyc b/ui/__pycache__/add_device_dialog.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..280fdbf --- /dev/null +++ b/ui/__pycache__/add_device_dialog.cpython-314.pyc diff --git a/ui/__pycache__/alarm_panel.cpython-314.pyc b/ui/__pycache__/alarm_panel.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..a1811f7 --- /dev/null +++ b/ui/__pycache__/alarm_panel.cpython-314.pyc diff --git a/ui/__pycache__/config_dialog.cpython-314.pyc b/ui/__pycache__/config_dialog.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..8876af3 --- /dev/null +++ b/ui/__pycache__/config_dialog.cpython-314.pyc diff --git a/ui/__pycache__/device_panel.cpython-314.pyc b/ui/__pycache__/device_panel.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..07eb750 --- /dev/null +++ b/ui/__pycache__/device_panel.cpython-314.pyc diff --git a/ui/__pycache__/main_window.cpython-314.pyc b/ui/__pycache__/main_window.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..27e4279 --- /dev/null +++ b/ui/__pycache__/main_window.cpython-314.pyc diff --git a/ui/__pycache__/readout_panel.cpython-314.pyc b/ui/__pycache__/readout_panel.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..9258b10 --- /dev/null +++ b/ui/__pycache__/readout_panel.cpython-314.pyc diff --git a/ui/__pycache__/strip_chart.cpython-314.pyc b/ui/__pycache__/strip_chart.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..4af6ff5 --- /dev/null +++ b/ui/__pycache__/strip_chart.cpython-314.pyc diff --git a/ui/add_device_dialog.py b/ui/add_device_dialog.py new file mode 100644 index 0000000..30d0016 --- /dev/null +++ b/ui/add_device_dialog.py @@ -0,0 +1,100 @@ +""" +ui/add_device_dialog.py — Dialog to add a new device at runtime. +""" + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QFormLayout, QHBoxLayout, + QComboBox, QLineEdit, QSpinBox, QCheckBox, + QPushButton, QLabel, QMessageBox, +) + +from devices.device_registry import DeviceRegistry +from devices.analog_input import AnalogInputDevice +from devices.digital_io import DigitalIODevice +from devices.serial_device import SerialDevice + + +# Map display name -> (class, extra_kwargs_defaults) +_DEVICE_TYPES = { + "Analog Input — NI-DAQmx": (AnalogInputDevice, {"backend": "nidaqmx"}), + "Analog Input — Arduino": (AnalogInputDevice, {"backend": "arduino"}), + "Digital I/O — NI-DAQmx": (DigitalIODevice, {"backend": "nidaqmx"}), + "Digital I/O — Arduino": (DigitalIODevice, {"backend": "arduino"}), + "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(360) + self._build() + + def _build(self): + layout = QVBoxLayout(self) + form = QFormLayout() + + self._type_cb = QComboBox() + self._type_cb.addItems(list(_DEVICE_TYPES.keys())) + form.addRow("Device Type:", self._type_cb) + + self._id_edit = QLineEdit() + self._id_edit.setPlaceholderText("e.g. ai_1 / ser_0") + 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_chk = QCheckBox("Simulation mode (no hardware required)") + self._sim_chk.setChecked(True) + form.addRow(self._sim_chk) + + layout.addLayout(form) + + btns = QHBoxLayout() + btns.addStretch() + cancel = QPushButton("Cancel") + cancel.clicked.connect(self.reject) + add = QPushButton("Add Device") + add.setDefault(True) + add.setObjectName("applyButton") + add.clicked.connect(self._on_add) + btns.addWidget(cancel) + btns.addWidget(add) + layout.addLayout(btns) + + def _on_add(self): + label = self._type_cb.currentText() + cls, kw = _DEVICE_TYPES[label] + dev_id = self._id_edit.text().strip() + + if not dev_id: + base = kw.get("backend", "dev") + existing = {d.info.device_id for d in self.registry.all_instances()} + for i in range(100): + candidate = f"{base}_{i}" + if candidate not in existing: + dev_id = candidate + break + + if self.registry.get_instance(dev_id): + QMessageBox.warning(self, "Duplicate ID", + f"A device with ID '{dev_id}' already exists.") + return + + try: + dev = cls( + device_id=dev_id, + num_channels=self._ch_spin.value(), + simulate=self._sim_chk.isChecked(), + **kw, + ) + self.created_device = dev + self.accept() + except Exception as e: + QMessageBox.critical(self, "Error creating device", str(e)) diff --git a/ui/alarm_panel.py b/ui/alarm_panel.py new file mode 100644 index 0000000..1270aff --- /dev/null +++ b/ui/alarm_panel.py @@ -0,0 +1,85 @@ +""" +ui/alarm_panel.py — Alarm event log. +""" + +from datetime import datetime +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QScrollArea, QFrame, QPushButton, +) +from PyQt6.QtCore import Qt + + +class AlarmRow(QFrame): + def __init__(self, dev_id: str, ch_id: str, value: float, kind: str): + super().__init__() + self.setObjectName("alarmEntry") + color = "#ef4444" if kind == "high" else "#f59e0b" + arrow = "▲" if kind == "high" else "▼" + self.setStyleSheet(f"QFrame#alarmEntry {{ border-left: 3px solid {color}; }}") + + ts_str = datetime.now().strftime("%H:%M:%S.%f")[:11] + layout = QHBoxLayout(self) + layout.setContentsMargins(8, 3, 8, 3) + + msg = QLabel(f"{arrow} {dev_id} / {ch_id} = {value:.4f} [{kind.upper()}]") + msg.setObjectName("alarmMsg") + ts = QLabel(ts_str) + ts.setObjectName("alarmTs") + + layout.addWidget(msg, 1) + layout.addWidget(ts) + + +class AlarmPanel(QWidget): + def __init__(self): + super().__init__() + self._count = 0 + self._build() + + def _build(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + hdr_w = QWidget() + hdr_w.setObjectName("alarmHeaderWidget") + hdr_lay = QHBoxLayout(hdr_w) + hdr_lay.setContentsMargins(0, 0, 4, 0) + hdr = QLabel(" ALARMS") + hdr.setObjectName("panelHeader") + hdr.setMinimumHeight(28) + hdr_lay.addWidget(hdr, 1) + clr = QPushButton("Clear") + clr.setObjectName("clearAlarmsBtn") + clr.clicked.connect(self.clear) + hdr_lay.addWidget(clr) + layout.addWidget(hdr_w) + + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._container = QWidget() + self._inner = QVBoxLayout(self._container) + self._inner.setContentsMargins(4, 4, 4, 4) + self._inner.setSpacing(2) + self._inner.addStretch() + self._scroll.setWidget(self._container) + layout.addWidget(self._scroll) + + def add_alarm(self, dev_id: str, ch_id: str, value: float, kind: str): + row = AlarmRow(dev_id, ch_id, value, kind) + self._inner.insertWidget(0, row) + self._count += 1 + # Cap at 300 entries + if self._count > 300: + item = self._inner.takeAt(self._inner.count() - 2) + if item and item.widget(): + item.widget().deleteLater() + self._count -= 1 + + def clear(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/ui/config_dialog.py b/ui/config_dialog.py new file mode 100644 index 0000000..7d87772 --- /dev/null +++ b/ui/config_dialog.py @@ -0,0 +1,111 @@ +""" +ui/config_dialog.py — Device configuration dialog (tabbed). +""" + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, + QWidget, QFormLayout, QGroupBox, QScrollArea, + QLabel, QLineEdit, QDoubleSpinBox, QCheckBox, + QPushButton, +) +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(520, 460) + self._build() + + def _build(self): + layout = QVBoxLayout(self) + layout.setSpacing(8) + + tabs = QTabWidget() + + # ── Tab 1: Device-specific widget ──────────────────────────── + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setWidget(self.device.get_config_widget()) + tabs.addTab(scroll, "Hardware / Backend") + + # ── Tab 2: Channel settings ─────────────────────────────────── + tabs.addTab(self._channel_tab(), "Channels & Alarms") + + # ── Tab 3: Device info ──────────────────────────────────────── + tabs.addTab(self._info_tab(), "Info") + + layout.addWidget(tabs) + + btn_row = QHBoxLayout() + btn_row.addStretch() + close_btn = QPushButton("Close") + close_btn.setDefault(True) + close_btn.clicked.connect(self.accept) + btn_row.addWidget(close_btn) + layout.addLayout(btn_row) + + def _channel_tab(self): + w = QScrollArea() + w.setWidgetResizable(True) + container = QWidget() + layout = QVBoxLayout(container) + + for ch in self.device.info.channels: + grp = QGroupBox(f"{ch.channel_id} — {ch.name}") + form = QFormLayout(grp) + + name_e = QLineEdit(ch.name) + unit_e = QLineEdit(ch.unit) + en_chk = QCheckBox() + en_chk.setChecked(ch.enabled) + + lo = QDoubleSpinBox(); lo.setRange(-1e9, 1e9); lo.setValue(ch.alarm_low or 0.0) + hi = QDoubleSpinBox(); hi.setRange(-1e9, 1e9); hi.setValue(ch.alarm_high or 100.0) + + form.addRow("Name:", name_e) + form.addRow("Unit:", unit_e) + form.addRow("Enabled:", en_chk) + form.addRow("Alarm Low:", lo) + form.addRow("Alarm High:", hi) + + apply = QPushButton("Apply") + apply.setObjectName("applyButton") + + def _make_apply(c, ne, ue, ec, ls, hs): + def _do(): + c.name = ne.text() + c.unit = ue.text() + c.enabled = ec.isChecked() + c.alarm_low = ls.value() + c.alarm_high = hs.value() + return _do + + apply.clicked.connect(_make_apply(ch, name_e, unit_e, en_chk, lo, hi)) + form.addRow(apply) + layout.addWidget(grp) + + layout.addStretch() + w.setWidget(container) + return w + + def _info_tab(self): + w = QWidget() + form = QFormLayout(w) + info = self.device.info + + def _ro(v): + e = QLineEdit(str(v)); e.setReadOnly(True); return e + + form.addRow("Device ID:", _ro(info.device_id)) + form.addRow("Name:", _ro(info.name)) + form.addRow("Type:", _ro(info.device_type)) + form.addRow("Description:", _ro(info.description)) + form.addRow("Manufacturer:", _ro(info.manufacturer)) + form.addRow("Model:", _ro(info.model)) + form.addRow("Version:", _ro(info.version)) + form.addRow("Status:", _ro(self.device.status.value)) + form.addRow("Channels:", _ro(len(info.channels))) + return w diff --git a/ui/device_panel.py b/ui/device_panel.py new file mode 100644 index 0000000..932f536 --- /dev/null +++ b/ui/device_panel.py @@ -0,0 +1,148 @@ +""" +ui/device_panel.py — Left sidebar: device list with status indicators. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QScrollArea, QFrame, QSizePolicy, +) +from PyQt6.QtCore import Qt, pyqtSignal, QTimer +from devices.base_device import DeviceStatus +from devices.device_registry import DeviceRegistry +from core.acquisition import AcquisitionEngine + + +_STATUS_STYLE = { + DeviceStatus.CONNECTED: "color:#22c55e;", + DeviceStatus.SIMULATED: "color:#3b82f6;", + DeviceStatus.DISCONNECTED: "color:#475569;", + DeviceStatus.CONNECTING: "color:#f59e0b;", + DeviceStatus.ERROR: "color:#ef4444;", +} +_STATUS_LABEL = { + DeviceStatus.CONNECTED: "CONNECTED", + DeviceStatus.SIMULATED: "SIMULATED", + DeviceStatus.DISCONNECTED: "OFFLINE", + DeviceStatus.CONNECTING: "CONNECTING", + DeviceStatus.ERROR: "ERROR", +} + + +class DeviceCard(QFrame): + config_requested = pyqtSignal(str) + + def __init__(self, device): + super().__init__() + self.device = device + self.setObjectName("deviceCard") + self._build() + + def _build(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(5) + + # Header row: icon + name + status dot + hdr = QHBoxLayout() + icon_lbl = QLabel(self.device.info.icon) + icon_lbl.setObjectName("deviceIcon") + hdr.addWidget(icon_lbl) + + name_lbl = QLabel(self.device.info.name) + name_lbl.setObjectName("deviceName") + hdr.addWidget(name_lbl, 1) + + self._dot = QLabel("●") + self._dot.setStyleSheet(_STATUS_STYLE.get(self.device.status, "color:#475569;") + " font-size:10px;") + hdr.addWidget(self._dot) + layout.addLayout(hdr) + + # Device ID + id_lbl = QLabel(self.device.info.device_id) + id_lbl.setObjectName("deviceSub") + layout.addWidget(id_lbl) + + # Status text + self._status_lbl = QLabel(_STATUS_LABEL.get(self.device.status, "UNKNOWN")) + style = _STATUS_STYLE.get(self.device.status, "color:#475569;") + self._status_lbl.setStyleSheet( + style + " font-family:'IBM Plex Mono',monospace; font-size:10px; font-weight:700; letter-spacing:1px;" + ) + layout.addWidget(self._status_lbl) + + # Channel count + n = sum(1 for c in self.device.info.channels if c.enabled) + ch_lbl = QLabel(f"{n} ch · {self.device.info.device_type}") + ch_lbl.setObjectName("deviceChannelCount") + layout.addWidget(ch_lbl) + + # Config button + cfg = QPushButton("Configure") + cfg.setObjectName("configButton") + cfg.clicked.connect(lambda: self.config_requested.emit(self.device.info.device_id)) + layout.addWidget(cfg) + + def refresh(self): + dot_style = _STATUS_STYLE.get(self.device.status, "color:#475569;") + " font-size:10px;" + label_style = _STATUS_STYLE.get(self.device.status, "color:#475569;") + \ + " font-family:'IBM Plex Mono',monospace; font-size:10px; font-weight:700; letter-spacing:1px;" + self._dot.setStyleSheet(dot_style) + self._status_lbl.setText(_STATUS_LABEL.get(self.device.status, "UNKNOWN")) + self._status_lbl.setStyleSheet(label_style) + + +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() + + # Refresh status every 2 s + self._timer = QTimer(self) + self._timer.setInterval(2000) + self._timer.timeout.connect(self._refresh_status) + self._timer.start() + + def _build(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + hdr = QLabel(" DEVICES") + hdr.setObjectName("panelHeader") + hdr.setMinimumHeight(28) + layout.addWidget(hdr) + + scroll = QScrollArea() + scroll.setObjectName("deviceScroll") + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + + 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): + 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_requested.connect(self.config_requested.emit) + self._inner.insertWidget(self._inner.count() - 1, card) + self._cards[dev.info.device_id] = card + + def _refresh_status(self): + for dev_id, card in self._cards.items(): + card.refresh() diff --git a/ui/main_window.py b/ui/main_window.py new file mode 100644 index 0000000..d329571 --- /dev/null +++ b/ui/main_window.py @@ -0,0 +1,214 @@ +""" +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 │ + └──────────────────────────────────────────────────────────┘ +""" + +from PyQt6.QtWidgets import ( + QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, + QSplitter, QStatusBar, QLabel, QPushButton, + QToolBar, QSizePolicy, QMessageBox, +) +from PyQt6.QtCore import Qt, QTimer, pyqtSlot + +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 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 + + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("LabDAQ — Data Acquisition System") + self.setMinimumSize(1360, 820) + + self.registry = DeviceRegistry() + self.engine = AcquisitionEngine(poll_interval_ms=100) + + self._elapsed = 0 + self._running = False + + self._init_demo_devices() + self._build_ui() + self._connect_signals() + + 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]: + self.registry.add_instance(dev) + self.engine.add_device(dev) + + def _build_ui(self): + tb = QToolBar("Main") + tb.setObjectName("mainToolbar") + tb.setMovable(False) + self.addToolBar(tb) + + 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._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) + 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) + + 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") + tb.addWidget(self._time_lbl) + + 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) + + self._dev_panel = DevicePanel(self.registry, self.engine) + self._dev_panel.setMinimumWidth(210) + self._dev_panel.setMaximumWidth(300) + hsplit.addWidget(self._dev_panel) + + self._chart = StripChartWidget(self.engine, self.registry) + 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]) + 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) + + 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): + if checked: + self.engine.start() + self._run_btn.setText("⏹ STOP") + self._log_btn.setEnabled(True) + self._clock.start() + self._status_lbl.setText("Acquiring…") + self._running = True + 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() + self._log_btn.setText("⏹ LOGGING") + self._status_lbl.setText(f"Logging → {path}") + 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) + + def _tick(self): + self._elapsed += 1 + 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() diff --git a/ui/readout_panel.py b/ui/readout_panel.py new file mode 100644 index 0000000..ef04e18 --- /dev/null +++ b/ui/readout_panel.py @@ -0,0 +1,122 @@ +""" +ui/readout_panel.py — Right-side numeric readout panel. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QScrollArea, QFrame, QProgressBar, +) +from PyQt6.QtCore import Qt, pyqtSlot +from devices.device_registry import DeviceRegistry + + +class ChannelReadout(QFrame): + def __init__(self, ch_config): + super().__init__() + self.ch = ch_config + self.setObjectName("channelReadout") + self._alarm = False + self._build() + + def _build(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 6, 8, 6) + layout.setSpacing(2) + + top = QHBoxLayout() + self._name = QLabel(self.ch.name) + self._name.setObjectName("readoutName") + top.addWidget(self._name, 1) + + self._val = QLabel("— — —") + self._val.setObjectName("readoutValue") + self._val.setStyleSheet(f"color:{self.ch.color};") + top.addWidget(self._val) + + unit = QLabel(f" {self.ch.unit}") + unit.setObjectName("readoutUnit") + top.addWidget(unit) + layout.addLayout(top) + + self._bar = QProgressBar() + self._bar.setObjectName("readoutBar") + self._bar.setRange(0, 1000) + self._bar.setValue(500) + self._bar.setTextVisible(False) + self._bar.setMaximumHeight(3) + self._bar.setStyleSheet( + f"QProgressBar::chunk {{ background:{self.ch.color}; border-radius:1px; }}" + ) + layout.addWidget(self._bar) + + def update_value(self, v: float): + self._val.setText(f"{v:>10.4f}".strip()) + rng = self.ch.max_value - self.ch.min_value + norm = int(((v - self.ch.min_value) / rng) * 1000) if rng else 500 + self._bar.setValue(max(0, min(1000, norm))) + + alarm = ( + (self.ch.alarm_low is not None and v < self.ch.alarm_low) or + (self.ch.alarm_high is not None and v > self.ch.alarm_high) + ) + if alarm != self._alarm: + self._alarm = alarm + self.setProperty("alarm", alarm) + self.style().unpolish(self) + self.style().polish(self) + + +class ReadoutPanel(QWidget): + def __init__(self, registry: DeviceRegistry): + super().__init__() + self.registry = registry + self._readouts = {} # (dev_id, ch_id) -> ChannelReadout + self._build() + self.refresh() + + def _build(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + hdr = QLabel(" CHANNELS") + hdr.setObjectName("panelHeader") + hdr.setMinimumHeight(28) + 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(): + w.deleteLater() + self._readouts.clear() + # Clear layout + while self._inner.count() > 1: + item = self._inner.takeAt(0) + if item and item.widget(): + item.widget().deleteLater() + + for dev in self.registry.all_instances(): + for ch in dev.info.channels: + if not ch.enabled: + continue + ro = ChannelReadout(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, dev_id: str, ch_id: str, _ts: float, value: float): + ro = self._readouts.get((dev_id, ch_id)) + if ro: + ro.update_value(value) diff --git a/ui/strip_chart.py b/ui/strip_chart.py new file mode 100644 index 0000000..afc7cf7 --- /dev/null +++ b/ui/strip_chart.py @@ -0,0 +1,186 @@ +""" +ui/strip_chart.py + +Live scrolling strip chart. One pyqtgraph plot per device, +all time-axes linked. Per-channel colored traces with legend. +""" + +import numpy as np +from typing import Dict + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QDoubleSpinBox, QPushButton, QSizePolicy, QComboBox, +) +from PyQt6.QtCore import Qt, pyqtSlot + +try: + import pyqtgraph as pg + pg.setConfigOptions(antialias=True, background="#0b0e13", foreground="#475569") + _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._window = 30.0 + self._paused = False + self._plots: Dict[str, Dict] = {} # dev_id -> {ch_id -> {curve, plot}} + self._build() + self.refresh() + + # ── Build ──────────────────────────────────────────────────────────── + + def _build(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(6, 6, 6, 4) + layout.setSpacing(4) + + # Control bar + ctrl = QHBoxLayout() + ctrl.addWidget(QLabel("Window:")) + + self._win_spin = QDoubleSpinBox() + self._win_spin.setRange(1.0, 3600.0) + self._win_spin.setValue(self._window) + self._win_spin.setSuffix(" s") + self._win_spin.valueChanged.connect(self._on_window_changed) + ctrl.addWidget(self._win_spin) + ctrl.addSpacing(16) + + ctrl.addWidget(QLabel("Y-Scale:")) + self._scale_cb = QComboBox() + self._scale_cb.addItems(["Auto", "Fixed"]) + self._scale_cb.currentTextChanged.connect(self._on_scale_changed) + ctrl.addWidget(self._scale_cb) + + ctrl.addStretch() + + self._pause_btn = QPushButton("⏸ Pause") + self._pause_btn.setCheckable(True) + self._pause_btn.setObjectName("pauseButton") + self._pause_btn.toggled.connect(self._on_pause) + ctrl.addWidget(self._pause_btn) + + layout.addLayout(ctrl) + + if _HAS_PG: + self._gw = pg.GraphicsLayoutWidget() + self._gw.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + layout.addWidget(self._gw) + else: + layout.addWidget(QLabel( + "⚠ pyqtgraph not installed.\n\npip install pyqtgraph\n\nData is still acquired & logged.", + alignment=Qt.AlignmentFlag.AlignCenter, + )) + + # ── Refresh (rebuild plots after device list changes) ───────────────── + + def refresh(self): + if not _HAS_PG: + return + self._gw.clear() + self._plots.clear() + + devs = self.registry.all_instances() + n_devs = len(devs) + if n_devs == 0: + return + + ref_plot = None # for x-axis linking + + for row, dev in enumerate(devs): + plot = self._gw.addPlot(row=row, col=0) + plot.setLabel("left", f"{dev.info.icon} {dev.info.name}") + plot.showGrid(x=True, y=True, alpha=0.12) + plot.getAxis("left").setStyle(tickFont=self._mono_font()) + plot.getAxis("bottom").setStyle(tickFont=self._mono_font()) + + if row < n_devs - 1: + plot.getAxis("bottom").setStyle(showValues=False) + plot.getAxis("bottom").setHeight(0) + else: + plot.setLabel("bottom", "Elapsed (s)") + + if ref_plot is not None: + plot.setXLink(ref_plot) + ref_plot = plot + + legend = plot.addLegend( + offset=(5, 5), + labelTextColor="#94a3b8", + brush=pg.mkBrush("#161d2e"), + pen=pg.mkPen("#2a3558"), + ) + + self._plots[dev.info.device_id] = {} + for ch in dev.info.channels: + if not ch.enabled: + continue + pen = pg.mkPen(color=ch.color, width=1.8) + curve = plot.plot([], [], pen=pen, name=ch.name) + self._plots[dev.info.device_id][ch.channel_id] = { + "curve": curve, + "plot": plot, + } + + @staticmethod + def _mono_font(): + from PyQt6.QtGui import QFont + f = QFont("IBM Plex Mono", 8) + return f + + # ── Slots ──────────────────────────────────────────────────────────── + + @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) + if dev_plots is None: + return + 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.window(self._window) + if not ts: + return + ts_arr = np.array(ts, dtype=np.float64) + vs_arr = np.array(vs, dtype=np.float64) + + entry["curve"].setData(ts_arr, vs_arr) + t_max = ts_arr[-1] + t_min = t_max - self._window + entry["plot"].setXRange(t_min, t_max, padding=0) + + if self._scale_cb.currentText() == "Auto": + entry["plot"].enableAutoRange(axis="y") + + def _on_window_changed(self, v: float): + self._window = v + + def _on_pause(self, checked: bool): + self._paused = checked + self._pause_btn.setText("▶ Resume" if checked else "⏸ Pause") + + def _on_scale_changed(self, text: str): + if not _HAS_PG: + return + if text == "Auto": + for dev_plots in self._plots.values(): + for entry in dev_plots.values(): + entry["plot"].enableAutoRange(axis="y") diff --git a/ui/style.qss b/ui/style.qss new file mode 100644 index 0000000..60415fb --- /dev/null +++ b/ui/style.qss @@ -0,0 +1,378 @@ +/* LabDAQ — Industrial Dark Theme + Font stack: IBM Plex Mono (monospace data), IBM Plex Sans (UI labels) + Palette: + bg-base #0b0e13 + bg-panel #111620 + bg-card #161d2e + bg-raised #1c2540 + border #2a3558 + accent-blue #3b82f6 + accent-cyan #00d4ff + accent-green #22c55e + text-primary #e2e8f0 + text-muted #64748b + danger #ef4444 + warning #f59e0b +*/ + +* { + font-family: "IBM Plex Sans", "Segoe UI", Tahoma, sans-serif; + font-size: 12px; + color: #e2e8f0; +} + +QMainWindow, QDialog { + background-color: #0b0e13; +} + +/* ── Toolbar ─────────────────────────────────────────────────── */ +QToolBar#mainToolbar { + background-color: #0b0e13; + border-bottom: 1px solid #2a3558; + padding: 4px 8px; + spacing: 6px; +} + +QPushButton#runButton { + background-color: #166534; + color: #dcfce7; + border: 1px solid #22c55e; + border-radius: 4px; + padding: 5px 16px; + font-family: "IBM Plex Mono", monospace; + font-weight: 600; + letter-spacing: 0.5px; + min-width: 90px; +} +QPushButton#runButton:checked { + background-color: #7f1d1d; + border-color: #ef4444; + color: #fee2e2; +} +QPushButton#runButton:hover { background-color: #15803d; } +QPushButton#runButton:checked:hover { background-color: #991b1b; } + +QPushButton#logButton { + background-color: #1c2540; + color: #94a3b8; + border: 1px solid #2a3558; + border-radius: 4px; + padding: 5px 14px; + font-family: "IBM Plex Mono", monospace; + min-width: 80px; +} +QPushButton#logButton:enabled { + color: #e2e8f0; + border-color: #3b82f6; +} +QPushButton#logButton:checked { + background-color: #7c2d12; + border-color: #ef4444; + color: #fee2e2; +} + +QPushButton#addDeviceButton { + background-color: #1e3a5f; + color: #93c5fd; + border: 1px solid #3b82f6; + border-radius: 4px; + padding: 5px 14px; +} +QPushButton#addDeviceButton:hover { + background-color: #1d4ed8; + color: #eff6ff; +} + +QLabel#timeLabel { + font-family: "IBM Plex Mono", monospace; + font-size: 16px; + font-weight: 700; + color: #00d4ff; + letter-spacing: 2px; + padding-right: 8px; +} + +/* ── Panels & Headers ────────────────────────────────────────── */ +QLabel#panelHeader { + background-color: #0f1521; + color: #64748b; + font-family: "IBM Plex Mono", monospace; + font-size: 10px; + font-weight: 700; + letter-spacing: 2px; + padding: 6px 0px; + border-bottom: 1px solid #2a3558; +} + +QWidget#alarmHeaderWidget { + background-color: #0f1521; + border-bottom: 1px solid #2a3558; +} + +/* ── Device Cards ────────────────────────────────────────────── */ +QScrollArea#deviceScroll { + background-color: #0b0e13; + border: none; + border-right: 1px solid #2a3558; +} + +QFrame#deviceCard { + background-color: #161d2e; + border: 1px solid #2a3558; + border-radius: 6px; +} +QFrame#deviceCard:hover { + border-color: #3b82f6; + background-color: #1c2540; +} + +QLabel#deviceIcon { + font-size: 18px; +} +QLabel#deviceName { + font-size: 13px; + font-weight: 600; + color: #e2e8f0; +} +QLabel#deviceSub { + font-family: "IBM Plex Mono", monospace; + font-size: 10px; + color: #64748b; +} +QLabel#deviceChannelCount { + font-size: 11px; + color: #3b82f6; +} + +QPushButton#configButton { + background-color: #1c2540; + color: #64748b; + border: 1px solid #2a3558; + border-radius: 3px; + padding: 3px 10px; + font-size: 11px; +} +QPushButton#configButton:hover { + background-color: #1e3a5f; + color: #93c5fd; + border-color: #3b82f6; +} + +/* ── Channel Readouts ────────────────────────────────────────── */ +QFrame#channelReadout { + background-color: #161d2e; + border: 1px solid #1c2540; + border-radius: 4px; +} +QFrame#channelReadout[alarm="true"] { + border-color: #ef4444; + background-color: #200d0d; +} +QLabel#readoutName { + font-size: 11px; + color: #64748b; +} +QLabel#readoutValue { + font-family: "IBM Plex Mono", monospace; + font-size: 15px; + font-weight: 700; +} +QLabel#readoutUnit { + font-family: "IBM Plex Mono", monospace; + font-size: 10px; + color: #475569; +} +QProgressBar#readoutBar { + background-color: #1c2540; + border: none; + border-radius: 2px; +} + +/* ── Alarm Panel ─────────────────────────────────────────────── */ +QFrame#alarmEntry { + background-color: #161d2e; + border-radius: 3px; + border: none; +} +QLabel#alarmMsg { + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + color: #e2e8f0; +} +QLabel#alarmTs { + font-family: "IBM Plex Mono", monospace; + font-size: 10px; + color: #475569; +} +QPushButton#clearAlarmsBtn { + background: transparent; + color: #64748b; + border: none; + font-size: 11px; + padding: 4px 8px; +} +QPushButton#clearAlarmsBtn:hover { color: #ef4444; } + +/* ── Strip Chart controls ────────────────────────────────────── */ +QPushButton#pauseButton { + background-color: #1c2540; + color: #64748b; + border: 1px solid #2a3558; + border-radius: 4px; + padding: 4px 12px; +} +QPushButton#pauseButton:checked { + background-color: #713f12; + border-color: #f59e0b; + color: #fef3c7; +} + +/* ── Config / Dialog ─────────────────────────────────────────── */ +QDialog { + background-color: #111620; +} +QTabWidget::pane { + background-color: #111620; + border: 1px solid #2a3558; + border-radius: 4px; +} +QTabBar::tab { + background-color: #0b0e13; + color: #64748b; + border: 1px solid #2a3558; + padding: 6px 16px; + margin-right: 2px; +} +QTabBar::tab:selected { + background-color: #1c2540; + color: #e2e8f0; + border-bottom: 2px solid #3b82f6; +} + +QGroupBox { + border: 1px solid #2a3558; + border-radius: 4px; + margin-top: 12px; + padding-top: 8px; + color: #64748b; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; +} +QGroupBox::title { + subcontrol-origin: margin; + left: 8px; + top: -6px; + background-color: #111620; + padding: 0 4px; +} + +QLineEdit, QDoubleSpinBox, QSpinBox, QComboBox { + background-color: #0b0e13; + border: 1px solid #2a3558; + border-radius: 3px; + padding: 4px 8px; + color: #e2e8f0; + font-family: "IBM Plex Mono", monospace; + min-height: 22px; +} +QLineEdit:focus, QDoubleSpinBox:focus, QSpinBox:focus, QComboBox:focus { + border-color: #3b82f6; +} +QComboBox::drop-down { border: none; width: 20px; } +QComboBox QAbstractItemView { + background-color: #161d2e; + border: 1px solid #3b82f6; + selection-background-color: #1e3a5f; +} + +QCheckBox { + color: #94a3b8; + spacing: 6px; +} +QCheckBox::indicator { + width: 14px; height: 14px; + border: 1px solid #2a3558; + border-radius: 3px; + background-color: #0b0e13; +} +QCheckBox::indicator:checked { + background-color: #3b82f6; + border-color: #3b82f6; +} + +QPushButton#applyButton { + background-color: #1e3a5f; + color: #93c5fd; + border: 1px solid #3b82f6; + border-radius: 4px; + padding: 6px 20px; + font-weight: 600; +} +QPushButton#applyButton:hover { + background-color: #1d4ed8; + color: #eff6ff; +} + +/* ── Digital output toggle ───────────────────────────────────── */ +QPushButton#digitalOutBtn { + background-color: #1c2540; + color: #64748b; + border: 1px solid #2a3558; + border-radius: 4px; + padding: 4px 14px; + font-family: "IBM Plex Mono", monospace; + min-width: 50px; +} +QPushButton#digitalOutBtn:checked { + background-color: #166534; + border-color: #22c55e; + color: #dcfce7; +} + +/* ── Scrollbars ──────────────────────────────────────────────── */ +QScrollBar:vertical { + background: #0b0e13; + width: 6px; + margin: 0; +} +QScrollBar::handle:vertical { + background: #2a3558; + border-radius: 3px; + min-height: 20px; +} +QScrollBar::handle:vertical:hover { background: #3b82f6; } +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } +QScrollBar:horizontal { height: 6px; background: #0b0e13; } +QScrollBar::handle:horizontal { background: #2a3558; border-radius: 3px; min-width: 20px; } + +/* ── Splitter ────────────────────────────────────────────────── */ +QSplitter::handle { + background-color: #2a3558; +} +QSplitter::handle:hover { + background-color: #3b82f6; +} + +/* ── Status bar ──────────────────────────────────────────────── */ +QStatusBar { + background-color: #0b0e13; + border-top: 1px solid #2a3558; + color: #475569; + font-family: "IBM Plex Mono", monospace; + font-size: 11px; +} + +/* ── General button default ──────────────────────────────────── */ +QPushButton { + background-color: #1c2540; + color: #94a3b8; + border: 1px solid #2a3558; + border-radius: 4px; + padding: 5px 12px; +} +QPushButton:hover { background-color: #1e3a5f; color: #e2e8f0; } +QPushButton:pressed { background-color: #172554; } +QPushButton:disabled { color: #334155; border-color: #1c2540; } + +QLabel { color: #94a3b8; } |
