summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md183
-rw-r--r--daq_system/core/__init__.py1
-rw-r--r--daq_system/core/__pycache__/__init__.cpython-312.pycbin0 -> 161 bytes
-rw-r--r--daq_system/core/__pycache__/acquisition.cpython-312.pycbin0 -> 11381 bytes
-rw-r--r--daq_system/core/acquisition.py214
-rw-r--r--daq_system/daq_system/README.md183
-rw-r--r--daq_system/daq_system/api_layers/__init__.py5
-rw-r--r--daq_system/daq_system/api_layers/arduino_layer.py295
-rw-r--r--daq_system/daq_system/api_layers/nidaqmx_layer.py181
-rw-r--r--daq_system/daq_system/core/__init__.py3
-rw-r--r--daq_system/daq_system/core/acquisition.py225
-rw-r--r--daq_system/daq_system/devices/__init__.py5
-rw-r--r--daq_system/daq_system/devices/analog_input.py276
-rw-r--r--daq_system/daq_system/devices/base_device.py104
-rw-r--r--daq_system/daq_system/devices/device_registry.py73
-rw-r--r--daq_system/daq_system/devices/digital_io.py261
-rw-r--r--daq_system/daq_system/devices/serial_device.py187
-rw-r--r--daq_system/daq_system/main.py40
-rw-r--r--daq_system/daq_system/requirements.txt8
-rw-r--r--daq_system/daq_system/ui/__init__.py1
-rw-r--r--daq_system/daq_system/ui/add_device_dialog.py100
-rw-r--r--daq_system/daq_system/ui/alarm_panel.py85
-rw-r--r--daq_system/daq_system/ui/config_dialog.py111
-rw-r--r--daq_system/daq_system/ui/device_panel.py148
-rw-r--r--daq_system/daq_system/ui/main_window.py214
-rw-r--r--daq_system/daq_system/ui/readout_panel.py122
-rw-r--r--daq_system/daq_system/ui/strip_chart.py186
-rw-r--r--daq_system/daq_system/ui/style.qss378
-rw-r--r--daq_system/devices/__init__.py1
-rw-r--r--daq_system/devices/__pycache__/__init__.cpython-312.pycbin0 -> 164 bytes
-rw-r--r--daq_system/devices/__pycache__/analog_input.cpython-312.pycbin0 -> 9034 bytes
-rw-r--r--daq_system/devices/__pycache__/base_device.cpython-312.pycbin0 -> 6014 bytes
-rw-r--r--daq_system/devices/__pycache__/device_registry.cpython-312.pycbin0 -> 5219 bytes
-rw-r--r--daq_system/devices/__pycache__/digital_io.cpython-312.pycbin0 -> 6027 bytes
-rw-r--r--daq_system/devices/__pycache__/serial_device.cpython-312.pycbin0 -> 7405 bytes
-rw-r--r--daq_system/devices/__pycache__/temperature.cpython-312.pycbin0 -> 6707 bytes
-rw-r--r--daq_system/devices/analog_input.py184
-rw-r--r--daq_system/devices/base_device.py127
-rw-r--r--daq_system/devices/device_registry.py87
-rw-r--r--daq_system/devices/digital_io.py95
-rw-r--r--daq_system/devices/serial_device.py136
-rw-r--r--daq_system/devices/temperature.py115
-rw-r--r--daq_system/main.py28
-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
56 files changed, 5251 insertions, 0 deletions
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6ef85cc
--- /dev/null
+++ b/README.md
@@ -0,0 +1,183 @@
+# LabDAQ — Modular Python DAQ Frontend
+
+A production-grade, modular PyQt6 data acquisition UI supporting NI-DAQmx and Arduino backends with live strip-chart plotting, CSV logging, and per-device configuration.
+
+---
+
+## Quick Start
+
+```bash
+# 1. Install dependencies
+pip install PyQt6 pyqtgraph numpy pyserial
+
+# 2. Run (simulation mode — no hardware required)
+python main.py
+
+# 3. For real NI hardware
+pip install nidaqmx # also install NI-DAQmx runtime from ni.com
+
+# 4. For real Arduino hardware
+# Upload devices/arduino_firmware/labdaq.ino to your board
+# Set port in device Configure dialog (e.g. COM3 / /dev/ttyUSB0)
+```
+
+---
+
+## Project Structure
+
+```
+daq_system/
+├── main.py # Entry point
+├── requirements.txt
+│
+├── api_layers/ # Hardware abstraction layers
+│ ├── nidaqmx_layer.py # NI-DAQmx wrapper + simulation
+│ └── arduino_layer.py # Arduino serial wrapper + simulation
+│
+├── devices/ # I/O device modules (auto-discovered)
+│ ├── base_device.py # Abstract base class
+│ ├── device_registry.py # Auto-discovery & instance manager
+│ ├── analog_input.py # AI — NI or Arduino backend
+│ ├── digital_io.py # DIO — NI or Arduino backend
+│ └── serial_device.py # Generic serial / UART
+│
+├── core/
+│ └── acquisition.py # Threaded polling engine, buffers, CSV log
+│
+└── ui/
+ ├── style.qss # Industrial dark theme
+ ├── main_window.py # Main window shell
+ ├── device_panel.py # Left sidebar — device cards
+ ├── strip_chart.py # Center — live pyqtgraph traces
+ ├── readout_panel.py # Right — numeric readouts
+ ├── alarm_panel.py # Right — alarm event log
+ ├── config_dialog.py # Per-device config dialog
+ └── add_device_dialog.py # Add device at runtime
+```
+
+---
+
+## Backend Architecture
+
+### Swappable API Layers
+
+Each device module accepts a `backend` parameter:
+
+```python
+# NI-DAQmx (real hardware)
+dev = AnalogInputDevice(
+ device_id="ai_0",
+ backend="nidaqmx",
+ ni_device="Dev1", # NI device name
+ simulate=False,
+)
+
+# Arduino (real hardware)
+dev = AnalogInputDevice(
+ device_id="ard_0",
+ backend="arduino",
+ ard_port="COM3", # or "/dev/ttyUSB0" on Linux/Mac
+ ard_baud=115200,
+ simulate=False,
+)
+
+# Simulation (no hardware)
+dev = AnalogInputDevice(device_id="ai_sim", simulate=True)
+```
+
+You can hot-swap backends at runtime from the Configure dialog without restarting.
+
+### NI-DAQmx Layer (`api_layers/nidaqmx_layer.py`)
+
+- Auto-detects installed NI devices via `NidaqmxLayer.list_devices()`
+- Falls back to simulation if `nidaqmx` package is not installed
+- Configurable voltage range, sample rate, terminal configuration
+
+### Arduino Layer (`api_layers/arduino_layer.py`)
+
+- Serial protocol: `"A0:1.23,A1:4.56,D2:1\n"` (key:value CSV)
+- Background read thread with latest-value cache
+- Digital write: sends `"W:D13:1\n"` to Arduino
+- Reference firmware included in `arduino_layer.py` as `ARDUINO_SKETCH`
+
+---
+
+## Adding a New Device Module
+
+1. Create `devices/my_sensor.py`
+2. Subclass `BaseDevice`
+3. Implement: `connect()`, `disconnect()`, `read_channels()`, `write_channel()`, `get_config_widget()`
+4. Drop the file in `devices/` — `DeviceRegistry` discovers it automatically
+
+```python
+from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus
+
+class MySensor(BaseDevice):
+ def __init__(self, device_id="my_0", simulate=True):
+ channels = [
+ ChannelConfig(channel_id="ch0", name="Pressure", unit="Pa",
+ min_value=0, max_value=1e5, color="#00d4ff"),
+ ]
+ info = DeviceInfo(device_id=device_id, name="My Sensor",
+ device_type="custom", channels=channels)
+ super().__init__(info)
+ self.simulate = simulate
+
+ def connect(self):
+ self.status = DeviceStatus.SIMULATED if self.simulate else DeviceStatus.CONNECTED
+ return True
+
+ def disconnect(self):
+ self.status = DeviceStatus.DISCONNECTED
+
+ def read_channels(self):
+ import random
+ return {"ch0": random.uniform(0, 1e5)}
+
+ def write_channel(self, channel_id, value):
+ return False
+
+ def get_config_widget(self):
+ from PyQt6.QtWidgets import QLabel
+ return QLabel("No configuration needed.")
+```
+
+---
+
+## Data Logging
+
+- Click **⬤ LOG** while acquisition is running
+- CSV saved to `logs/daq_YYYYMMDD_HHMMSS.csv`
+- Columns: `elapsed_s`, then one column per channel (`device_id/channel_id[unit]`)
+- Stop logging with **⏹ LOGGING** — file is flushed and closed cleanly
+
+---
+
+## Alarm System
+
+- Set per-channel `alarm_low` / `alarm_high` in the Configure → Channels & Alarms tab
+- Alarms appear in the bottom-right panel with timestamp and direction (▲ HIGH / ▼ LOW)
+- Alarm state is debounced — fires once on breach, resets when value recovers
+- Channel readout highlights red while in alarm
+
+---
+
+## Strip Chart
+
+- One plot row per device; all share a linked time axis
+- Adjustable window (1 s → 3600 s)
+- Auto or Fixed Y-scale
+- Pause / Resume without stopping acquisition
+- Powered by **pyqtgraph** for GPU-accelerated rendering
+
+---
+
+## Dependencies
+
+| Package | Purpose | Required |
+|-------------|----------------------------|----------|
+| PyQt6 | UI framework | ✓ |
+| pyqtgraph | Real-time strip chart | ✓ |
+| numpy | Array math for chart data | ✓ |
+| pyserial | Arduino serial comms | ✓ |
+| nidaqmx | NI-DAQmx hardware access | Optional |
diff --git a/daq_system/core/__init__.py b/daq_system/core/__init__.py
new file mode 100644
index 0000000..91db526
--- /dev/null
+++ b/daq_system/core/__init__.py
@@ -0,0 +1 @@
+# core/__init__.py
diff --git a/daq_system/core/__pycache__/__init__.cpython-312.pyc b/daq_system/core/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..0ffb7a0
--- /dev/null
+++ b/daq_system/core/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/daq_system/core/__pycache__/acquisition.cpython-312.pyc b/daq_system/core/__pycache__/acquisition.cpython-312.pyc
new file mode 100644
index 0000000..8e4bde7
--- /dev/null
+++ b/daq_system/core/__pycache__/acquisition.cpython-312.pyc
Binary files differ
diff --git a/daq_system/core/acquisition.py b/daq_system/core/acquisition.py
new file mode 100644
index 0000000..7e43529
--- /dev/null
+++ b/daq_system/core/acquisition.py
@@ -0,0 +1,214 @@
+"""
+core/acquisition.py
+
+Background acquisition engine. Polls all connected devices at
+their configured sample rates and emits data via Qt signals.
+"""
+
+import time
+import threading
+import csv
+import os
+from collections import deque
+from datetime import datetime
+from typing import Callable, Dict, List, Optional, Tuple
+
+from PyQt6.QtCore import QObject, pyqtSignal
+
+from devices.base_device import BaseDevice, DeviceStatus
+
+
+MAX_BUFFER = 10_000 # points per channel
+
+
+class ChannelBuffer:
+ """Ring buffer for one channel's time-series data."""
+ def __init__(self, maxlen: int = MAX_BUFFER):
+ self.times: deque = deque(maxlen=maxlen)
+ self.values: deque = deque(maxlen=maxlen)
+
+ def append(self, t: float, v: float):
+ self.times.append(t)
+ self.values.append(v)
+
+ def latest(self, n: int = 1) -> Tuple[List[float], List[float]]:
+ ts = list(self.times)[-n:]
+ vs = list(self.values)[-n:]
+ return ts, vs
+
+ def all(self) -> Tuple[List[float], List[float]]:
+ return list(self.times), list(self.values)
+
+ def clear(self):
+ self.times.clear()
+ self.values.clear()
+
+ def __len__(self):
+ return len(self.times)
+
+
+class AcquisitionEngine(QObject):
+ """
+ Runs a background polling thread for all registered devices.
+
+ Signals:
+ new_data(device_id, channel_id, timestamp, value)
+ alarm_triggered(device_id, channel_id, value, kind) kind = 'low'|'high'
+ device_status_changed(device_id, status_str)
+ log_written(filepath)
+ """
+
+ new_data = pyqtSignal(str, str, float, float)
+ alarm_triggered = pyqtSignal(str, str, float, str)
+ device_status_changed = pyqtSignal(str, str)
+ log_written = pyqtSignal(str)
+
+ def __init__(self, poll_interval_ms: int = 100):
+ super().__init__()
+ self.poll_interval_s = poll_interval_ms / 1000.0
+ self._devices: List[BaseDevice] = []
+ self._buffers: Dict[str, Dict[str, ChannelBuffer]] = {}
+ self._running = False
+ self._thread: Optional[threading.Thread] = None
+ self._start_time = 0.0
+
+ # Logging
+ self._logging = False
+ self._log_file: Optional[str] = None
+ self._csv_writer = None
+ self._csv_handle = None
+
+ # Alarm state (prevent repeated triggers)
+ self._alarm_active: Dict[str, bool] = {}
+
+ # ------------------------------------------------------------------ #
+ # Device management #
+ # ------------------------------------------------------------------ #
+
+ def add_device(self, device: BaseDevice) -> None:
+ self._devices.append(device)
+ self._buffers[device.info.device_id] = {
+ ch.channel_id: ChannelBuffer()
+ for ch in device.info.channels
+ }
+
+ def remove_device(self, device_id: str) -> None:
+ self._devices = [d for d in self._devices if d.info.device_id != device_id]
+ self._buffers.pop(device_id, None)
+
+ def get_buffer(self, device_id: str, channel_id: str) -> Optional[ChannelBuffer]:
+ return self._buffers.get(device_id, {}).get(channel_id)
+
+ # ------------------------------------------------------------------ #
+ # Start / Stop #
+ # ------------------------------------------------------------------ #
+
+ def start(self) -> None:
+ if self._running:
+ return
+ self._running = True
+ self._start_time = time.time()
+ self._thread = threading.Thread(target=self._loop, daemon=True)
+ self._thread.start()
+
+ def stop(self) -> None:
+ self._running = False
+ if self._thread:
+ self._thread.join(timeout=2.0)
+ self.stop_logging()
+
+ # ------------------------------------------------------------------ #
+ # Logging #
+ # ------------------------------------------------------------------ #
+
+ def start_logging(self, filepath: Optional[str] = None) -> str:
+ if filepath is None:
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
+ os.makedirs("logs", exist_ok=True)
+ filepath = f"logs/daq_{ts}.csv"
+ self._log_file = filepath
+ self._csv_handle = open(filepath, "w", newline="")
+ # Build header
+ headers = ["timestamp"]
+ for dev in self._devices:
+ for ch in dev.info.channels:
+ headers.append(f"{dev.info.device_id}.{ch.channel_id}")
+ self._csv_writer = csv.writer(self._csv_handle)
+ self._csv_writer.writerow(headers)
+ self._logging = True
+ return filepath
+
+ def stop_logging(self) -> None:
+ self._logging = False
+ if self._csv_handle:
+ try:
+ self._csv_handle.close()
+ except Exception:
+ pass
+ self._csv_handle = None
+ self._csv_writer = None
+
+ # ------------------------------------------------------------------ #
+ # Background loop #
+ # ------------------------------------------------------------------ #
+
+ def _loop(self) -> None:
+ while self._running:
+ t0 = time.time()
+ timestamp = t0 - self._start_time
+ log_row = [f"{timestamp:.3f}"]
+
+ for dev in self._devices:
+ if dev.status not in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED):
+ for ch in dev.info.channels:
+ log_row.append("")
+ continue
+ try:
+ readings = dev.read_channels()
+ except Exception as e:
+ print(f"[Acq] Error reading {dev.info.device_id}: {e}")
+ readings = {}
+
+ for ch in dev.info.channels:
+ val = readings.get(ch.channel_id)
+ if val is None:
+ log_row.append("")
+ continue
+ # Buffer
+ buf = self._buffers.get(dev.info.device_id, {}).get(ch.channel_id)
+ if buf is not None:
+ buf.append(timestamp, val)
+ # Signal (emit on main thread via Qt queued connection)
+ self.new_data.emit(dev.info.device_id, ch.channel_id, timestamp, val)
+ log_row.append(f"{val:.4f}")
+ # Alarms
+ self._check_alarm(dev.info.device_id, ch, val)
+
+ if self._logging and self._csv_writer:
+ try:
+ self._csv_writer.writerow(log_row)
+ except Exception:
+ pass
+
+ elapsed = time.time() - t0
+ sleep_t = self.poll_interval_s - elapsed
+ if sleep_t > 0:
+ time.sleep(sleep_t)
+
+ def _check_alarm(self, device_id: str, ch, val: float) -> None:
+ key_lo = f"{device_id}.{ch.channel_id}.low"
+ key_hi = f"{device_id}.{ch.channel_id}.high"
+
+ if ch.alarm_low is not None:
+ if val < ch.alarm_low and not self._alarm_active.get(key_lo):
+ self._alarm_active[key_lo] = True
+ self.alarm_triggered.emit(device_id, ch.channel_id, val, "low")
+ elif val >= ch.alarm_low:
+ self._alarm_active[key_lo] = False
+
+ if ch.alarm_high is not None:
+ if val > ch.alarm_high and not self._alarm_active.get(key_hi):
+ self._alarm_active[key_hi] = True
+ self.alarm_triggered.emit(device_id, ch.channel_id, val, "high")
+ elif val <= ch.alarm_high:
+ self._alarm_active[key_hi] = False
diff --git a/daq_system/daq_system/README.md b/daq_system/daq_system/README.md
new file mode 100644
index 0000000..6ef85cc
--- /dev/null
+++ b/daq_system/daq_system/README.md
@@ -0,0 +1,183 @@
+# LabDAQ — Modular Python DAQ Frontend
+
+A production-grade, modular PyQt6 data acquisition UI supporting NI-DAQmx and Arduino backends with live strip-chart plotting, CSV logging, and per-device configuration.
+
+---
+
+## Quick Start
+
+```bash
+# 1. Install dependencies
+pip install PyQt6 pyqtgraph numpy pyserial
+
+# 2. Run (simulation mode — no hardware required)
+python main.py
+
+# 3. For real NI hardware
+pip install nidaqmx # also install NI-DAQmx runtime from ni.com
+
+# 4. For real Arduino hardware
+# Upload devices/arduino_firmware/labdaq.ino to your board
+# Set port in device Configure dialog (e.g. COM3 / /dev/ttyUSB0)
+```
+
+---
+
+## Project Structure
+
+```
+daq_system/
+├── main.py # Entry point
+├── requirements.txt
+│
+├── api_layers/ # Hardware abstraction layers
+│ ├── nidaqmx_layer.py # NI-DAQmx wrapper + simulation
+│ └── arduino_layer.py # Arduino serial wrapper + simulation
+│
+├── devices/ # I/O device modules (auto-discovered)
+│ ├── base_device.py # Abstract base class
+│ ├── device_registry.py # Auto-discovery & instance manager
+│ ├── analog_input.py # AI — NI or Arduino backend
+│ ├── digital_io.py # DIO — NI or Arduino backend
+│ └── serial_device.py # Generic serial / UART
+│
+├── core/
+│ └── acquisition.py # Threaded polling engine, buffers, CSV log
+│
+└── ui/
+ ├── style.qss # Industrial dark theme
+ ├── main_window.py # Main window shell
+ ├── device_panel.py # Left sidebar — device cards
+ ├── strip_chart.py # Center — live pyqtgraph traces
+ ├── readout_panel.py # Right — numeric readouts
+ ├── alarm_panel.py # Right — alarm event log
+ ├── config_dialog.py # Per-device config dialog
+ └── add_device_dialog.py # Add device at runtime
+```
+
+---
+
+## Backend Architecture
+
+### Swappable API Layers
+
+Each device module accepts a `backend` parameter:
+
+```python
+# NI-DAQmx (real hardware)
+dev = AnalogInputDevice(
+ device_id="ai_0",
+ backend="nidaqmx",
+ ni_device="Dev1", # NI device name
+ simulate=False,
+)
+
+# Arduino (real hardware)
+dev = AnalogInputDevice(
+ device_id="ard_0",
+ backend="arduino",
+ ard_port="COM3", # or "/dev/ttyUSB0" on Linux/Mac
+ ard_baud=115200,
+ simulate=False,
+)
+
+# Simulation (no hardware)
+dev = AnalogInputDevice(device_id="ai_sim", simulate=True)
+```
+
+You can hot-swap backends at runtime from the Configure dialog without restarting.
+
+### NI-DAQmx Layer (`api_layers/nidaqmx_layer.py`)
+
+- Auto-detects installed NI devices via `NidaqmxLayer.list_devices()`
+- Falls back to simulation if `nidaqmx` package is not installed
+- Configurable voltage range, sample rate, terminal configuration
+
+### Arduino Layer (`api_layers/arduino_layer.py`)
+
+- Serial protocol: `"A0:1.23,A1:4.56,D2:1\n"` (key:value CSV)
+- Background read thread with latest-value cache
+- Digital write: sends `"W:D13:1\n"` to Arduino
+- Reference firmware included in `arduino_layer.py` as `ARDUINO_SKETCH`
+
+---
+
+## Adding a New Device Module
+
+1. Create `devices/my_sensor.py`
+2. Subclass `BaseDevice`
+3. Implement: `connect()`, `disconnect()`, `read_channels()`, `write_channel()`, `get_config_widget()`
+4. Drop the file in `devices/` — `DeviceRegistry` discovers it automatically
+
+```python
+from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus
+
+class MySensor(BaseDevice):
+ def __init__(self, device_id="my_0", simulate=True):
+ channels = [
+ ChannelConfig(channel_id="ch0", name="Pressure", unit="Pa",
+ min_value=0, max_value=1e5, color="#00d4ff"),
+ ]
+ info = DeviceInfo(device_id=device_id, name="My Sensor",
+ device_type="custom", channels=channels)
+ super().__init__(info)
+ self.simulate = simulate
+
+ def connect(self):
+ self.status = DeviceStatus.SIMULATED if self.simulate else DeviceStatus.CONNECTED
+ return True
+
+ def disconnect(self):
+ self.status = DeviceStatus.DISCONNECTED
+
+ def read_channels(self):
+ import random
+ return {"ch0": random.uniform(0, 1e5)}
+
+ def write_channel(self, channel_id, value):
+ return False
+
+ def get_config_widget(self):
+ from PyQt6.QtWidgets import QLabel
+ return QLabel("No configuration needed.")
+```
+
+---
+
+## Data Logging
+
+- Click **⬤ LOG** while acquisition is running
+- CSV saved to `logs/daq_YYYYMMDD_HHMMSS.csv`
+- Columns: `elapsed_s`, then one column per channel (`device_id/channel_id[unit]`)
+- Stop logging with **⏹ LOGGING** — file is flushed and closed cleanly
+
+---
+
+## Alarm System
+
+- Set per-channel `alarm_low` / `alarm_high` in the Configure → Channels & Alarms tab
+- Alarms appear in the bottom-right panel with timestamp and direction (▲ HIGH / ▼ LOW)
+- Alarm state is debounced — fires once on breach, resets when value recovers
+- Channel readout highlights red while in alarm
+
+---
+
+## Strip Chart
+
+- One plot row per device; all share a linked time axis
+- Adjustable window (1 s → 3600 s)
+- Auto or Fixed Y-scale
+- Pause / Resume without stopping acquisition
+- Powered by **pyqtgraph** for GPU-accelerated rendering
+
+---
+
+## Dependencies
+
+| Package | Purpose | Required |
+|-------------|----------------------------|----------|
+| PyQt6 | UI framework | ✓ |
+| pyqtgraph | Real-time strip chart | ✓ |
+| numpy | Array math for chart data | ✓ |
+| pyserial | Arduino serial comms | ✓ |
+| nidaqmx | NI-DAQmx hardware access | Optional |
diff --git a/daq_system/daq_system/api_layers/__init__.py b/daq_system/daq_system/api_layers/__init__.py
new file mode 100644
index 0000000..242d6a9
--- /dev/null
+++ b/daq_system/daq_system/api_layers/__init__.py
@@ -0,0 +1,5 @@
+# api_layers/__init__.py
+from api_layers.nidaqmx_layer import NidaqmxLayer
+from api_layers.arduino_layer import ArduinoLayer
+
+__all__ = ["NidaqmxLayer", "ArduinoLayer"]
diff --git a/daq_system/daq_system/api_layers/arduino_layer.py b/daq_system/daq_system/api_layers/arduino_layer.py
new file mode 100644
index 0000000..dea2783
--- /dev/null
+++ b/daq_system/daq_system/api_layers/arduino_layer.py
@@ -0,0 +1,295 @@
+"""
+api_layers/arduino_layer.py
+
+Arduino serial API layer.
+
+Protocol (default): Arduino sends newline-terminated CSV strings:
+ "A0:1.23,A1:4.56,A2:0.12\n" (analog)
+ "D2:1,D3:0,D4:1\n" (digital)
+
+The Arduino firmware sketch is provided at the bottom of this file
+as a multi-line string for reference / deployment.
+
+Swap for a different protocol by subclassing ArduinoLayer and
+overriding `_parse_line()` and `_build_write_cmd()`.
+
+Usage:
+ from api_layers.arduino_layer import ArduinoLayer
+ layer = ArduinoLayer(port="COM3", baud=115200, simulate=False)
+ if layer.connect():
+ data = layer.read() # {"A0": 3.14, "A1": 1.07, ...}
+ layer.write("D13", 1)
+ layer.disconnect()
+"""
+
+import math
+import random
+import re
+import threading
+import time
+from typing import Dict, List, Optional, Tuple
+
+
+# ── Try pyserial ─────────────────────────────────────────────────────────────
+try:
+ import serial # type: ignore
+ import serial.tools.list_ports # type: ignore
+ _SERIAL_AVAILABLE = True
+except ImportError:
+ _SERIAL_AVAILABLE = False
+
+
+class ArduinoLayer:
+ """
+ Serial communication layer for Arduino-based DAQ nodes.
+
+ Supports:
+ - Auto-detect available serial ports
+ - Configurable baud rate / timeout
+ - Background read thread with latest-value cache
+ - Digital output writes
+ - Full simulation mode (no hardware required)
+ """
+
+ DEFAULT_ANALOG_PINS = ["A0", "A1", "A2", "A3", "A4", "A5"]
+ DEFAULT_DIGITAL_PINS = ["D2", "D3", "D4", "D5", "D6", "D7"]
+
+ def __init__(
+ self,
+ port: str = "COM3",
+ baud: int = 115200,
+ timeout: float = 0.5,
+ analog_pins: List[str] = None,
+ digital_pins: List[str] = None,
+ simulate: bool = True,
+ ):
+ self.port = port
+ self.baud = baud
+ self.timeout = timeout
+ self.analog_pins = analog_pins or self.DEFAULT_ANALOG_PINS
+ self.digital_pins = digital_pins or []
+ self.simulate = simulate or not _SERIAL_AVAILABLE
+
+ self._ser: Optional[object] = None
+ self._cache: Dict[str, float] = {}
+ self._lock = threading.Lock()
+ self._running = False
+ self._thread: Optional[threading.Thread] = None
+ self._t0 = 0.0
+
+ # Sim waveform params
+ self._sim_params = {
+ pin: {
+ "freq": 0.1 + i * 0.13,
+ "amp": 2.5,
+ "offset": 2.5,
+ "noise": 0.01,
+ "phase": i * 1.1,
+ }
+ for i, pin in enumerate(self.analog_pins)
+ }
+
+ # ── Lifecycle ────────────────────────────────────────────────────────
+
+ def connect(self) -> bool:
+ self._t0 = time.time()
+ if self.simulate:
+ self._running = True
+ self._thread = threading.Thread(target=self._sim_loop, daemon=True)
+ self._thread.start()
+ return True
+ if not _SERIAL_AVAILABLE:
+ print("[ArduinoLayer] pyserial not installed.")
+ return False
+ try:
+ self._ser = serial.Serial(
+ port=self.port, baudrate=self.baud, timeout=self.timeout
+ )
+ time.sleep(2.0) # Allow Arduino reset
+ self._ser.reset_input_buffer()
+ self._running = True
+ self._thread = threading.Thread(target=self._read_loop, daemon=True)
+ self._thread.start()
+ return True
+ except Exception as e:
+ print(f"[ArduinoLayer] connect() failed: {e}")
+ return False
+
+ def disconnect(self) -> None:
+ self._running = False
+ if self._thread:
+ self._thread.join(timeout=2.0)
+ if self._ser:
+ try:
+ self._ser.close()
+ except Exception:
+ pass
+ self._ser = None
+
+ # ── Read / Write ────────────────────────────────────────────────────
+
+ def read(self) -> Dict[str, float]:
+ """Return cached latest values for all pins."""
+ with self._lock:
+ return dict(self._cache)
+
+ def write(self, pin: str, value: int) -> bool:
+ """
+ Send digital write command to Arduino.
+ Format sent: "W:D13:1\n"
+ """
+ if self.simulate:
+ return True
+ if self._ser and self._ser.is_open:
+ try:
+ cmd = f"W:{pin}:{int(bool(value))}\n"
+ self._ser.write(cmd.encode())
+ return True
+ except Exception as e:
+ print(f"[ArduinoLayer] write() failed: {e}")
+ return False
+
+ # ── Background threads ───────────────────────────────────────────────
+
+ def _read_loop(self):
+ """Background thread: reads lines from serial port."""
+ while self._running and self._ser and self._ser.is_open:
+ try:
+ line = self._ser.readline().decode("utf-8", errors="replace").strip()
+ if line:
+ parsed = self._parse_line(line)
+ with self._lock:
+ self._cache.update(parsed)
+ except Exception:
+ time.sleep(0.05)
+
+ def _sim_loop(self):
+ """Background thread: generates simulated waveforms."""
+ while self._running:
+ t = time.time() - self._t0
+ update = {}
+ for pin, p in self._sim_params.items():
+ val = p["amp"] * math.sin(2 * math.pi * p["freq"] * t + p["phase"])
+ val += p["offset"]
+ val += random.gauss(0, p["noise"] * p["amp"])
+ # Clamp to 0-5V (Arduino ADC range)
+ update[pin] = round(max(0.0, min(5.0, val)), 4)
+ with self._lock:
+ self._cache.update(update)
+ time.sleep(0.05)
+
+ # ── Protocol helpers ─────────────────────────────────────────────────
+
+ def _parse_line(self, line: str) -> Dict[str, float]:
+ """
+ Parse "A0:1.23,A1:4.56,D2:1" → {"A0": 1.23, "A1": 4.56, "D2": 1.0}
+ Also handles plain CSV "1.23,4.56,0.12" mapped to analog_pins in order.
+ """
+ result: Dict[str, float] = {}
+ # Key:value pairs
+ for token in line.split(","):
+ token = token.strip()
+ if ":" in token:
+ parts = token.split(":", 1)
+ try:
+ result[parts[0].strip()] = float(parts[1].strip())
+ except ValueError:
+ pass
+ else:
+ # plain CSV fallback
+ try:
+ idx = len(result)
+ if idx < len(self.analog_pins):
+ result[self.analog_pins[idx]] = float(token)
+ except ValueError:
+ pass
+ return result
+
+ def _build_write_cmd(self, pin: str, value: int) -> str:
+ return f"W:{pin}:{int(bool(value))}\n"
+
+ # ── Utilities ────────────────────────────────────────────────────────
+
+ @staticmethod
+ def list_ports() -> List[str]:
+ """Return available serial port names."""
+ if not _SERIAL_AVAILABLE:
+ return []
+ return [p.device for p in serial.tools.list_ports.comports()]
+
+ @property
+ def is_simulated(self) -> bool:
+ return self.simulate
+
+ def __repr__(self):
+ mode = "SIM" if self.simulate else f"HW:{self.port}@{self.baud}"
+ return f"<ArduinoLayer {mode} pins={self.analog_pins}>"
+
+
+# ════════════════════════════════════════════════════════════════════════════
+# Arduino Firmware Reference Sketch
+# ════════════════════════════════════════════════════════════════════════════
+ARDUINO_SKETCH = """
+/*
+ * LabDAQ Arduino Firmware
+ * Upload this to your Arduino to communicate with the Python DAQ system.
+ *
+ * Protocol:
+ * SEND (Arduino → PC): "A0:3.14,A1:2.71,A2:1.41,D2:1,D3:0\\n"
+ * RECV (PC → Arduino): "W:D13:1\\n" to set digital outputs
+ *
+ * Analog values are converted from 10-bit ADC (0-1023) to 0.0-5.0 V.
+ */
+
+const int ANALOG_PINS[] = {A0, A1, A2, A3, A4, A5};
+const int DIGITAL_IN[] = {2, 3, 4};
+const int DIGITAL_OUT[] = {5, 6, 7, 13};
+const int N_ANALOG = 6;
+const int N_DIG_IN = 3;
+const int N_DIG_OUT = 4;
+const int SEND_INTERVAL = 50; // ms between transmissions
+
+unsigned long lastSend = 0;
+
+void setup() {
+ Serial.begin(115200);
+ for (int i = 0; i < N_DIG_IN; i++) pinMode(DIGITAL_IN[i], INPUT_PULLUP);
+ for (int i = 0; i < N_DIG_OUT; i++) pinMode(DIGITAL_OUT[i], OUTPUT);
+}
+
+void loop() {
+ // ── Handle incoming commands ────────────────────────────
+ if (Serial.available()) {
+ String cmd = Serial.readStringUntil('\\n');
+ cmd.trim();
+ if (cmd.startsWith("W:")) {
+ // W:D13:1 → set pin 13 HIGH
+ int colon1 = cmd.indexOf(':', 2);
+ int colon2 = cmd.indexOf(':', colon1 + 1);
+ if (colon1 > 0 && colon2 > 0) {
+ String pinStr = cmd.substring(colon1 + 1, colon2);
+ int val = cmd.substring(colon2 + 1).toInt();
+ int pin = pinStr.substring(1).toInt(); // strip 'D'
+ digitalWrite(pin, val ? HIGH : LOW);
+ }
+ }
+ }
+
+ // ── Transmit data ────────────────────────────────────────
+ unsigned long now = millis();
+ if (now - lastSend >= SEND_INTERVAL) {
+ lastSend = now;
+ String out = "";
+ for (int i = 0; i < N_ANALOG; i++) {
+ float v = analogRead(ANALOG_PINS[i]) * (5.0 / 1023.0);
+ out += "A" + String(i) + ":" + String(v, 3);
+ if (i < N_ANALOG - 1) out += ",";
+ }
+ for (int i = 0; i < N_DIG_IN; i++) {
+ out += ",D" + String(DIGITAL_IN[i]) + ":" + String(!digitalRead(DIGITAL_IN[i]));
+ }
+ Serial.println(out);
+ }
+}
+*/
+"""
diff --git a/daq_system/daq_system/api_layers/nidaqmx_layer.py b/daq_system/daq_system/api_layers/nidaqmx_layer.py
new file mode 100644
index 0000000..ba40efb
--- /dev/null
+++ b/daq_system/daq_system/api_layers/nidaqmx_layer.py
@@ -0,0 +1,181 @@
+"""
+api_layers/nidaqmx_layer.py
+
+NI-DAQmx API abstraction layer.
+
+ • When nidaqmx package + NI runtime are present → uses real hardware
+ • Otherwise → falls back to simulation
+
+Swap this layer by changing the `backend` parameter on AnalogInputDevice
+or by subclassing NidaqmxLayer and overriding _hw_read().
+
+Usage example:
+ from api_layers.nidaqmx_layer import NidaqmxLayer
+ layer = NidaqmxLayer(device_name="Dev1", channels=["ai0","ai1"], simulate=False)
+ layer.start()
+ values = layer.read() # {"ai0": 1.23, "ai1": -0.45}
+ layer.stop()
+"""
+
+import math
+import random
+import time
+from typing import Dict, List, Optional
+
+# ── Try to import real nidaqmx ──────────────────────────────────────────────
+try:
+ import nidaqmx # type: ignore
+ from nidaqmx.constants import TerminalConfiguration # type: ignore
+ _NI_AVAILABLE = True
+except ImportError:
+ _NI_AVAILABLE = False
+
+
+class NidaqmxLayer:
+ """
+ Thin wrapper around nidaqmx.Task for analog input.
+
+ Parameters
+ ----------
+ device_name : str
+ NI device identifier, e.g. "Dev1"
+ channels : list of str
+ Physical channel names relative to device, e.g. ["ai0", "ai1", "ai2"]
+ sample_rate : float
+ Samples per second (hardware mode only; ignored in sim)
+ min_val / max_val : float
+ Expected voltage range for hardware task configuration
+ simulate : bool
+ Force simulation even if nidaqmx is available
+ """
+
+ def __init__(
+ self,
+ device_name: str = "Dev1",
+ channels: List[str] = None,
+ sample_rate: float = 1000.0,
+ min_val: float = -10.0,
+ max_val: float = 10.0,
+ simulate: bool = True,
+ ):
+ self.device_name = device_name
+ self.channels = channels or ["ai0", "ai1", "ai2", "ai3"]
+ self.sample_rate = sample_rate
+ self.min_val = min_val
+ self.max_val = max_val
+ self.simulate = simulate or not _NI_AVAILABLE
+
+ self._task = None
+ self._started = False
+ self._t0 = 0.0
+
+ # Sim waveform params per channel
+ self._sim_params = [
+ {
+ "freq": 0.3 + i * 0.17,
+ "amp": (max_val - min_val) * 0.4,
+ "offset": (max_val + min_val) / 2,
+ "noise": 0.02,
+ "phase": i * 0.8,
+ }
+ for i in range(len(self.channels))
+ ]
+
+ # ── Lifecycle ───────────────────────────────────────────────────────
+
+ def start(self) -> bool:
+ """Configure and start acquisition. Returns True on success."""
+ self._t0 = time.time()
+ if self.simulate:
+ self._started = True
+ return True
+ try:
+ self._task = nidaqmx.Task()
+ for ch in self.channels:
+ physical = f"{self.device_name}/{ch}"
+ self._task.ai_channels.add_ai_voltage_chan(
+ physical,
+ min_val=self.min_val,
+ max_val=self.max_val,
+ terminal_config=TerminalConfiguration.RSE,
+ )
+ self._task.timing.cfg_samp_clk_timing(
+ rate=self.sample_rate,
+ sample_mode=nidaqmx.constants.AcquisitionType.CONTINUOUS,
+ samps_per_chan=int(self.sample_rate),
+ )
+ self._task.start()
+ self._started = True
+ return True
+ except Exception as e:
+ print(f"[NidaqmxLayer] start() failed: {e}")
+ self._started = False
+ return False
+
+ def stop(self) -> None:
+ self._started = False
+ if self._task is not None:
+ try:
+ self._task.stop()
+ self._task.close()
+ except Exception:
+ pass
+ self._task = None
+
+ # ── Read ────────────────────────────────────────────────────────────
+
+ def read(self) -> Dict[str, float]:
+ """Return latest sample per channel as {channel_name: voltage}."""
+ if not self._started:
+ return {}
+ if self.simulate:
+ return self._sim_read()
+ return self._hw_read()
+
+ def _hw_read(self) -> Dict[str, float]:
+ """Read one sample per channel from hardware."""
+ try:
+ samples = self._task.read(number_of_samples_per_channel=1)
+ # nidaqmx returns list-of-lists when multiple channels
+ if len(self.channels) == 1:
+ samples = [samples]
+ return {ch: float(samples[i][0]) for i, ch in enumerate(self.channels)}
+ except Exception as e:
+ print(f"[NidaqmxLayer] read() failed: {e}")
+ return {}
+
+ def _sim_read(self) -> Dict[str, float]:
+ t = time.time() - self._t0
+ result = {}
+ for i, ch in enumerate(self.channels):
+ p = self._sim_params[i]
+ val = p["amp"] * math.sin(2 * math.pi * p["freq"] * t + p["phase"]) + p["offset"]
+ val += random.gauss(0, p["noise"] * p["amp"])
+ val = max(self.min_val, min(self.max_val, val))
+ result[ch] = round(val, 5)
+ return result
+
+ # ── Introspection ───────────────────────────────────────────────────
+
+ @staticmethod
+ def list_devices() -> List[str]:
+ """Return list of detected NI device names, or [] if unavailable."""
+ if not _NI_AVAILABLE:
+ return []
+ try:
+ system = nidaqmx.system.System.local()
+ return [d.name for d in system.devices]
+ except Exception:
+ return []
+
+ @property
+ def is_simulated(self) -> bool:
+ return self.simulate
+
+ @property
+ def ni_available(self) -> bool:
+ return _NI_AVAILABLE
+
+ def __repr__(self):
+ mode = "SIM" if self.simulate else "HW"
+ return f"<NidaqmxLayer {self.device_name} ch={self.channels} [{mode}]>"
diff --git a/daq_system/daq_system/core/__init__.py b/daq_system/daq_system/core/__init__.py
new file mode 100644
index 0000000..06fabd5
--- /dev/null
+++ b/daq_system/daq_system/core/__init__.py
@@ -0,0 +1,3 @@
+# core/__init__.py
+from core.acquisition import AcquisitionEngine, ChannelBuffer
+__all__ = ["AcquisitionEngine", "ChannelBuffer"]
diff --git a/daq_system/daq_system/core/acquisition.py b/daq_system/daq_system/core/acquisition.py
new file mode 100644
index 0000000..640e6d5
--- /dev/null
+++ b/daq_system/daq_system/core/acquisition.py
@@ -0,0 +1,225 @@
+"""
+core/acquisition.py
+
+Background acquisition engine.
+Polls all connected devices, buffers data, fires Qt signals,
+writes CSV logs, and checks alarm thresholds.
+"""
+
+import csv
+import os
+import threading
+import time
+from collections import deque
+from datetime import datetime
+from typing import Dict, List, Optional, Tuple
+
+from PyQt6.QtCore import QObject, pyqtSignal
+
+from devices.base_device import BaseDevice, DeviceStatus
+
+MAX_BUFFER = 20_000 # samples per channel
+
+
+class ChannelBuffer:
+ """Circular time-series buffer for one channel."""
+
+ def __init__(self, maxlen: int = MAX_BUFFER):
+ self.times: deque = deque(maxlen=maxlen)
+ self.values: deque = deque(maxlen=maxlen)
+
+ def append(self, t: float, v: float):
+ self.times.append(t)
+ self.values.append(v)
+
+ def window(self, seconds: float) -> Tuple[List[float], List[float]]:
+ """Return the last `seconds` worth of data."""
+ if not self.times:
+ return [], []
+ cutoff = self.times[-1] - seconds
+ ts = list(self.times)
+ vs = list(self.values)
+ idx = next((i for i, t in enumerate(ts) if t >= cutoff), 0)
+ return ts[idx:], vs[idx:]
+
+ def all(self) -> Tuple[List[float], List[float]]:
+ return list(self.times), list(self.values)
+
+ def latest(self) -> Optional[float]:
+ return self.values[-1] if self.values else None
+
+ def clear(self):
+ self.times.clear()
+ self.values.clear()
+
+ def __len__(self):
+ return len(self.times)
+
+
+class AcquisitionEngine(QObject):
+ """
+ Thread-safe DAQ polling engine.
+
+ Signals
+ -------
+ new_data(device_id, channel_id, timestamp, value)
+ alarm_triggered(device_id, channel_id, value, kind) kind: "low"|"high"
+ device_status_changed(device_id, status_str)
+ log_started(filepath)
+ log_stopped(filepath)
+ """
+
+ new_data = pyqtSignal(str, str, float, float)
+ alarm_triggered = pyqtSignal(str, str, float, str)
+ device_status_changed = pyqtSignal(str, str)
+ log_started = pyqtSignal(str)
+ log_stopped = pyqtSignal(str)
+
+ def __init__(self, poll_interval_ms: int = 100):
+ super().__init__()
+ self._interval = poll_interval_ms / 1000.0
+ self._devices: List[BaseDevice] = []
+ self._buffers: Dict[str, Dict[str, ChannelBuffer]] = {}
+ self._running = False
+ self._thread: Optional[threading.Thread] = None
+ self._t0 = 0.0
+
+ # Logging
+ self._logging = False
+ self._log_path = ""
+ self._csv_file = None
+ self._csv_writer = None
+
+ # Alarm dedup
+ self._alarm_state: Dict[str, bool] = {}
+
+ # ── Device management ────────────────────────────────────────────────
+
+ def add_device(self, dev: BaseDevice):
+ self._devices.append(dev)
+ self._buffers[dev.info.device_id] = {
+ ch.channel_id: ChannelBuffer()
+ for ch in dev.info.channels
+ }
+
+ def remove_device(self, device_id: str):
+ self._devices = [d for d in self._devices if d.info.device_id != device_id]
+ self._buffers.pop(device_id, None)
+
+ def get_buffer(self, device_id: str, channel_id: str) -> Optional[ChannelBuffer]:
+ return self._buffers.get(device_id, {}).get(channel_id)
+
+ # ── Start / stop ─────────────────────────────────────────────────────
+
+ def start(self):
+ if self._running:
+ return
+ self._t0 = time.time()
+ self._running = True
+ self._thread = threading.Thread(target=self._loop, daemon=True, name="DAQ-Acq")
+ self._thread.start()
+
+ def stop(self):
+ self._running = False
+ if self._thread:
+ self._thread.join(timeout=3.0)
+ self.stop_logging()
+
+ # ── Logging ──────────────────────────────────────────────────────────
+
+ def start_logging(self, filepath: str = "") -> str:
+ if not filepath:
+ os.makedirs("logs", exist_ok=True)
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
+ filepath = f"logs/daq_{ts}.csv"
+ self._log_path = filepath
+ self._csv_file = open(filepath, "w", newline="")
+ headers = ["elapsed_s"]
+ for dev in self._devices:
+ for ch in dev.info.channels:
+ headers.append(f"{dev.info.device_id}/{ch.channel_id}[{ch.unit}]")
+ self._csv_writer = csv.writer(self._csv_file)
+ self._csv_writer.writerow(headers)
+ self._logging = True
+ self.log_started.emit(filepath)
+ return filepath
+
+ def stop_logging(self):
+ if not self._logging:
+ return
+ self._logging = False
+ path = self._log_path
+ try:
+ if self._csv_file:
+ self._csv_file.flush()
+ self._csv_file.close()
+ except Exception:
+ pass
+ self._csv_file = None
+ self._csv_writer = None
+ self.log_stopped.emit(path)
+
+ # ── Acquisition loop ──────────────────────────────────────────────────
+
+ def _loop(self):
+ while self._running:
+ t_start = time.time()
+ elapsed = t_start - self._t0
+ log_row = [f"{elapsed:.4f}"]
+
+ for dev in list(self._devices):
+ active = dev.status in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED)
+ try:
+ readings = dev.read_channels() if active else {}
+ except Exception as e:
+ print(f"[Acq] {dev.info.device_id} read error: {e}")
+ readings = {}
+
+ for ch in dev.info.channels:
+ val = readings.get(ch.channel_id)
+ if val is None:
+ log_row.append("")
+ continue
+ # Buffer
+ buf = self._buffers.get(dev.info.device_id, {}).get(ch.channel_id)
+ if buf is not None:
+ buf.append(elapsed, float(val))
+ # Signal
+ self.new_data.emit(dev.info.device_id, ch.channel_id, elapsed, float(val))
+ log_row.append(f"{val:.5f}")
+ # Alarms
+ self._check_alarm(dev.info.device_id, ch, float(val))
+
+ if self._logging and self._csv_writer:
+ try:
+ self._csv_writer.writerow(log_row)
+ except Exception:
+ pass
+
+ # Sleep remainder of interval
+ dt = time.time() - t_start
+ sleep = self._interval - dt
+ if sleep > 0:
+ time.sleep(sleep)
+
+ # ── Alarm logic ───────────────────────────────────────────────────────
+
+ def _check_alarm(self, device_id: str, ch, val: float):
+ lo_key = f"{device_id}.{ch.channel_id}.lo"
+ hi_key = f"{device_id}.{ch.channel_id}.hi"
+
+ if ch.alarm_low is not None:
+ if val < ch.alarm_low:
+ if not self._alarm_state.get(lo_key):
+ self._alarm_state[lo_key] = True
+ self.alarm_triggered.emit(device_id, ch.channel_id, val, "low")
+ else:
+ self._alarm_state[lo_key] = False
+
+ if ch.alarm_high is not None:
+ if val > ch.alarm_high:
+ if not self._alarm_state.get(hi_key):
+ self._alarm_state[hi_key] = True
+ self.alarm_triggered.emit(device_id, ch.channel_id, val, "high")
+ else:
+ self._alarm_state[hi_key] = False
diff --git a/daq_system/daq_system/devices/__init__.py b/daq_system/daq_system/devices/__init__.py
new file mode 100644
index 0000000..f79b333
--- /dev/null
+++ b/daq_system/daq_system/devices/__init__.py
@@ -0,0 +1,5 @@
+# devices/__init__.py
+from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus
+from devices.device_registry import DeviceRegistry
+
+__all__ = ["BaseDevice", "ChannelConfig", "DeviceInfo", "DeviceStatus", "DeviceRegistry"]
diff --git a/daq_system/daq_system/devices/analog_input.py b/daq_system/daq_system/devices/analog_input.py
new file mode 100644
index 0000000..5bb5d28
--- /dev/null
+++ b/daq_system/daq_system/devices/analog_input.py
@@ -0,0 +1,276 @@
+"""
+devices/analog_input.py
+
+Analog Input device module.
+
+Supports two interchangeable backends:
+ • backend="nidaqmx" → NidaqmxLayer (NI hardware or sim)
+ • backend="arduino" → ArduinoLayer (Arduino hardware or sim)
+
+The device is backend-agnostic at the acquisition layer — swap
+the backend without changing any other code.
+"""
+
+from typing import Any, Dict, List
+
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QFormLayout, QGroupBox,
+ QComboBox, QDoubleSpinBox, QSpinBox, QCheckBox,
+ QLineEdit, QLabel, QPushButton, QHBoxLayout,
+)
+from PyQt6.QtCore import Qt
+
+from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus
+from api_layers.nidaqmx_layer import NidaqmxLayer
+from api_layers.arduino_layer import ArduinoLayer
+
+# Distinct colors for up to 16 channels
+_COLORS = [
+ "#00d4ff", "#ff6b35", "#7fff6e", "#ffcc00",
+ "#c77dff", "#ff4d6d", "#4cc9f0", "#f72585",
+ "#38b000", "#e9c46a", "#a8dadc", "#e63946",
+ "#90e0ef", "#fb8500", "#b5e48c", "#d62828",
+]
+
+
+class AnalogInputDevice(BaseDevice):
+ """Multi-channel analog input. Backend: NI-DAQmx or Arduino."""
+
+ DEVICE_TYPE = "analog_input"
+ ICON = "〜"
+
+ def __init__(
+ self,
+ device_id: str = "ai_0",
+ num_channels: int = 4,
+ simulate: bool = True,
+ backend: str = "nidaqmx", # "nidaqmx" | "arduino"
+ # NI-specific
+ ni_device: str = "Dev1",
+ ni_min_v: float = -10.0,
+ ni_max_v: float = 10.0,
+ # Arduino-specific
+ ard_port: str = "COM3",
+ ard_baud: int = 115200,
+ ):
+ self.backend = backend
+ self.simulate = simulate
+
+ # Build channel list
+ if backend == "arduino":
+ pins = ArduinoLayer.DEFAULT_ANALOG_PINS[:num_channels]
+ channels = [
+ ChannelConfig(
+ channel_id=p, name=p, unit="V",
+ min_value=0.0, max_value=5.0,
+ alarm_low=None, alarm_high=4.8,
+ color=_COLORS[i % len(_COLORS)],
+ )
+ for i, p in enumerate(pins)
+ ]
+ else:
+ ni_pins = [f"ai{i}" for i in range(num_channels)]
+ channels = [
+ ChannelConfig(
+ channel_id=p, name=p.upper(), unit="V",
+ min_value=ni_min_v, max_value=ni_max_v,
+ alarm_low=None, alarm_high=ni_max_v * 0.9,
+ color=_COLORS[i % len(_COLORS)],
+ )
+ for i, p in enumerate(ni_pins)
+ ]
+
+ info = DeviceInfo(
+ device_id=device_id,
+ name=f"Analog Input ({backend.upper()})",
+ device_type=self.DEVICE_TYPE,
+ description=f"Multi-channel analog input via {backend}",
+ manufacturer="NI" if backend == "nidaqmx" else "Arduino",
+ icon=self.ICON,
+ channels=channels,
+ )
+ super().__init__(info)
+
+ # Instantiate the backend layer
+ if backend == "arduino":
+ self._layer = ArduinoLayer(
+ port=ard_port,
+ baud=ard_baud,
+ analog_pins=[ch.channel_id for ch in channels],
+ simulate=simulate,
+ )
+ else:
+ self._layer = NidaqmxLayer(
+ device_name=ni_device,
+ channels=[ch.channel_id for ch in channels],
+ min_val=ni_min_v,
+ max_val=ni_max_v,
+ simulate=simulate,
+ )
+
+ # Store config for the config widget
+ self._ni_device = ni_device
+ self._ni_min_v = ni_min_v
+ self._ni_max_v = ni_max_v
+ self._ard_port = ard_port
+ self._ard_baud = ard_baud
+
+ # ── BaseDevice interface ────────────────────────────────────────────
+
+ def connect(self) -> bool:
+ ok = self._layer.start() if hasattr(self._layer, "start") else self._layer.connect()
+ self.status = DeviceStatus.SIMULATED if self.simulate else (
+ DeviceStatus.CONNECTED if ok else DeviceStatus.ERROR
+ )
+ return ok
+
+ def disconnect(self) -> None:
+ if hasattr(self._layer, "stop"):
+ self._layer.stop()
+ else:
+ self._layer.disconnect()
+ self.status = DeviceStatus.DISCONNECTED
+
+ def read_channels(self) -> Dict[str, float]:
+ return self._layer.read()
+
+ def write_channel(self, channel_id: str, value: Any) -> bool:
+ return False # AI is read-only
+
+ def get_config_widget(self) -> QWidget:
+ return AnalogInputConfigWidget(self)
+
+ def switch_backend(self, backend: str, **kwargs) -> None:
+ """Hot-swap the API layer without re-creating the device object."""
+ was_running = self.status in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED)
+ if was_running:
+ self.disconnect()
+ self.backend = backend
+ self.simulate = kwargs.get("simulate", self.simulate)
+ if backend == "arduino":
+ self._layer = ArduinoLayer(
+ port=kwargs.get("port", self._ard_port),
+ baud=kwargs.get("baud", self._ard_baud),
+ analog_pins=[ch.channel_id for ch in self.info.channels],
+ simulate=self.simulate,
+ )
+ else:
+ self._layer = NidaqmxLayer(
+ device_name=kwargs.get("ni_device", self._ni_device),
+ channels=[ch.channel_id for ch in self.info.channels],
+ min_val=kwargs.get("min_val", self._ni_min_v),
+ max_val=kwargs.get("max_val", self._ni_max_v),
+ simulate=self.simulate,
+ )
+ if was_running:
+ self.connect()
+
+
+# ── Config Widget ────────────────────────────────────────────────────────────
+
+class AnalogInputConfigWidget(QWidget):
+ def __init__(self, device: AnalogInputDevice):
+ super().__init__()
+ self.device = device
+ self._build()
+
+ def _build(self):
+ root = QVBoxLayout(self)
+ root.setContentsMargins(0, 0, 0, 0)
+
+ # ── Backend selector ──────────────────────────────────────────
+ be_grp = QGroupBox("API Backend")
+ be_form = QFormLayout(be_grp)
+
+ self.backend_cb = QComboBox()
+ self.backend_cb.addItems(["nidaqmx", "arduino"])
+ self.backend_cb.setCurrentText(self.device.backend)
+ be_form.addRow("Backend:", self.backend_cb)
+
+ self.sim_check = QCheckBox("Simulation Mode")
+ self.sim_check.setChecked(self.device.simulate)
+ be_form.addRow(self.sim_check)
+
+ root.addWidget(be_grp)
+
+ # ── NI settings ───────────────────────────────────────────────
+ self.ni_grp = QGroupBox("NI-DAQmx Settings")
+ ni_form = QFormLayout(self.ni_grp)
+
+ self.ni_dev_edit = QLineEdit(self.device._ni_device)
+ ni_form.addRow("Device:", self.ni_dev_edit)
+
+ self.ni_min_spin = QDoubleSpinBox()
+ self.ni_min_spin.setRange(-100, 0); self.ni_min_spin.setValue(self.device._ni_min_v)
+ self.ni_min_spin.setSuffix(" V")
+ ni_form.addRow("Min V:", self.ni_min_spin)
+
+ self.ni_max_spin = QDoubleSpinBox()
+ self.ni_max_spin.setRange(0, 100); self.ni_max_spin.setValue(self.device._ni_max_v)
+ self.ni_max_spin.setSuffix(" V")
+ ni_form.addRow("Max V:", self.ni_max_spin)
+
+ # Detect button
+ detect_btn = QPushButton("Detect NI Devices")
+ detect_btn.clicked.connect(self._detect_ni)
+ ni_form.addRow(detect_btn)
+ self.ni_detect_lbl = QLabel("")
+ ni_form.addRow(self.ni_detect_lbl)
+
+ root.addWidget(self.ni_grp)
+
+ # ── Arduino settings ──────────────────────────────────────────
+ self.ard_grp = QGroupBox("Arduino Settings")
+ ard_form = QFormLayout(self.ard_grp)
+
+ self.ard_port_edit = QLineEdit(self.device._ard_port)
+ ard_form.addRow("Port:", self.ard_port_edit)
+
+ self.ard_baud_cb = QComboBox()
+ self.ard_baud_cb.addItems(["9600", "57600", "115200", "230400"])
+ self.ard_baud_cb.setCurrentText(str(self.device._ard_baud))
+ ard_form.addRow("Baud Rate:", self.ard_baud_cb)
+
+ scan_btn = QPushButton("Scan Serial Ports")
+ scan_btn.clicked.connect(self._scan_ports)
+ ard_form.addRow(scan_btn)
+ self.ard_port_lbl = QLabel("")
+ ard_form.addRow(self.ard_port_lbl)
+
+ root.addWidget(self.ard_grp)
+
+ # ── Apply button ──────────────────────────────────────────────
+ apply_btn = QPushButton("Apply & Reconnect")
+ apply_btn.setObjectName("applyButton")
+ apply_btn.clicked.connect(self._apply)
+ root.addWidget(apply_btn)
+ root.addStretch()
+
+ self._update_visibility()
+ self.backend_cb.currentTextChanged.connect(self._update_visibility)
+
+ def _update_visibility(self):
+ be = self.backend_cb.currentText()
+ self.ni_grp.setVisible(be == "nidaqmx")
+ self.ard_grp.setVisible(be == "arduino")
+
+ def _detect_ni(self):
+ from api_layers.nidaqmx_layer import NidaqmxLayer
+ devs = NidaqmxLayer.list_devices()
+ self.ni_detect_lbl.setText(", ".join(devs) if devs else "None detected")
+
+ def _scan_ports(self):
+ from api_layers.arduino_layer import ArduinoLayer
+ ports = ArduinoLayer.list_ports()
+ self.ard_port_lbl.setText(", ".join(ports) if ports else "None found")
+
+ def _apply(self):
+ self.device.switch_backend(
+ backend=self.backend_cb.currentText(),
+ simulate=self.sim_check.isChecked(),
+ ni_device=self.ni_dev_edit.text(),
+ min_val=self.ni_min_spin.value(),
+ max_val=self.ni_max_spin.value(),
+ port=self.ard_port_edit.text(),
+ baud=int(self.ard_baud_cb.currentText()),
+ )
diff --git a/daq_system/daq_system/devices/base_device.py b/daq_system/daq_system/devices/base_device.py
new file mode 100644
index 0000000..922a743
--- /dev/null
+++ b/daq_system/daq_system/devices/base_device.py
@@ -0,0 +1,104 @@
+"""
+devices/base_device.py
+
+Abstract base class for all DAQ I/O modules.
+Every device plugin must subclass BaseDevice and implement the required methods.
+"""
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Optional
+from enum import Enum
+
+
+class DeviceStatus(Enum):
+ DISCONNECTED = "disconnected"
+ CONNECTING = "connecting"
+ CONNECTED = "connected"
+ ERROR = "error"
+ SIMULATED = "simulated"
+
+
+@dataclass
+class ChannelConfig:
+ """Configuration for a single I/O channel."""
+ channel_id: str
+ name: str
+ unit: str = ""
+ min_value: float = 0.0
+ max_value: float = 100.0
+ alarm_low: Optional[float] = None
+ alarm_high: Optional[float] = None
+ enabled: bool = True
+ color: str = "#00d4ff"
+ extra: Dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass
+class DeviceInfo:
+ """Metadata describing a device module."""
+ device_id: str
+ name: str
+ device_type: str # "analog_input" | "digital_io" | "serial" | "temperature"
+ description: str = ""
+ manufacturer: str = ""
+ model: str = ""
+ version: str = "1.0.0"
+ icon: str = "⚙"
+ channels: List[ChannelConfig] = field(default_factory=list)
+
+
+class BaseDevice(ABC):
+ """
+ Abstract base for all DAQ device plugins.
+
+ To create a new module:
+ 1. Subclass BaseDevice
+ 2. Implement all @abstractmethod methods
+ 3. Drop the file in devices/ — DeviceRegistry auto-discovers it
+ """
+
+ def __init__(self, device_info: DeviceInfo):
+ self.info = device_info
+ self.status = DeviceStatus.DISCONNECTED
+ self._callbacks: List[Any] = []
+
+ # ── Required interface ──────────────────────────────────────────────
+
+ @abstractmethod
+ def connect(self) -> bool:
+ """Open connection. Returns True on success."""
+
+ @abstractmethod
+ def disconnect(self) -> None:
+ """Close connection and release resources."""
+
+ @abstractmethod
+ def read_channels(self) -> Dict[str, float]:
+ """Return {channel_id: value} for all enabled channels."""
+
+ @abstractmethod
+ def write_channel(self, channel_id: str, value: Any) -> bool:
+ """Write value to an output channel. Returns True on success."""
+
+ @abstractmethod
+ def get_config_widget(self):
+ """Return a QWidget with device-specific configuration controls."""
+
+ # ── Shared helpers ──────────────────────────────────────────────────
+
+ def add_data_callback(self, cb) -> None:
+ self._callbacks.append(cb)
+
+ def _emit(self, channel_id: str, value: float, timestamp: float) -> None:
+ for cb in self._callbacks:
+ try:
+ cb(self.info.device_id, channel_id, value, timestamp)
+ except Exception:
+ pass
+
+ def get_channel(self, channel_id: str) -> Optional[ChannelConfig]:
+ return next((c for c in self.info.channels if c.channel_id == channel_id), None)
+
+ def __repr__(self):
+ return f"<{self.__class__.__name__} id={self.info.device_id} status={self.status.value}>"
diff --git a/daq_system/daq_system/devices/device_registry.py b/daq_system/daq_system/devices/device_registry.py
new file mode 100644
index 0000000..8ed6886
--- /dev/null
+++ b/daq_system/daq_system/devices/device_registry.py
@@ -0,0 +1,73 @@
+"""
+devices/device_registry.py
+
+Auto-discovers and manages all BaseDevice subclasses.
+Drop a new .py file in devices/ and it appears automatically.
+"""
+
+import importlib
+import inspect
+import pkgutil
+from pathlib import Path
+from typing import Dict, List, Optional, Type
+
+from devices.base_device import BaseDevice, DeviceInfo
+
+
+class DeviceRegistry:
+ def __init__(self):
+ self._classes: Dict[str, Type[BaseDevice]] = {}
+ self._instances: Dict[str, BaseDevice] = {}
+ self._discover()
+
+ # ── Discovery ────────────────────────────────────────────────────────
+
+ def _discover(self):
+ path = Path(__file__).parent
+ package = "devices"
+ skip = {"base_device", "device_registry"}
+
+ for _, mod_name, _ in pkgutil.iter_modules([str(path)]):
+ if mod_name.startswith("_") or mod_name in skip:
+ continue
+ try:
+ mod = importlib.import_module(f"{package}.{mod_name}")
+ for name, obj in inspect.getmembers(mod, inspect.isclass):
+ if issubclass(obj, BaseDevice) and obj is not BaseDevice:
+ self._classes[name] = obj
+ except Exception as e:
+ print(f"[Registry] Could not load {mod_name}: {e}")
+
+ # ── Instance management ──────────────────────────────────────────────
+
+ def add_instance(self, device: BaseDevice) -> None:
+ self._instances[device.info.device_id] = device
+
+ def remove_instance(self, device_id: str) -> None:
+ dev = self._instances.pop(device_id, None)
+ if dev:
+ try: dev.disconnect()
+ except Exception: pass
+
+ def get_instance(self, device_id: str) -> Optional[BaseDevice]:
+ return self._instances.get(device_id)
+
+ def all_instances(self) -> List[BaseDevice]:
+ return list(self._instances.values())
+
+ def available_classes(self) -> List[str]:
+ return list(self._classes.keys())
+
+ def get_class(self, name: str) -> Optional[Type[BaseDevice]]:
+ return self._classes.get(name)
+
+ def create(self, class_name: str, device_id: str, **kw) -> BaseDevice:
+ cls = self._classes.get(class_name)
+ if not cls:
+ raise ValueError(f"Unknown device class: {class_name}")
+ dev = cls(device_id=device_id, **kw)
+ self._instances[device_id] = dev
+ return dev
+
+ def __len__(self):
+ return len(self._instances)
diff --git a/daq_system/daq_system/devices/digital_io.py b/daq_system/daq_system/devices/digital_io.py
new file mode 100644
index 0000000..23a345f
--- /dev/null
+++ b/daq_system/daq_system/devices/digital_io.py
@@ -0,0 +1,261 @@
+"""
+devices/digital_io.py
+
+Digital I/O device module. Backends: NI-DAQmx or Arduino.
+
+NI backend – uses nidaqmx digital line tasks (P0.0..P0.7)
+Arduino – uses ArduinoLayer digital pin reads; writes via W:Dxx:val
+"""
+
+import random
+import time
+from typing import Any, Dict
+
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QGroupBox,
+ QCheckBox, QPushButton, QLabel, QFormLayout,
+ QComboBox, QLineEdit,
+)
+
+from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus
+
+_IN_COLORS = ["#00d4ff", "#4cc9f0", "#90e0ef", "#caf0f8",
+ "#0077b6", "#023e8a", "#48cae4", "#ade8f4"]
+_OUT_COLORS = ["#ff6b35", "#ffcc00", "#f77f00", "#fcbf49",
+ "#d62828", "#e63946", "#fb8500", "#ffd166"]
+
+
+class DigitalIODevice(BaseDevice):
+ DEVICE_TYPE = "digital_io"
+ ICON = "⬛"
+
+ def __init__(
+ self,
+ device_id: str = "dio_0",
+ num_inputs: int = 8,
+ num_outputs: int = 8,
+ simulate: bool = True,
+ backend: str = "nidaqmx", # "nidaqmx" | "arduino"
+ ni_device: str = "Dev1",
+ ard_port: str = "COM3",
+ ard_baud: int = 115200,
+ ):
+ self.simulate = simulate
+ self.backend = backend
+ self._ni_device = ni_device
+ self._ard_port = ard_port
+ self._ard_baud = ard_baud
+
+ channels = []
+ for i in range(num_inputs):
+ channels.append(ChannelConfig(
+ channel_id=f"di{i}", name=f"DI {i}", unit="",
+ min_value=0.0, max_value=1.0,
+ color=_IN_COLORS[i % len(_IN_COLORS)],
+ ))
+ for i in range(num_outputs):
+ channels.append(ChannelConfig(
+ channel_id=f"do{i}", name=f"DO {i}", unit="",
+ min_value=0.0, max_value=1.0,
+ color=_OUT_COLORS[i % len(_OUT_COLORS)],
+ ))
+
+ info = DeviceInfo(
+ device_id=device_id,
+ name=f"Digital I/O ({backend.upper()})",
+ device_type=self.DEVICE_TYPE,
+ description="Digital input/output module",
+ icon=self.ICON,
+ channels=channels,
+ )
+ super().__init__(info)
+
+ self._output_state: Dict[str, int] = {
+ f"do{i}": 0 for i in range(num_outputs)
+ }
+ self._sim_toggle: Dict[str, int] = {}
+ self._sim_state: Dict[str, int] = {}
+
+ # Hardware task placeholders
+ self._ni_in_task = None
+ self._ni_out_task = None
+ self._ard_layer = None
+
+ # ── BaseDevice ──────────────────────────────────────────────────────
+
+ def connect(self) -> bool:
+ if self.simulate:
+ self.status = DeviceStatus.SIMULATED
+ return True
+
+ if self.backend == "nidaqmx":
+ return self._ni_connect()
+ else:
+ return self._ard_connect()
+
+ def _ni_connect(self) -> bool:
+ try:
+ import nidaqmx # type: ignore
+ from nidaqmx.constants import LineGrouping # type: ignore
+ n_in = sum(1 for c in self.info.channels if c.channel_id.startswith("di"))
+ n_out = sum(1 for c in self.info.channels if c.channel_id.startswith("do"))
+
+ if n_in:
+ self._ni_in_task = nidaqmx.Task()
+ for i in range(n_in):
+ self._ni_in_task.di_channels.add_di_chan(
+ f"{self._ni_device}/port0/line{i}",
+ line_grouping=LineGrouping.CHAN_PER_LINE,
+ )
+ self._ni_in_task.start()
+
+ if n_out:
+ self._ni_out_task = nidaqmx.Task()
+ for i in range(n_out):
+ self._ni_out_task.do_channels.add_do_chan(
+ f"{self._ni_device}/port1/line{i}",
+ line_grouping=LineGrouping.CHAN_PER_LINE,
+ )
+ self._ni_out_task.start()
+
+ self.status = DeviceStatus.CONNECTED
+ return True
+ except Exception as e:
+ print(f"[DigitalIODevice] NI connect failed: {e}")
+ self.status = DeviceStatus.ERROR
+ return False
+
+ def _ard_connect(self) -> bool:
+ from api_layers.arduino_layer import ArduinoLayer
+ self._ard_layer = ArduinoLayer(
+ port=self._ard_port, baud=self._ard_baud,
+ digital_pins=[c.channel_id for c in self.info.channels if c.channel_id.startswith("di")],
+ simulate=False,
+ )
+ ok = self._ard_layer.connect()
+ self.status = DeviceStatus.CONNECTED if ok else DeviceStatus.ERROR
+ return ok
+
+ def disconnect(self) -> None:
+ if self._ni_in_task:
+ try: self._ni_in_task.stop(); self._ni_in_task.close()
+ except Exception: pass
+ if self._ni_out_task:
+ try: self._ni_out_task.stop(); self._ni_out_task.close()
+ except Exception: pass
+ if self._ard_layer:
+ self._ard_layer.disconnect()
+ self.status = DeviceStatus.DISCONNECTED
+
+ def read_channels(self) -> Dict[str, float]:
+ if self.simulate:
+ return self._sim_read()
+ if self.backend == "nidaqmx":
+ return self._ni_read()
+ return self._ard_read()
+
+ def _ni_read(self) -> Dict[str, float]:
+ result = {}
+ try:
+ if self._ni_in_task:
+ vals = self._ni_in_task.read()
+ for i, ch in enumerate(c for c in self.info.channels if c.channel_id.startswith("di")):
+ result[ch.channel_id] = float(vals[i] if isinstance(vals, list) else vals)
+ except Exception as e:
+ print(f"[DigitalIODevice] NI read failed: {e}")
+ for ch in (c for c in self.info.channels if c.channel_id.startswith("do")):
+ result[ch.channel_id] = float(self._output_state.get(ch.channel_id, 0))
+ return result
+
+ def _ard_read(self) -> Dict[str, float]:
+ if not self._ard_layer:
+ return {}
+ raw = self._ard_layer.read()
+ result = {}
+ for ch in self.info.channels:
+ if ch.channel_id in raw:
+ result[ch.channel_id] = raw[ch.channel_id]
+ elif ch.channel_id.startswith("do"):
+ result[ch.channel_id] = float(self._output_state.get(ch.channel_id, 0))
+ return result
+
+ def _sim_read(self) -> Dict[str, float]:
+ result = {}
+ for ch in self.info.channels:
+ if ch.channel_id.startswith("di"):
+ cnt = self._sim_toggle.get(ch.channel_id, 0) + 1
+ if cnt >= random.randint(8, 40):
+ self._sim_state[ch.channel_id] = 1 - self._sim_state.get(ch.channel_id, 0)
+ cnt = 0
+ self._sim_toggle[ch.channel_id] = cnt
+ result[ch.channel_id] = float(self._sim_state.get(ch.channel_id, 0))
+ else:
+ result[ch.channel_id] = float(self._output_state.get(ch.channel_id, 0))
+ return result
+
+ def write_channel(self, channel_id: str, value: Any) -> bool:
+ self._output_state[channel_id] = int(bool(value))
+ if not self.simulate:
+ if self.backend == "nidaqmx" and self._ni_out_task:
+ try:
+ out_chs = [c for c in self.info.channels if c.channel_id.startswith("do")]
+ idx = next((i for i, c in enumerate(out_chs) if c.channel_id == channel_id), None)
+ if idx is not None:
+ states = [self._output_state.get(c.channel_id, 0) for c in out_chs]
+ self._ni_out_task.write(states)
+ except Exception as e:
+ print(f"[DigitalIODevice] NI write failed: {e}")
+ elif self.backend == "arduino" and self._ard_layer:
+ self._ard_layer.write(channel_id, int(bool(value)))
+ return True
+
+ def get_config_widget(self) -> QWidget:
+ return DigitalIOConfigWidget(self)
+
+
+class DigitalIOConfigWidget(QWidget):
+ def __init__(self, device: DigitalIODevice):
+ super().__init__()
+ self.device = device
+ self._buttons: Dict[str, QPushButton] = {}
+ self._build()
+
+ def _build(self):
+ root = QVBoxLayout(self)
+ root.setContentsMargins(0, 0, 0, 0)
+
+ be_grp = QGroupBox("Backend")
+ be_form = QFormLayout(be_grp)
+ self.be_cb = QComboBox()
+ self.be_cb.addItems(["nidaqmx", "arduino"])
+ self.be_cb.setCurrentText(self.device.backend)
+ be_form.addRow("Backend:", self.be_cb)
+ self.sim_chk = QCheckBox("Simulate")
+ self.sim_chk.setChecked(self.device.simulate)
+ be_form.addRow(self.sim_chk)
+ root.addWidget(be_grp)
+
+ out_grp = QGroupBox("Digital Outputs")
+ out_lay = QVBoxLayout(out_grp)
+ for ch in (c for c in self.device.info.channels if c.channel_id.startswith("do")):
+ row = QHBoxLayout()
+ lbl = QLabel(ch.name)
+ lbl.setMinimumWidth(50)
+ btn = QPushButton("OFF")
+ btn.setCheckable(True)
+ btn.setChecked(bool(self.device._output_state.get(ch.channel_id, 0)))
+ btn.setObjectName("digitalOutBtn")
+ cid = ch.channel_id
+
+ def _tog(checked, c=cid, b=btn):
+ self.device.write_channel(c, checked)
+ b.setText("ON" if checked else "OFF")
+
+ btn.toggled.connect(_tog)
+ row.addWidget(lbl)
+ row.addWidget(btn)
+ out_lay.addLayout(row)
+ self._buttons[ch.channel_id] = btn
+
+ root.addWidget(out_grp)
+ root.addStretch()
diff --git a/daq_system/daq_system/devices/serial_device.py b/daq_system/daq_system/devices/serial_device.py
new file mode 100644
index 0000000..6bb7bf1
--- /dev/null
+++ b/daq_system/daq_system/devices/serial_device.py
@@ -0,0 +1,187 @@
+"""
+devices/serial_device.py
+
+Generic Serial / UART device module.
+
+Uses ArduinoLayer for communication, but works with ANY instrument
+that sends newline-terminated data. Configurable parse formats:
+ • "csv" – plain comma-separated values mapped to channels in order
+ • "key:val" – "CH0:1.23,CH1:4.56" key-colon-value pairs
+ • "json" – {"CH0":1.23,"CH1":4.56}
+
+Switch format in the config widget without restarting.
+"""
+
+import json
+import math
+import random
+import threading
+import time
+from typing import Any, Dict, List
+
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QFormLayout, QGroupBox,
+ QComboBox, QLineEdit, QSpinBox, QLabel, QPushButton,
+)
+
+from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus
+from api_layers.arduino_layer import ArduinoLayer
+
+_COLORS = ["#7fff6e", "#4cc9f0", "#f72585", "#00d4ff",
+ "#ffcc00", "#c77dff", "#ff6b35", "#38b000"]
+
+
+class SerialDevice(BaseDevice):
+ DEVICE_TYPE = "serial"
+ ICON = "⇌"
+
+ def __init__(
+ self,
+ device_id: str = "ser_0",
+ port: str = "COM3",
+ baud_rate: int = 115200,
+ num_channels: int = 4,
+ channel_names: List[str] = None,
+ units: List[str] = None,
+ parse_format: str = "key:val", # "csv" | "key:val" | "json"
+ simulate: bool = True,
+ ):
+ self._port = port
+ self._baud = baud_rate
+ self._parse_format = parse_format
+ self.simulate = simulate
+
+ names = channel_names or [f"CH{i}" for i in range(num_channels)]
+ _units = units or ["" for _ in range(num_channels)]
+
+ channels = [
+ ChannelConfig(
+ channel_id=names[i], name=names[i], unit=_units[i],
+ min_value=0.0, max_value=1023.0,
+ color=_COLORS[i % len(_COLORS)],
+ )
+ for i in range(num_channels)
+ ]
+
+ info = DeviceInfo(
+ device_id=device_id, name="Serial / UART",
+ device_type=self.DEVICE_TYPE,
+ description=f"{port} @ {baud_rate}",
+ icon=self.ICON, channels=channels,
+ )
+ super().__init__(info)
+
+ self._layer = ArduinoLayer(
+ port=port, baud=baud_rate,
+ analog_pins=[ch.channel_id for ch in channels],
+ simulate=simulate,
+ )
+ self._t0 = 0.0
+
+ # ── BaseDevice ──────────────────────────────────────────────────────
+
+ def connect(self) -> bool:
+ self._t0 = time.time()
+ ok = self._layer.connect()
+ self.status = DeviceStatus.SIMULATED if self.simulate else (
+ DeviceStatus.CONNECTED if ok else DeviceStatus.ERROR
+ )
+ return ok
+
+ def disconnect(self) -> None:
+ self._layer.disconnect()
+ self.status = DeviceStatus.DISCONNECTED
+
+ def read_channels(self) -> Dict[str, float]:
+ raw = self._layer.read()
+ # Map by order if keys don't match channel IDs
+ if raw:
+ mapped: Dict[str, float] = {}
+ raw_vals = list(raw.values())
+ for i, ch in enumerate(self.info.channels):
+ if ch.channel_id in raw:
+ mapped[ch.channel_id] = raw[ch.channel_id]
+ elif i < len(raw_vals):
+ mapped[ch.channel_id] = raw_vals[i]
+ return mapped
+ return {}
+
+ def write_channel(self, channel_id: str, value: Any) -> bool:
+ return self._layer.write(channel_id, int(value))
+
+ def get_config_widget(self) -> QWidget:
+ return SerialConfigWidget(self)
+
+ def reconfigure(self, port: str, baud: int, fmt: str, simulate: bool):
+ was_on = self.status in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED)
+ if was_on:
+ self.disconnect()
+ self._port = port
+ self._baud = baud
+ self._parse_format = fmt
+ self.simulate = simulate
+ self._layer = ArduinoLayer(
+ port=port, baud=baud,
+ analog_pins=[ch.channel_id for ch in self.info.channels],
+ simulate=simulate,
+ )
+ if was_on:
+ self.connect()
+
+
+class SerialConfigWidget(QWidget):
+ def __init__(self, device: SerialDevice):
+ super().__init__()
+ self.device = device
+ self._build()
+
+ def _build(self):
+ root = QVBoxLayout(self)
+ root.setContentsMargins(0, 0, 0, 0)
+
+ grp = QGroupBox("Port Settings")
+ form = QFormLayout(grp)
+
+ self.port_edit = QLineEdit(self.device._port)
+ form.addRow("Port:", self.port_edit)
+
+ self.baud_cb = QComboBox()
+ self.baud_cb.addItems(["9600", "19200", "38400", "57600", "115200", "230400", "460800"])
+ self.baud_cb.setCurrentText(str(self.device._baud))
+ form.addRow("Baud Rate:", self.baud_cb)
+
+ self.fmt_cb = QComboBox()
+ self.fmt_cb.addItems(["key:val", "csv", "json"])
+ self.fmt_cb.setCurrentText(self.device._parse_format)
+ form.addRow("Parse Format:", self.fmt_cb)
+
+ self.sim_chk = QComboBox()
+ self.sim_chk.addItems(["Simulate", "Real Hardware"])
+ self.sim_chk.setCurrentIndex(0 if self.device.simulate else 1)
+ form.addRow("Mode:", self.sim_chk)
+
+ scan_btn = QPushButton("Scan Ports")
+ scan_btn.clicked.connect(self._scan)
+ form.addRow(scan_btn)
+ self.port_lbl = QLabel("")
+ form.addRow(self.port_lbl)
+
+ root.addWidget(grp)
+
+ apply_btn = QPushButton("Apply & Reconnect")
+ apply_btn.setObjectName("applyButton")
+ apply_btn.clicked.connect(self._apply)
+ root.addWidget(apply_btn)
+ root.addStretch()
+
+ def _scan(self):
+ ports = ArduinoLayer.list_ports()
+ self.port_lbl.setText(", ".join(ports) if ports else "None found")
+
+ def _apply(self):
+ self.device.reconfigure(
+ port=self.port_edit.text(),
+ baud=int(self.baud_cb.currentText()),
+ fmt=self.fmt_cb.currentText(),
+ simulate=(self.sim_chk.currentIndex() == 0),
+ )
diff --git a/daq_system/daq_system/main.py b/daq_system/daq_system/main.py
new file mode 100644
index 0000000..2f2d515
--- /dev/null
+++ b/daq_system/daq_system/main.py
@@ -0,0 +1,40 @@
+"""
+main.py — LabDAQ entry point.
+
+Run:
+ python main.py
+
+Requirements:
+ pip install PyQt6 pyqtgraph numpy pyserial
+
+Optional (real hardware):
+ pip install nidaqmx # NI-DAQmx runtime must also be installed
+"""
+
+import sys
+import os
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from PyQt6.QtWidgets import QApplication
+from PyQt6.QtCore import Qt
+from ui.main_window import MainWindow
+
+
+def main():
+ app = QApplication(sys.argv)
+ app.setApplicationName("LabDAQ")
+ app.setOrganizationName("Lab Instruments")
+ app.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps)
+
+ qss_path = os.path.join(os.path.dirname(__file__), "ui", "style.qss")
+ with open(qss_path, "r") as f:
+ app.setStyleSheet(f.read())
+
+ win = MainWindow()
+ win.show()
+ sys.exit(app.exec())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/daq_system/daq_system/requirements.txt b/daq_system/daq_system/requirements.txt
new file mode 100644
index 0000000..d5114b5
--- /dev/null
+++ b/daq_system/daq_system/requirements.txt
@@ -0,0 +1,8 @@
+PyQt6>=6.5.0
+pyqtgraph>=0.13.3
+numpy>=1.24.0
+pyserial>=3.5
+
+# Optional - install if using real hardware:
+# nidaqmx>=0.9.0 # NI-DAQmx Python API (requires NI-DAQmx runtime)
+# For Arduino: pyserial is sufficient (already listed above)
diff --git a/daq_system/daq_system/ui/__init__.py b/daq_system/daq_system/ui/__init__.py
new file mode 100644
index 0000000..dfde25c
--- /dev/null
+++ b/daq_system/daq_system/ui/__init__.py
@@ -0,0 +1 @@
+# ui/__init__.py
diff --git a/daq_system/daq_system/ui/add_device_dialog.py b/daq_system/daq_system/ui/add_device_dialog.py
new file mode 100644
index 0000000..30d0016
--- /dev/null
+++ b/daq_system/daq_system/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/daq_system/daq_system/ui/alarm_panel.py b/daq_system/daq_system/ui/alarm_panel.py
new file mode 100644
index 0000000..1270aff
--- /dev/null
+++ b/daq_system/daq_system/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/daq_system/daq_system/ui/config_dialog.py b/daq_system/daq_system/ui/config_dialog.py
new file mode 100644
index 0000000..7d87772
--- /dev/null
+++ b/daq_system/daq_system/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/daq_system/daq_system/ui/device_panel.py b/daq_system/daq_system/ui/device_panel.py
new file mode 100644
index 0000000..932f536
--- /dev/null
+++ b/daq_system/daq_system/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/daq_system/daq_system/ui/main_window.py b/daq_system/daq_system/ui/main_window.py
new file mode 100644
index 0000000..d329571
--- /dev/null
+++ b/daq_system/daq_system/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/daq_system/daq_system/ui/readout_panel.py b/daq_system/daq_system/ui/readout_panel.py
new file mode 100644
index 0000000..ef04e18
--- /dev/null
+++ b/daq_system/daq_system/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/daq_system/daq_system/ui/strip_chart.py b/daq_system/daq_system/ui/strip_chart.py
new file mode 100644
index 0000000..afc7cf7
--- /dev/null
+++ b/daq_system/daq_system/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/daq_system/daq_system/ui/style.qss b/daq_system/daq_system/ui/style.qss
new file mode 100644
index 0000000..60415fb
--- /dev/null
+++ b/daq_system/daq_system/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; }
diff --git a/daq_system/devices/__init__.py b/daq_system/devices/__init__.py
new file mode 100644
index 0000000..9c98181
--- /dev/null
+++ b/daq_system/devices/__init__.py
@@ -0,0 +1 @@
+# devices/__init__.py
diff --git a/daq_system/devices/__pycache__/__init__.cpython-312.pyc b/daq_system/devices/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..af251ba
--- /dev/null
+++ b/daq_system/devices/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/daq_system/devices/__pycache__/analog_input.cpython-312.pyc b/daq_system/devices/__pycache__/analog_input.cpython-312.pyc
new file mode 100644
index 0000000..efe5752
--- /dev/null
+++ b/daq_system/devices/__pycache__/analog_input.cpython-312.pyc
Binary files differ
diff --git a/daq_system/devices/__pycache__/base_device.cpython-312.pyc b/daq_system/devices/__pycache__/base_device.cpython-312.pyc
new file mode 100644
index 0000000..4f08c29
--- /dev/null
+++ b/daq_system/devices/__pycache__/base_device.cpython-312.pyc
Binary files differ
diff --git a/daq_system/devices/__pycache__/device_registry.cpython-312.pyc b/daq_system/devices/__pycache__/device_registry.cpython-312.pyc
new file mode 100644
index 0000000..8d5ca55
--- /dev/null
+++ b/daq_system/devices/__pycache__/device_registry.cpython-312.pyc
Binary files differ
diff --git a/daq_system/devices/__pycache__/digital_io.cpython-312.pyc b/daq_system/devices/__pycache__/digital_io.cpython-312.pyc
new file mode 100644
index 0000000..9ca7bc7
--- /dev/null
+++ b/daq_system/devices/__pycache__/digital_io.cpython-312.pyc
Binary files differ
diff --git a/daq_system/devices/__pycache__/serial_device.cpython-312.pyc b/daq_system/devices/__pycache__/serial_device.cpython-312.pyc
new file mode 100644
index 0000000..f4a2190
--- /dev/null
+++ b/daq_system/devices/__pycache__/serial_device.cpython-312.pyc
Binary files differ
diff --git a/daq_system/devices/__pycache__/temperature.cpython-312.pyc b/daq_system/devices/__pycache__/temperature.cpython-312.pyc
new file mode 100644
index 0000000..ab2a2b9
--- /dev/null
+++ b/daq_system/devices/__pycache__/temperature.cpython-312.pyc
Binary files differ
diff --git a/daq_system/devices/analog_input.py b/daq_system/devices/analog_input.py
new file mode 100644
index 0000000..8aaf9db
--- /dev/null
+++ b/daq_system/devices/analog_input.py
@@ -0,0 +1,184 @@
+"""
+devices/analog_input.py
+
+Analog Input device module — reads voltage/current channels.
+Includes a simulation mode (no hardware required) for development/demo.
+"""
+
+import math
+import random
+import time
+from typing import Any, Dict
+
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox,
+ QDoubleSpinBox, QGroupBox, QCheckBox, QSpinBox, QFormLayout
+)
+from PyQt6.QtCore import Qt
+
+from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus
+
+
+CHANNEL_COLORS = ["#00d4ff", "#ff6b35", "#7fff6e", "#ffcc00", "#c77dff", "#ff4d6d", "#4cc9f0", "#f72585"]
+
+
+class AnalogInputDevice(BaseDevice):
+ """
+ Analog voltage/current input module.
+
+ Supports up to 16 channels. In simulation mode, generates
+ realistic waveforms (sine, ramp, noise) for each channel.
+ Real hardware: override read_channels() with your SDK calls.
+ """
+
+ DEVICE_TYPE = "analog_input"
+ ICON = "〜"
+
+ def __init__(self, device_id: str = "ai_0", num_channels: int = 4,
+ simulate: bool = True, sample_rate_hz: float = 10.0):
+ channels = [
+ ChannelConfig(
+ channel_id=f"ch{i}",
+ name=f"AI {i}",
+ unit="V",
+ min_value=-10.0,
+ max_value=10.0,
+ alarm_low=-8.0,
+ alarm_high=8.0,
+ color=CHANNEL_COLORS[i % len(CHANNEL_COLORS)],
+ )
+ for i in range(num_channels)
+ ]
+ info = DeviceInfo(
+ device_id=device_id,
+ name="Analog Input",
+ device_type=self.DEVICE_TYPE,
+ description="Multi-channel analog voltage/current input",
+ manufacturer="Generic",
+ model="AI-16",
+ icon=self.ICON,
+ channels=channels,
+ )
+ super().__init__(info)
+ self.simulate = simulate
+ self.sample_rate_hz = sample_rate_hz
+ self._start_time = 0.0
+ # Sim parameters per channel
+ self._sim_params = [
+ {"freq": 0.5 + i * 0.3, "amp": 5.0, "offset": 0.0, "noise": 0.05, "mode": "sine"}
+ for i in range(num_channels)
+ ]
+
+ # ------------------------------------------------------------------ #
+ # BaseDevice interface #
+ # ------------------------------------------------------------------ #
+
+ def connect(self) -> bool:
+ if self.simulate:
+ self._start_time = time.time()
+ self.status = DeviceStatus.SIMULATED
+ return True
+ # TODO: Replace with real hardware SDK init
+ # e.g. import nidaqmx; self._task = nidaqmx.Task(); ...
+ self.status = DeviceStatus.ERROR
+ return False
+
+ def disconnect(self) -> None:
+ self.status = DeviceStatus.DISCONNECTED
+
+ def read_channels(self) -> Dict[str, float]:
+ if self.simulate:
+ return self._simulate_read()
+ # TODO: Replace with real hardware read
+ return {}
+
+ def write_channel(self, channel_id: str, value: Any) -> bool:
+ # Analog inputs don't support write — subclass for AO
+ return False
+
+ def get_config_widget(self) -> QWidget:
+ return AnalogInputConfigWidget(self)
+
+ # ------------------------------------------------------------------ #
+ # Simulation #
+ # ------------------------------------------------------------------ #
+
+ def _simulate_read(self) -> Dict[str, float]:
+ t = time.time() - self._start_time
+ result = {}
+ for i, ch in enumerate(self.info.channels):
+ if not ch.enabled:
+ continue
+ p = self._sim_params[i]
+ if p["mode"] == "sine":
+ val = p["amp"] * math.sin(2 * math.pi * p["freq"] * t) + p["offset"]
+ elif p["mode"] == "ramp":
+ period = 1.0 / max(p["freq"], 0.01)
+ val = p["amp"] * ((t % period) / period) * 2 - p["amp"] + p["offset"]
+ elif p["mode"] == "square":
+ val = p["amp"] * math.copysign(1, math.sin(2 * math.pi * p["freq"] * t)) + p["offset"]
+ else:
+ val = p["offset"]
+ val += random.gauss(0, p["noise"] * p["amp"])
+ val = max(ch.min_value, min(ch.max_value, val))
+ result[ch.channel_id] = round(val, 4)
+ return result
+
+
+# ------------------------------------------------------------------ #
+# Config Widget #
+# ------------------------------------------------------------------ #
+
+class AnalogInputConfigWidget(QWidget):
+ def __init__(self, device: AnalogInputDevice):
+ super().__init__()
+ self.device = device
+ self._build_ui()
+
+ def _build_ui(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ # Global settings
+ global_group = QGroupBox("Device Settings")
+ form = QFormLayout(global_group)
+
+ self.sim_check = QCheckBox("Simulation Mode")
+ self.sim_check.setChecked(self.device.simulate)
+ form.addRow(self.sim_check)
+
+ self.rate_spin = QDoubleSpinBox()
+ self.rate_spin.setRange(0.1, 1000.0)
+ self.rate_spin.setValue(self.device.sample_rate_hz)
+ self.rate_spin.setSuffix(" Hz")
+ form.addRow("Sample Rate:", self.rate_spin)
+
+ layout.addWidget(global_group)
+
+ # Per-channel
+ ch_group = QGroupBox("Channel Configuration")
+ ch_layout = QVBoxLayout(ch_group)
+
+ for i, ch in enumerate(self.device.info.channels):
+ row = QHBoxLayout()
+ en = QCheckBox(ch.name)
+ en.setChecked(ch.enabled)
+ row.addWidget(en)
+
+ mode_cb = QComboBox()
+ mode_cb.addItems(["sine", "ramp", "square", "dc"])
+ mode_cb.setCurrentText(self.device._sim_params[i]["mode"])
+ row.addWidget(QLabel("Mode:"))
+ row.addWidget(mode_cb)
+
+ freq_sp = QDoubleSpinBox()
+ freq_sp.setRange(0.01, 100.0)
+ freq_sp.setValue(self.device._sim_params[i]["freq"])
+ freq_sp.setSuffix(" Hz")
+ row.addWidget(QLabel("Freq:"))
+ row.addWidget(freq_sp)
+
+ ch_layout.addLayout(row)
+
+ layout.addWidget(ch_group)
+ layout.addStretch()
diff --git a/daq_system/devices/base_device.py b/daq_system/devices/base_device.py
new file mode 100644
index 0000000..4898a7a
--- /dev/null
+++ b/daq_system/devices/base_device.py
@@ -0,0 +1,127 @@
+"""
+devices/base_device.py
+
+Abstract base class for all DAQ I/O modules.
+Every device plugin must subclass BaseDevice and implement the required methods.
+"""
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Optional
+from enum import Enum
+
+
+class DeviceStatus(Enum):
+ DISCONNECTED = "disconnected"
+ CONNECTING = "connecting"
+ CONNECTED = "connected"
+ ERROR = "error"
+ SIMULATED = "simulated"
+
+
+@dataclass
+class ChannelConfig:
+ """Configuration for a single I/O channel."""
+ channel_id: str
+ name: str
+ unit: str = ""
+ min_value: float = 0.0
+ max_value: float = 100.0
+ alarm_low: Optional[float] = None
+ alarm_high: Optional[float] = None
+ enabled: bool = True
+ color: str = "#00d4ff" # For plotting
+ extra: Dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass
+class DeviceInfo:
+ """Metadata describing a device module."""
+ device_id: str
+ name: str
+ device_type: str # e.g. "analog_input", "digital_io", "serial", "temperature"
+ description: str = ""
+ manufacturer: str = ""
+ model: str = ""
+ version: str = "1.0.0"
+ icon: str = "⚙" # Unicode icon for UI display
+ channels: List[ChannelConfig] = field(default_factory=list)
+
+
+class BaseDevice(ABC):
+ """
+ Abstract base class for all DAQ device plugins.
+
+ To create a new device module:
+ 1. Subclass BaseDevice
+ 2. Implement all @abstractmethod methods
+ 3. Place the file in the devices/ directory
+ 4. The DeviceRegistry will auto-discover it
+ """
+
+ def __init__(self, device_info: DeviceInfo):
+ self.info = device_info
+ self.status = DeviceStatus.DISCONNECTED
+ self._callbacks: List[callable] = []
+
+ # ------------------------------------------------------------------ #
+ # Abstract interface — every device must implement these #
+ # ------------------------------------------------------------------ #
+
+ @abstractmethod
+ def connect(self) -> bool:
+ """
+ Open connection to the physical device.
+ Returns True on success, False on failure.
+ Sets self.status appropriately.
+ """
+
+ @abstractmethod
+ def disconnect(self) -> None:
+ """Close the connection and release resources."""
+
+ @abstractmethod
+ def read_channels(self) -> Dict[str, float]:
+ """
+ Read current values from all enabled channels.
+ Returns dict mapping channel_id -> float value.
+ Called repeatedly by the acquisition loop.
+ """
+
+ @abstractmethod
+ def write_channel(self, channel_id: str, value: Any) -> bool:
+ """
+ Write a value to an output channel (if supported).
+ Returns True on success.
+ """
+
+ @abstractmethod
+ def get_config_widget(self):
+ """
+ Return a QWidget with device-specific configuration controls.
+ This widget is embedded in the Device Config panel.
+ """
+
+ # ------------------------------------------------------------------ #
+ # Shared helpers #
+ # ------------------------------------------------------------------ #
+
+ def add_data_callback(self, callback: callable) -> None:
+ """Register a callback: callback(device_id, channel_id, value, timestamp)"""
+ self._callbacks.append(callback)
+
+ def _emit(self, channel_id: str, value: float, timestamp: float) -> None:
+ for cb in self._callbacks:
+ try:
+ cb(self.info.device_id, channel_id, value, timestamp)
+ except Exception:
+ pass
+
+ def get_channel(self, channel_id: str) -> Optional[ChannelConfig]:
+ for ch in self.info.channels:
+ if ch.channel_id == channel_id:
+ return ch
+ return None
+
+ def __repr__(self):
+ return f"<{self.__class__.__name__} id={self.info.device_id} status={self.status.value}>"
diff --git a/daq_system/devices/device_registry.py b/daq_system/devices/device_registry.py
new file mode 100644
index 0000000..e407bc3
--- /dev/null
+++ b/daq_system/devices/device_registry.py
@@ -0,0 +1,87 @@
+"""
+devices/device_registry.py
+
+Discovers, instantiates, and manages all device modules.
+Add new devices by dropping a .py file into the devices/ directory.
+"""
+
+import importlib
+import inspect
+import pkgutil
+from pathlib import Path
+from typing import Dict, List, Optional, Type
+
+from devices.base_device import BaseDevice, DeviceInfo
+
+
+class DeviceRegistry:
+ """Central registry for all DAQ device modules."""
+
+ def __init__(self):
+ self._device_classes: Dict[str, Type[BaseDevice]] = {}
+ self._instances: Dict[str, BaseDevice] = {}
+ self._auto_discover()
+
+ # ------------------------------------------------------------------ #
+ # Discovery #
+ # ------------------------------------------------------------------ #
+
+ def _auto_discover(self):
+ """Scan the devices/ package for BaseDevice subclasses."""
+ devices_path = Path(__file__).parent
+ package = "devices"
+
+ for _, module_name, _ in pkgutil.iter_modules([str(devices_path)]):
+ if module_name.startswith("_") or module_name in ("base_device", "device_registry"):
+ continue
+ try:
+ module = importlib.import_module(f"{package}.{module_name}")
+ for name, obj in inspect.getmembers(module, inspect.isclass):
+ if issubclass(obj, BaseDevice) and obj is not BaseDevice:
+ self._device_classes[name] = obj
+ except Exception as e:
+ print(f"[DeviceRegistry] Failed to load {module_name}: {e}")
+
+ def register_class(self, cls: Type[BaseDevice]) -> None:
+ """Manually register a device class (for testing / runtime plugins)."""
+ self._device_classes[cls.__name__] = cls
+
+ # ------------------------------------------------------------------ #
+ # Instance management #
+ # ------------------------------------------------------------------ #
+
+ def create_device(self, class_name: str, device_id: str, **kwargs) -> Optional[BaseDevice]:
+ """Instantiate a device by class name with a unique device_id."""
+ cls = self._device_classes.get(class_name)
+ if cls is None:
+ raise ValueError(f"Unknown device class: {class_name}")
+ instance = cls(device_id=device_id, **kwargs)
+ self._instances[device_id] = instance
+ return instance
+
+ def add_instance(self, device: BaseDevice) -> None:
+ """Register a pre-built device instance."""
+ self._instances[device.info.device_id] = device
+
+ def remove_instance(self, device_id: str) -> None:
+ dev = self._instances.pop(device_id, None)
+ if dev:
+ try:
+ dev.disconnect()
+ except Exception:
+ pass
+
+ def get_instance(self, device_id: str) -> Optional[BaseDevice]:
+ return self._instances.get(device_id)
+
+ def all_instances(self) -> List[BaseDevice]:
+ return list(self._instances.values())
+
+ def available_classes(self) -> List[str]:
+ return list(self._device_classes.keys())
+
+ def get_class(self, class_name: str) -> Optional[Type[BaseDevice]]:
+ return self._device_classes.get(class_name)
+
+ def __len__(self):
+ return len(self._instances)
diff --git a/daq_system/devices/digital_io.py b/daq_system/devices/digital_io.py
new file mode 100644
index 0000000..b78b5cf
--- /dev/null
+++ b/daq_system/devices/digital_io.py
@@ -0,0 +1,95 @@
+"""
+devices/digital_io.py
+
+Digital I/O device module — reads and writes binary channels.
+"""
+
+import random
+import time
+from typing import Any, Dict
+
+from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QCheckBox, QGroupBox, QPushButton, QLabel
+from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus
+
+
+class DigitalIODevice(BaseDevice):
+ DEVICE_TYPE = "digital_io"
+ ICON = "⬛"
+
+ def __init__(self, device_id: str = "dio_0", num_inputs: int = 8,
+ num_outputs: int = 8, simulate: bool = True):
+ channels = []
+ for i in range(num_inputs):
+ channels.append(ChannelConfig(
+ channel_id=f"di{i}", name=f"DI {i}", unit="",
+ min_value=0.0, max_value=1.0,
+ color="#00d4ff" if i % 2 == 0 else "#4cc9f0"
+ ))
+ for i in range(num_outputs):
+ channels.append(ChannelConfig(
+ channel_id=f"do{i}", name=f"DO {i}", unit="",
+ min_value=0.0, max_value=1.0,
+ color="#ff6b35" if i % 2 == 0 else "#ffcc00"
+ ))
+ info = DeviceInfo(
+ device_id=device_id, name="Digital I/O",
+ device_type=self.DEVICE_TYPE,
+ description="Digital input/output module",
+ icon=self.ICON, channels=channels
+ )
+ super().__init__(info)
+ self.simulate = simulate
+ self._output_state: Dict[str, int] = {f"do{i}": 0 for i in range(num_outputs)}
+ self._toggle_counters = [0] * num_inputs
+
+ def connect(self) -> bool:
+ self.status = DeviceStatus.SIMULATED if self.simulate else DeviceStatus.ERROR
+ return self.simulate
+
+ def disconnect(self) -> None:
+ self.status = DeviceStatus.DISCONNECTED
+
+ def read_channels(self) -> Dict[str, float]:
+ result = {}
+ # Simulate toggling inputs randomly
+ for i, ch in enumerate(self.info.channels):
+ if ch.channel_id.startswith("di"):
+ self._toggle_counters[i] += 1
+ if self._toggle_counters[i] > random.randint(5, 30):
+ self._toggle_counters[i] = 0
+ result[ch.channel_id] = float(random.randint(0, 1))
+ else:
+ result[ch.channel_id] = result.get(ch.channel_id, 0.0)
+ elif ch.channel_id.startswith("do"):
+ result[ch.channel_id] = float(self._output_state.get(ch.channel_id, 0))
+ return result
+
+ def write_channel(self, channel_id: str, value: Any) -> bool:
+ if channel_id in self._output_state:
+ self._output_state[channel_id] = int(bool(value))
+ return True
+ return False
+
+ def get_config_widget(self) -> QWidget:
+ w = QWidget()
+ layout = QVBoxLayout(w)
+ grp = QGroupBox("Output Controls")
+ grp_layout = QVBoxLayout(grp)
+ for k in self._output_state:
+ row = QHBoxLayout()
+ lbl = QLabel(k.upper())
+ btn = QPushButton("OFF")
+ btn.setCheckable(True)
+ btn.setChecked(bool(self._output_state[k]))
+ btn.setText("ON" if self._output_state[k] else "OFF")
+ channel_id = k
+ def on_toggle(checked, cid=channel_id, b=btn):
+ self.write_channel(cid, checked)
+ b.setText("ON" if checked else "OFF")
+ btn.toggled.connect(on_toggle)
+ row.addWidget(lbl)
+ row.addWidget(btn)
+ grp_layout.addLayout(row)
+ layout.addWidget(grp)
+ layout.addStretch()
+ return w
diff --git a/daq_system/devices/serial_device.py b/daq_system/devices/serial_device.py
new file mode 100644
index 0000000..842a401
--- /dev/null
+++ b/daq_system/devices/serial_device.py
@@ -0,0 +1,136 @@
+"""
+devices/serial_device.py
+
+Serial / UART device module — reads data from a serial port.
+Parses CSV-format lines: "ch0,ch1,ch2,...\\n"
+"""
+
+import random
+import time
+from typing import Any, Dict
+
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QFormLayout, QGroupBox,
+ QComboBox, QSpinBox, QLineEdit, QPushButton, QLabel
+)
+from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus
+
+SER_COLORS = ["#7fff6e", "#4cc9f0", "#f72585", "#00d4ff"]
+
+
+class SerialDevice(BaseDevice):
+ DEVICE_TYPE = "serial"
+ ICON = "⇌"
+
+ def __init__(self, device_id: str = "ser_0", port: str = "COM3",
+ baud_rate: int = 115200, num_channels: int = 4,
+ simulate: bool = True):
+ channels = [
+ ChannelConfig(
+ channel_id=f"s{i}", name=f"Serial {i}",
+ unit="", min_value=0.0, max_value=1023.0,
+ color=SER_COLORS[i % len(SER_COLORS)]
+ )
+ for i in range(num_channels)
+ ]
+ info = DeviceInfo(
+ device_id=device_id, name="Serial / UART",
+ device_type=self.DEVICE_TYPE,
+ description=f"Serial port {port} @ {baud_rate} baud",
+ icon=self.ICON, channels=channels
+ )
+ super().__init__(info)
+ self.port = port
+ self.baud_rate = baud_rate
+ self.simulate = simulate
+ self._serial = None # Replace with serial.Serial() for real hardware
+ self._t0 = 0.0
+
+ def connect(self) -> bool:
+ if self.simulate:
+ self._t0 = time.time()
+ self.status = DeviceStatus.SIMULATED
+ return True
+ try:
+ import serial
+ self._serial = serial.Serial(self.port, self.baud_rate, timeout=0.1)
+ self.status = DeviceStatus.CONNECTED
+ return True
+ except Exception as e:
+ print(f"[SerialDevice] Connect failed: {e}")
+ self.status = DeviceStatus.ERROR
+ return False
+
+ def disconnect(self) -> None:
+ if self._serial:
+ try:
+ self._serial.close()
+ except Exception:
+ pass
+ self.status = DeviceStatus.DISCONNECTED
+
+ def read_channels(self) -> Dict[str, float]:
+ if self.simulate:
+ return self._simulate_read()
+ if not self._serial or not self._serial.is_open:
+ return {}
+ try:
+ line = self._serial.readline().decode("utf-8").strip()
+ if not line:
+ return {}
+ parts = line.split(",")
+ return {
+ ch.channel_id: float(parts[i])
+ for i, ch in enumerate(self.info.channels)
+ if i < len(parts)
+ }
+ except Exception:
+ return {}
+
+ def _simulate_read(self) -> Dict[str, float]:
+ t = time.time() - self._t0
+ import math
+ return {
+ ch.channel_id: round(512 + 400 * math.sin(2 * 3.14159 * (0.2 + i * 0.15) * t)
+ + random.gauss(0, 5), 1)
+ for i, ch in enumerate(self.info.channels)
+ }
+
+ def write_channel(self, channel_id: str, value: Any) -> bool:
+ if self._serial and self._serial.is_open:
+ try:
+ cmd = f"{channel_id}:{value}\n"
+ self._serial.write(cmd.encode())
+ return True
+ except Exception:
+ return False
+ return False
+
+ def get_config_widget(self) -> QWidget:
+ w = QWidget()
+ layout = QVBoxLayout(w)
+ grp = QGroupBox("Serial Port Settings")
+ form = QFormLayout(grp)
+
+ self._port_edit = QLineEdit(self.port)
+ form.addRow("Port:", self._port_edit)
+
+ self._baud_cb = QComboBox()
+ self._baud_cb.addItems(["9600", "19200", "38400", "57600", "115200", "230400", "460800"])
+ self._baud_cb.setCurrentText(str(self.baud_rate))
+ form.addRow("Baud Rate:", self._baud_cb)
+
+ parity_cb = QComboBox()
+ parity_cb.addItems(["None", "Even", "Odd"])
+ form.addRow("Parity:", parity_cb)
+
+ bits_cb = QComboBox()
+ bits_cb.addItems(["8", "7"])
+ form.addRow("Data Bits:", bits_cb)
+
+ apply_btn = QPushButton("Apply & Reconnect")
+ form.addRow(apply_btn)
+
+ layout.addWidget(grp)
+ layout.addStretch()
+ return w
diff --git a/daq_system/devices/temperature.py b/daq_system/devices/temperature.py
new file mode 100644
index 0000000..5427cf6
--- /dev/null
+++ b/daq_system/devices/temperature.py
@@ -0,0 +1,115 @@
+"""
+devices/temperature.py
+
+Temperature sensor module — thermocouple / RTD / thermistor inputs.
+"""
+
+import math
+import random
+import time
+from typing import Any, Dict
+
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QFormLayout, QGroupBox,
+ QComboBox, QDoubleSpinBox, QLabel, QCheckBox
+)
+from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus
+
+TEMP_COLORS = ["#ff6b35", "#ffcc00", "#c77dff", "#ff4d6d"]
+
+
+class TemperatureDevice(BaseDevice):
+ DEVICE_TYPE = "temperature"
+ ICON = "🌡"
+
+ def __init__(self, device_id: str = "temp_0", num_channels: int = 4,
+ simulate: bool = True, sensor_type: str = "thermocouple"):
+ channels = [
+ ChannelConfig(
+ channel_id=f"tc{i}", name=f"TC {i}",
+ unit="°C", min_value=-200.0, max_value=1200.0,
+ alarm_low=0.0, alarm_high=100.0,
+ color=TEMP_COLORS[i % len(TEMP_COLORS)]
+ )
+ for i in range(num_channels)
+ ]
+ info = DeviceInfo(
+ device_id=device_id, name="Temperature",
+ device_type=self.DEVICE_TYPE,
+ description=f"{sensor_type.title()} temperature input",
+ icon=self.ICON, channels=channels
+ )
+ super().__init__(info)
+ self.simulate = simulate
+ self.sensor_type = sensor_type
+ self._start = time.time()
+ # Simulate slow thermal drift
+ self._targets = [20.0 + i * 5 for i in range(num_channels)]
+ self._currents = [20.0 + i * 5 for i in range(num_channels)]
+
+ def connect(self) -> bool:
+ self._start = time.time()
+ self.status = DeviceStatus.SIMULATED if self.simulate else DeviceStatus.ERROR
+ return self.simulate
+
+ def disconnect(self) -> None:
+ self.status = DeviceStatus.DISCONNECTED
+
+ def read_channels(self) -> Dict[str, float]:
+ result = {}
+ for i, ch in enumerate(self.info.channels):
+ if not ch.enabled:
+ continue
+ # Slow drift toward target with noise
+ diff = self._targets[i] - self._currents[i]
+ self._currents[i] += diff * 0.05 + random.gauss(0, 0.02)
+ # Occasionally shift target
+ if random.random() < 0.01:
+ self._targets[i] += random.gauss(0, 2.0)
+ self._targets[i] = max(10.0, min(200.0, self._targets[i]))
+ result[ch.channel_id] = round(self._currents[i], 2)
+ return result
+
+ def write_channel(self, channel_id: str, value: Any) -> bool:
+ return False # Read-only
+
+ def get_config_widget(self) -> QWidget:
+ w = QWidget()
+ layout = QVBoxLayout(w)
+ grp = QGroupBox("Sensor Configuration")
+ form = QFormLayout(grp)
+
+ sensor_cb = QComboBox()
+ sensor_cb.addItems(["thermocouple", "rtd", "thermistor", "ic_sensor"])
+ sensor_cb.setCurrentText(self.sensor_type)
+ form.addRow("Sensor Type:", sensor_cb)
+
+ tc_type = QComboBox()
+ tc_type.addItems(["K", "J", "T", "E", "N", "R", "S", "B"])
+ form.addRow("TC Type:", tc_type)
+
+ unit_cb = QComboBox()
+ unit_cb.addItems(["°C", "°F", "K"])
+ form.addRow("Units:", unit_cb)
+
+ layout.addWidget(grp)
+
+ # Alarm config per channel
+ alarm_grp = QGroupBox("Alarm Setpoints")
+ alarm_layout = QVBoxLayout(alarm_grp)
+ for ch in self.info.channels:
+ row_layout = QFormLayout()
+ lo = QDoubleSpinBox()
+ lo.setRange(-200, 1200)
+ lo.setValue(ch.alarm_low or 0.0)
+ lo.setSuffix(" °C")
+ hi = QDoubleSpinBox()
+ hi.setRange(-200, 1200)
+ hi.setValue(ch.alarm_high or 100.0)
+ hi.setSuffix(" °C")
+ row_layout.addRow(f"{ch.name} Low:", lo)
+ row_layout.addRow(f"{ch.name} High:", hi)
+ alarm_layout.addLayout(row_layout)
+ layout.addWidget(alarm_grp)
+ layout.addStretch()
+ return w
diff --git a/daq_system/main.py b/daq_system/main.py
new file mode 100644
index 0000000..d4979b6
--- /dev/null
+++ b/daq_system/main.py
@@ -0,0 +1,28 @@
+"""
+Lab DAQ System - Main Entry Point
+Run this file to launch the application.
+"""
+
+import sys
+from PyQt6.QtWidgets import QApplication
+from PyQt6.QtCore import Qt
+from ui.main_window import MainWindow
+
+
+def main():
+ app = QApplication(sys.argv)
+ app.setApplicationName("LabDAQ")
+ app.setOrganizationName("Lab Instruments")
+ app.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps)
+
+ # Load global stylesheet
+ with open("ui/style.qss", "r") as f:
+ app.setStyleSheet(f.read())
+
+ window = MainWindow()
+ window.show()
+ sys.exit(app.exec())
+
+
+if __name__ == "__main__":
+ main()
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)