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